bm-downloader/bm.js

493 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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', '--progress']
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,channel|Unknown)s - %(title)s.%(ext)s`,
'--embed-metadata', '--embed-thumbnail')
if (!isApple && !isSpotify) {
args.push('--extractor-args', 'youtube:player_client=web_creator,web')
}
}
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
let lastErr = ''
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()
const lineL = line.toLowerCase()
if (parseProgress(line)) { render(); return }
if (lineL.includes('error') || lineL.includes('warning')) {
lastErr = line.replace(/\x1b\[[0-9;]*m/g, '').trim().slice(0, 90)
st.msg = lastErr
render()
}
const needsCookies = lineL.includes('403') || lineL.includes('sign in') ||
lineL.includes('please sign') || lineL.includes('cookies-from-browser') ||
lineL.includes('login required') || lineL.includes('not available')
if (!got403 && needsCookies && !retry403) {
got403 = true
dlProc.kill()
st.msg = 'нужны куки Chrome — retry...'
render()
doDownload(url, fmtIdx, true)
}
})
dlProc.on('close', code => {
if (got403) return
dlProc = null
if (code === 0) {
st.mode = 'done'; st.progress = 100; st.msg = ''
} else if (code !== null) {
st.mode = 'error'
st.msg = lastErr || `ошибка (код ${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)
})