#!/usr/bin/env node 'use strict' const { spawn } = require('child_process') const fs = require('fs') const path = require('path') const https = require('https') const http = require('http') const os = require('os') const BIN_DIR = path.join(os.homedir(), 'AppData', 'Roaming', 'bm-cli') const YTDLP = path.join(BIN_DIR, 'yt-dlp.exe') const FFMPEG = path.join(BIN_DIR, 'ffmpeg.exe') const OUT_DIR = path.join(os.homedir(), 'Music', 'BurmaldaMusic') const VERSION = '0.1.1' const FORMATS = ['FLAC', 'WAV', 'MP3', 'OPUS', 'MP4 видео'] const SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'] const sleep = ms => new Promise(r => setTimeout(r, ms)) const c = { r: '\x1b[0m', b: '\x1b[1m', d: '\x1b[2m', pu: '\x1b[35m', ma: '\x1b[95m', cy: '\x1b[36m', wh: '\x1b[37m', gr: '\x1b[90m', gn: '\x1b[32m', ye: '\x1b[33m', re: '\x1b[31m', } const st = { query: '', formatIdx: 0, focus: 'input', mode: 'idle', progress: 0, speed: '', eta: '', file: '', msg: '', setupMsg: '', spinI: 0, results: [], resultIdx: 0, } let spinTimer = null let dlProc = null function w(s) { process.stdout.write(String(s)) } function render() { w('\x1b[2J\x1b[H\x1b[?25l') const L = [] const p = s => L.push(s) p('') p(` ${c.pu}${c.b} ██████╗ ███╗ ███╗${c.r}`) p(` ${c.pu}${c.b} ██╔══██╗████╗ ████║${c.r} ${c.wh}${c.b}BurmaldaMusic${c.r} ${c.gr}v${VERSION}${c.r}`) p(` ${c.pu}${c.b} ██████╔╝██╔████╔██║${c.r}`) p(` ${c.pu}${c.b} ██╔══██╗██║╚██╔╝██║${c.r} ${c.gr}youtube · spotify · soundcloud · apple${c.r}`) p(` ${c.pu}${c.b} ██████╔╝██║ ╚═╝ ██║${c.r}`) p(` ${c.pu}${c.b} ╚═════╝ ╚═╝ ╚═╝${c.r}`) p('') if (st.mode === 'setup') { p(` ${c.cy}${SPIN[st.spinI % 10]}${c.r} ${c.gr}${st.setupMsg}${c.r}`) p('') p(` ${c.gr}первый запуск — скачиваем зависимости...${c.r}`) p('') } else if (st.mode === 'searching') { p(` ${c.cy}${SPIN[st.spinI % 10]}${c.r} ${c.gr}ищем: ${st.query}${c.r}`) p('') } else if (st.mode === 'results') { p(` ${c.gr}выбери трек ↑↓ ↵ скачать esc назад${c.r}`) p('') st.results.forEach((r, i) => { const sel = i === st.resultIdx const num = `${c.gr}${i + 1}.${c.r}` const title = sel ? `${c.pu}${c.b}▶ ${r.title}${c.r}` : ` ${r.title}` const dur = r.duration ? ` ${c.gr}${r.duration}${c.r}` : '' const ch = r.channel ? ` ${c.gr}${r.channel}${c.r}` : '' p(` ${num} ${title}${dur}${ch}`) }) p('') p(` ${c.d}${'─'.repeat(52)}${c.r}`) p(` ${c.gr}↑↓ выбор ↵ скачать esc назад ctrl+c выход${c.r}`) p('') } else if (st.mode === 'downloading') { p(` ${c.pu}${c.b}▸ скачиваем...${c.r}`) p('') const f = Math.min(20, Math.floor(st.progress / 5)) const bar = `${c.pu}${'█'.repeat(f)}${c.r}${c.gr}${'░'.repeat(20-f)}${c.r}` const pct = `${c.b}${st.progress.toFixed(1)}%${c.r}` const sp = st.speed ? `${c.gr}${st.speed}${c.r}` : '' const et = st.eta ? `${c.gr}ETA ${st.eta}${c.r}` : '' p(` ${bar} ${pct} ${sp} ${et}`) p('') if (st.msg) p(` ${c.gr}${st.msg}${c.r}`) p('') p(` ${c.d}${'─'.repeat(52)}${c.r}`) p(` ${c.gr}ctrl+c — отмена загрузки${c.r}`) p('') } else { const iF = st.focus === 'input' const fF = st.focus === 'format' const iB = iF ? c.ma : c.gr const fB = fF ? c.ma : c.gr const qDisp = st.query.length > 44 ? '…' + st.query.slice(-43) : st.query const cursor = iF ? `${c.ma}█${c.r}` : ' ' p(` ${iB}┌─ url или название трека ${'─'.repeat(22)}┐${c.r}`) p(` ${iB}│${c.r} ${iF ? c.wh : c.gr}▸ ${qDisp}${c.r}${cursor}${' '.repeat(Math.max(0, 44 - qDisp.length))}${iB}│${c.r}`) p(` ${iB}└${'─'.repeat(50)}┘${c.r}`) p('') const fmtRow = FORMATS.map((f, i) => i === st.formatIdx ? `${c.pu}${c.b}[ ${f} ]${c.r}` : `${c.gr}${f}${c.r}` ).join(` `) p(` ${fB}формат${c.r} ${c.gr}←${c.r} ${fmtRow} ${c.gr}→${c.r}`) p('') if (st.mode === 'done') { p(` ${c.gn}${c.b}✓ готово!${c.r} ${c.gr}${st.file}${c.r}`) p('') } else if (st.mode === 'error') { p(` ${c.re}✗ ошибка${c.r} ${c.gr}${st.msg}${c.r}`) p('') } else { p('') } p(` ${c.d}${'─'.repeat(52)}${c.r}`) p(` ${c.gr}↑↓ навигация ←→ формат ↵ скачать ctrl+c выход${c.r}`) p(` ${c.gr}made by ${c.r}${c.pu}${c.b}im.savsis.xyz${c.r} ${c.re}♥${c.r}`) p('') } w(L.join('\n')) } function startSpinner(msg) { st.setupMsg = msg if (spinTimer) return spinTimer = setInterval(() => { st.spinI++; render() }, 100) } function stopSpinner() { if (spinTimer) { clearInterval(spinTimer); spinTimer = null } } function dlFile(url, dest) { return new Promise((res, rej) => { const follow = u => { const mod = u.startsWith('https') ? https : http mod.get(u, r => { if ([301,302,303,307,308].includes(r.statusCode)) return follow(r.headers.location) const tmp = dest + '.tmp' const out = fs.createWriteStream(tmp) r.pipe(out) out.on('finish', () => { fs.renameSync(tmp, dest); res() }) out.on('error', rej) r.on('error', rej) }).on('error', rej) } follow(url) }) } async function ensureBins() { if (!fs.existsSync(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true }) if (!fs.existsSync(YTDLP)) { startSpinner('скачиваем yt-dlp.exe ...') await dlFile('https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe', YTDLP) } if (!fs.existsSync(FFMPEG)) { startSpinner('скачиваем ffmpeg.exe ...') const zipPath = path.join(BIN_DIR, 'ffmpeg.zip') const extractDir = path.join(BIN_DIR, 'ffmpeg-extract') await dlFile('https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip', zipPath) startSpinner('распаковываем ffmpeg ...') await new Promise((res, rej) => { const ps = spawn('powershell', ['-Command', `Expand-Archive -Path '${zipPath}' -DestinationPath '${extractDir}' -Force`]) ps.on('close', res) ps.on('error', rej) }) const found = fs.readdirSync(extractDir, { recursive: true }).find(f => String(f).endsWith('ffmpeg.exe')) if (found) fs.copyFileSync(path.join(extractDir, String(found)), FFMPEG) try { fs.rmSync(zipPath); fs.rmSync(extractDir, { recursive: true }) } catch {} } stopSpinner() } function parseProgress(line) { const pm = line.match(/(\d+\.?\d*)%/) if (!pm) return false st.progress = parseFloat(pm[1]) st.speed = line.match(/at\s+([\d.]+\s*\S*\/s)/)?.[1] || st.speed st.eta = line.match(/ETA\s+(\d+:\d+)/)?.[1] || st.eta return true } function doDownload(url, fmtIdx, retry403 = false) { if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true }) const fmt = FORMATS[fmtIdx] const isVideo = fmt === 'MP4 видео' const isApple = url.includes('music.apple.com') const isSpotify = url.includes('spotify.com') const ffmpegDir = path.dirname(FFMPEG) let args = [url, '--no-playlist', '--continue', '--ffmpeg-location', ffmpegDir, '--newline', '--no-colors'] if (isVideo) { args.push('-f', 'bestvideo[height<=1080]+bestaudio/best', '-o', `${OUT_DIR}\\%(title)s.%(ext)s`, '--merge-output-format', 'mp4') } else { const audioFmt = { FLAC:'flac', WAV:'wav', MP3:'mp3', OPUS:'opus' }[fmt] || 'flac' args.push('-x', '--audio-format', audioFmt, '--audio-quality', '0', '-o', `${OUT_DIR}\\%(uploader)s - %(title)s.%(ext)s`, '--embed-thumbnail', '--embed-metadata', '--add-metadata') if (!isApple && !isSpotify) { args.push('--extractor-args', 'youtube:player_client=web_creator,web', '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36') } } if (isApple || retry403) args.push('--cookies-from-browser', 'chrome') st.mode = 'downloading' st.progress = 0; st.speed = ''; st.eta = ''; st.msg = '' render() let got403 = false dlProc = spawn(YTDLP, args) const onData = d => { const line = d.toString() parseProgress(line) const dm = line.match(/(?:Destination|Merging formats into):\s*(.+)/) if (dm) st.file = path.basename(dm[1].trim()) if (line.includes('ExtractAudio')) st.msg = 'конвертируем...' if (line.includes('Merger')) st.msg = 'мержим...' render() } dlProc.stdout.on('data', onData) dlProc.stderr.on('data', d => { const line = d.toString() if (parseProgress(line)) { render(); return } if (!got403 && (line.includes('HTTP Error 403') || line.includes('Sign in'))) { got403 = true dlProc.kill() st.msg = '403 — retry с куками Chrome...' render() doDownload(url, fmtIdx, true) } }) dlProc.on('close', code => { if (got403) return dlProc = null if (code === 0) { st.mode = 'done'; st.progress = 100 } else if (code !== null) { st.mode = 'error' st.msg = `ошибка (код ${code})` } render() }) } async function searchYT(query) { return new Promise(resolve => { const args = [`ytsearch5:${query}`, '--flat-playlist', '-J', '--no-playlist', '--no-colors'] const proc = spawn(YTDLP, args) let out = '' proc.stdout.on('data', d => { out += d }) proc.on('close', () => { try { const data = JSON.parse(out) const entries = data.entries || [] resolve(entries.map(e => ({ url: `https://www.youtube.com/watch?v=${e.id}`, title: e.title || e.id, duration: e.duration ? `${Math.floor(e.duration/60)}:${String(e.duration%60).padStart(2,'0')}` : '', channel: e.uploader || e.channel || '' }))) } catch { resolve([]) } }) }) } function handleKey(key) { const k = typeof key === 'string' ? key : key.toString('utf8') if (k === '\x03') { if (st.mode === 'downloading' && dlProc) { try { dlProc.kill() } catch {} dlProc = null st.mode = 'idle'; st.msg = ''; st.file = '' render() } else if (st.mode === 'searching') { stopSpinner() st.mode = 'idle' render() } else { cleanup() } return } if (k === '\x1b') { if (st.mode === 'results') { st.mode = 'idle'; render() } return } if (st.mode === 'setup' || st.mode === 'downloading' || st.mode === 'searching') return if (st.mode === 'results') { if (k === '\x1b[A') { st.resultIdx = Math.max(0, st.resultIdx - 1); render() } if (k === '\x1b[B') { st.resultIdx = Math.min(st.results.length - 1, st.resultIdx + 1); render() } if (k === '\r' || k === '\n') { const r = st.results[st.resultIdx] if (r) doDownload(r.url, st.formatIdx) } return } if (st.mode === 'done' || st.mode === 'error') { st.mode = 'idle'; st.msg = ''; st.file = '' render(); return } if (k === '\x1b[A') { st.focus = 'input'; render(); return } if (k === '\x1b[B') { st.focus = 'format'; render(); return } if (k === '\x1b[C' && st.focus === 'format') { st.formatIdx = (st.formatIdx + 1) % FORMATS.length; render(); return } if (k === '\x1b[D' && st.focus === 'format') { st.formatIdx = (st.formatIdx - 1 + FORMATS.length) % FORMATS.length; render(); return } if (k === '\r' || k === '\n') { if (!st.query.trim()) return const q = st.query.trim() if (/^https?:\/\//.test(q)) { doDownload(q, st.formatIdx) } else { st.mode = 'searching' startSpinner(`ищем: ${q}`) searchYT(q).then(results => { stopSpinner() if (!results.length) { st.mode = 'error'; st.msg = 'ничего не найдено' } else { st.results = results; st.resultIdx = 0; st.mode = 'results' } render() }) } return } if (st.focus === 'input') { if (k === '\x7f' || k === '\x08') { st.query = st.query.slice(0, -1) } else if (k.length >= 1 && k.charCodeAt(0) >= 32) { st.query += k } render() } } function cleanup() { stopSpinner() if (dlProc) try { dlProc.kill() } catch {} w('\x1b[?25h\x1b[0m\n') process.exit(0) } process.on('SIGINT', cleanup) process.on('exit', () => w('\x1b[?25h\x1b[0m')) const LOGO_LINES = [ ``, ` ${c.pu}${c.b} ██████╗ ███╗ ███╗${c.r}`, ` ${c.pu}${c.b} ██╔══██╗████╗ ████║${c.r}`, ` ${c.pu}${c.b} ██████╔╝██╔████╔██║${c.r}`, ` ${c.pu}${c.b} ██╔══██╗██║╚██╔╝██║${c.r}`, ` ${c.pu}${c.b} ██████╔╝██║ ╚═╝ ██║${c.r}`, ` ${c.pu}${c.b} ╚═════╝ ╚═╝ ╚═╝${c.r}`, `` ] const HELP_LINES = [ [` ${c.b}${c.wh}КАК ПОЛЬЗОВАТЬСЯ${c.r}`, ''], [``, ''], [` ${c.pu}bm${c.r}`, `открыть интерфейс`], [` ${c.pu}bm${c.r} ${c.ma}${c.r}`, `скачать FLAC`], [` ${c.pu}bm${c.r} ${c.ma}${c.r} ${c.gr}--video${c.r}`, `скачать mp4`], [` ${c.pu}bm${c.r} ${c.ma}<название трека>${c.r}`, `поиск → выбор → скачать`], [``, ''], [` ${c.b}${c.wh}ИСТОЧНИКИ${c.r}`, ''], [``, ''], [` ${c.gn}✓${c.r} YouTube · Spotify · SoundCloud · Apple Music`, ''], [``, ''], [` ${c.b}${c.wh}ФОРМАТЫ${c.r}`, ''], [``, ''], [` ${c.gr}FLAC WAV MP3 OPUS MP4 — выбор стрелками ← →${c.r}`, ''], [``, ''], [` ${c.d}${'─'.repeat(46)}${c.r}`, ''], [``, ''], [` ${c.gr}made by ${c.r}${c.pu}${c.b}im.savsis.xyz${c.r} ${c.re}♥${c.r}`, ''], [``, ''], ] async function writeAnimated(s, delay) { for (let i = 0; i < s.length; i++) { if (s[i] === '\x1b') { const e = s.indexOf('m', i); w(s.slice(i, e+1)); i = e } else { w(s[i]); await sleep(delay) } } } async function intro() { w('\x1b[2J\x1b[H\x1b[?25l') for (const line of LOGO_LINES) { const raw = line.replace(/\x1b\[[0-9;]*m/g, '') await writeAnimated(line, raw.trim() ? 3 : 0) w('\n') await sleep(28) } await writeAnimated(` ${c.gr}BurmaldaMusic CLI v${VERSION} — yt · spotify · sc · apple${c.r}`, 13) w('\n') await sleep(70) await writeAnimated(` ${c.d}${'─'.repeat(46)}${c.r}`, 5) w('\n') await sleep(50) for (const [left, right] of HELP_LINES) { if (!left) { w('\n'); await sleep(12); continue } w(left) if (right) w(` ${c.gr}${right}${c.r}`) w('\n') await sleep(28) } await sleep(700) } async function main() { const args = process.argv.slice(2) if (args.length && args[0].startsWith('http')) { w('\x1b[?25l') await ensureBins() doDownload(args[0], args.includes('--video') ? 4 : 0) process.stdin.setRawMode(true) process.stdin.resume() process.stdin.setEncoding('utf8') process.stdin.on('data', handleKey) return } await intro() st.mode = 'setup' render() startSpinner('проверяем зависимости...') await ensureBins() stopSpinner() st.mode = 'idle' render() process.stdin.setRawMode(true) process.stdin.resume() process.stdin.setEncoding('utf8') process.stdin.on('data', handleKey) } main().catch(e => { stopSpinner() w(`\x1b[?25h\x1b[0m\nОшибка: ${e.message}\n`) process.exit(1) })