BurmaldaMusic CLI v0.1.1

This commit is contained in:
Savsis? 2026-08-30 03:30:10 +05:00
commit ff40a1b82d
5 changed files with 531 additions and 0 deletions

482
bm.js Normal file
View file

@ -0,0 +1,482 @@
#!/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}<url>${c.r}`, `скачать FLAC`],
[` ${c.pu}bm${c.r} ${c.ma}<url>${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)
})