commit ff40a1b82d0988f21a50f646dadaf6c334af01d6 Author: devsavsis Date: Sun Aug 30 03:30:10 2026 +0500 BurmaldaMusic CLI v0.1.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e03b3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.exe diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6ed1d3 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# bm — BurmaldaMusic Downloader + +youtube / spotify / soundcloud / apple music → FLAC WAV MP3 OPUS MP4 + +## установка + +скачай `bm.exe` из [releases](https://github.com/devsavsis/bm-downloader/releases) и кинь куда хочешь + +зависимости (yt-dlp, ffmpeg) скачаются сами при первом запуске + +## использование + +``` +bm — интерактивный интерфейс +bm — скачать (FLAC по умолчанию) +bm --video — видео mp4 +``` + +## интерфейс + +- **↑↓** — переключаться между вводом и форматом +- **←→** — выбрать формат (FLAC WAV MP3 OPUS MP4) +- **↵** — скачать +- **ctrl+c** — выход + +made by [im.savsis.xyz](https://im.savsis.xyz) ♥ diff --git a/bm.js b/bm.js new file mode 100644 index 0000000..f55da61 --- /dev/null +++ b/bm.js @@ -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}${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) +}) diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..08116fa Binary files /dev/null and b/icon.ico differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..6eebcc3 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "bm-downloader", + "version": "0.1.1", + "description": "BurmaldaMusic CLI — youtube/spotify/soundcloud/apple → FLAC", + "author": "ImSavsis (https://im.savsis.xyz)", + "license": "MIT", + "bin": { + "bm": "bm.js" + }, + "pkg": { + "targets": [ + "node18-win-x64" + ], + "outputPath": "dist", + "assets": [] + }, + "devDependencies": { + "png-to-ico": "^3.0.2" + } +}