flac / main.py
Starchik1's picture
Update main.py
ea43d7c verified
# app.py
# Single-file Flask app:
# - Поиск в YouTube Music (ytmusicapi)
# - Встроенный интерфейс + плеер (играет прямо в интерфейсе)
# - Прокси-стрим через сервер (/play/youtube/<video_id>) с поддержкой Range (progressive streaming)
# - Скачивание трека (/download/youtube/<video_id>) -> attachment
#
# Требования:
# pip install flask ytmusicapi requests mutagen
# yt-dlp и ffmpeg должны лежать в той же папке, что и app.py (или быть в PATH).
#
# Запуск:
# python app.py
# Открой http://localhost:5100
from flask import (
Flask, request, jsonify, render_template_string, send_file,
after_this_request, Response, stream_with_context
)
from ytmusicapi import YTMusic
import os, subprocess, tempfile, pathlib, logging, re, shutil, requests
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.logger.setLevel(logging.INFO)
BASE_DIR = pathlib.Path(__file__).resolve().parent
TEMP_DIR = pathlib.Path(tempfile.gettempdir()) / "ytmusic_downloader"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
def find_executable(name: str) -> str:
exe_win = BASE_DIR / f"{name}.exe"
exe_nix = BASE_DIR / name
if exe_win.exists() and os.access(exe_win, os.X_OK):
return str(exe_win)
if exe_nix.exists() and os.access(exe_nix, os.X_OK):
return str(exe_nix)
return name
YTDLP = find_executable("yt-dlp")
FFMPEG = find_executable("ffmpeg")
def clean_filename(s: str) -> str:
if not s:
return ""
s = re.sub(r'[\/:*?"<>|]', ' ', s)
s = re.sub(r'\s+', ' ', s)
return s.strip()
def run_subprocess(cmd, cwd=None, timeout=None):
app.logger.info("Run: %s", " ".join(cmd) if isinstance(cmd, (list,tuple)) else str(cmd))
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
return proc.returncode, stdout.decode(errors='ignore'), stderr.decode(errors='ignore')
# --- HTML шаблон (single file) ---
HTML = """
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>YouTube Music — Поиск и встроенный плеер</title>
<style>
:root{--bg:#0f1115;--card:#15181d;--accent:#1db954;--text:#e6eef1}
body{margin:0;font-family:Inter,Arial,Helvetica,sans-serif;background:var(--bg);color:var(--text)}
.container{max-width:980px;margin:28px auto;padding:20px}
h1{margin:0 0 14px;font-weight:600}
.search{display:flex;gap:8px}
input[type="search"]{flex:1;padding:12px 14px;border-radius:10px;border:1px solid #222;background:#0b0c0f;color:var(--text);outline:none;font-size:16px}
button{background:var(--accent);border:none;color:#fff;padding:10px 14px;border-radius:10px;cursor:pointer;font-weight:600}
.results{margin-top:20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
.card{background:var(--card);padding:12px;border-radius:12px;display:flex;gap:12px;align-items:flex-start;min-height:104px;overflow:hidden}
.thumb{width:84px;height:84px;border-radius:8px;flex:0 0 84px;background:#222;background-size:cover;background-position:center}
.meta{flex:1;display:flex;flex-direction:column;min-width:0}
.title{font-size:15px;font-weight:700;margin-bottom:6px;line-height:1.15;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis}
.sub{font-size:13px;color:#9fb0a0;margin-bottom:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.actions{display:flex;gap:8px;align-items:center;margin-top:auto}
.linkbtn{background:transparent;border:1px solid rgba(255,255,255,0.06);padding:8px 10px;border-radius:8px;color:var(--text);cursor:pointer;text-decoration:none}
.download{background:var(--accent);border:none;color:#05120b;padding:8px 10px;border-radius:8px;font-weight:700;text-decoration:none}
.small{font-size:12px;color:#94a3a1}
.player{position:fixed;left:20px;right:20px;bottom:20px;background:#0f1411;padding:12px;border-radius:12px;display:flex;gap:12px;align-items:center;box-shadow:0 10px 30px rgba(0,0,0,0.6)}
.player .info{flex:1;min-width:0}
.player .controls{display:flex;gap:8px;align-items:center}
.progress-wrap{width:100%;height:8px;background:#0b0c0f;border-radius:6px;overflow:hidden;margin-top:8px;position:relative}
.buffer-bar{height:100%;width:0%;background:linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.04));position:relative}
.play-bar{height:100%;width:0%;background:var(--accent);position:absolute;left:0;top:0}
.time{font-size:12px;color:#9fb0a0;margin-left:8px;min-width:70px;text-align:right}
footer{margin-top:28px;text-align:center;color:#6f7c78;font-size:13px}
/* --- Важное: отдельный стиль для обложки текущего трека --- */
#playerThumb{
width:64px;
height:64px;
border-radius:6px;
background-color:#0b0c0f;
background-size:contain; /* показываем обложку целиком (без обрезки) */
background-repeat:no-repeat;
background-position:center center;
flex:0 0 64px;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
}
/* Иконка-кнопка стилей */
.icon-btn{
display:inline-flex;
align-items:center;
justify-content:center;
width:40px;
height:40px;
border-radius:8px;
background:transparent;
border:1px solid rgba(255,255,255,0.06);
cursor:pointer;
padding:6px;
}
.icon-btn svg{width:20px;height:20px;display:block;fill:currentColor; color:var(--text);}
@media (max-width:600px){
.player{left:10px;right:10px;bottom:10px;padding:8px}
.thumb{width:56px;height:56px}
#playerThumb{width:56px;height:56px;flex:0 0 56px}
}
</style>
</head>
<body>
<div class="container">
<h1>🔎 YouTube Music — Поиск и встроенный плеер</h1>
<div class="search">
<input id="q" type="search" placeholder="Введите исполнителя, название трека или альбома" />
<button id="btnSearch">Поиск</button>
</div>
<div id="msg" class="small" style="margin-top:10px"></div>
<div id="results" class="results"></div>
<footer>Интерфейс в одном файле. yt-dlp и ffmpeg должны быть в той же папке, что и сервер.</footer>
</div>
<div id="player" class="player" style="display:none;">
<div id="playerThumb"></div>
<div class="info">
<div id="playerTitle" style="font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>
<div id="playerArtist" class="small"></div>
<div class="progress-wrap" aria-hidden="true">
<div class="buffer-bar" id="bufferBar"><div class="play-bar" id="playBar"></div></div>
</div>
</div>
<div class="controls" style="flex-direction:column;align-items:flex-end">
<div style="display:flex;gap:8px;align-items:center">
<!-- Кнопка теперь с иконкой, accessible aria-label -->
<button id="playPause" class="icon-btn" aria-label="Play">
<!-- иконка вставляется JS (play by default) -->
<svg id="playIcon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 5v14l11-7z"></path>
</svg>
</button>
<a id="downloadCurrent" class="download" href="#" >⬇ Save</a>
</div>
<div style="display:flex;align-items:center;margin-top:6px">
<div id="currentTime" class="small">0:00</div>
<div class="small" style="margin:0 6px">/</div>
<div id="duration" class="small">0:00</div>
</div>
</div>
<audio id="audio" preload="none"></audio>
</div>
<script>
const resultsEl = document.getElementById('results');
const msgEl = document.getElementById('msg');
const audio = document.getElementById('audio');
const player = document.getElementById('player');
const playerTitle = document.getElementById('playerTitle');
const playerArtist = document.getElementById('playerArtist');
const playerThumb = document.getElementById('playerThumb');
const playPauseBtn = document.getElementById('playPause');
const downloadCurrent = document.getElementById('downloadCurrent');
const bufferBar = document.getElementById('bufferBar');
const playBar = document.getElementById('playBar');
const currentTimeEl = document.getElementById('currentTime');
const durationEl = document.getElementById('duration');
// SVGs для переключения (используем innerHTML)
const SVG_PLAY = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M8 5v14l11-7z"></path></svg>';
const SVG_PAUSE = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z"></path></svg>';
document.getElementById('btnSearch').addEventListener('click', doSearch);
document.getElementById('q').addEventListener('keydown', (e) => { if(e.key === 'Enter') doSearch(); });
let currentVideoId = null;
let currentThumbnail = '';
function formatTime(secs){
if(!secs || isNaN(secs)) return '0:00';
const s = Math.floor(secs % 60).toString().padStart(2,'0');
const m = Math.floor(secs/60);
return m + ':' + s;
}
async function doSearch(){
const q = document.getElementById('q').value.trim();
resultsEl.innerHTML = '';
msgEl.textContent = '';
if(!q){ msgEl.textContent = 'Введите текст для поиска'; return; }
msgEl.textContent = 'Идёт поиск...';
try {
const resp = await fetch('/search/youtube?query=' + encodeURIComponent(q) + '&limit=12');
const data = await resp.json();
if(!data.success){ msgEl.textContent = 'Ошибка поиска: ' + (data.error || 'unknown'); return; }
const items = data.results;
if(items.length === 0){ msgEl.textContent = 'Ничего не найдено'; return; }
msgEl.textContent = 'Найдено ' + items.length + ' результатов';
for(const it of items){
const card = document.createElement('div'); card.className = 'card';
const thumb = document.createElement('div'); thumb.className = 'thumb';
if(it.thumbnail) thumb.style.backgroundImage = 'url(' + it.thumbnail + ')';
const meta = document.createElement('div'); meta.className = 'meta';
const title = document.createElement('div'); title.className = 'title'; title.textContent = it.title;
const sub = document.createElement('div'); sub.className = 'sub'; sub.textContent = (it.artist ? it.artist + ' · ' : '') + (it.duration || '');
const actions = document.createElement('div'); actions.className = 'actions';
const play = document.createElement('button'); play.className = 'linkbtn'; play.textContent = '▶ Play';
play.addEventListener('click', () => playInline(it));
const dl = document.createElement('a'); dl.className = 'download'; dl.textContent = '⬇ Download';
dl.href = '/download/youtube/' + encodeURIComponent(it.videoId);
dl.setAttribute('download','');
actions.appendChild(play);
actions.appendChild(dl);
meta.appendChild(title);
meta.appendChild(sub);
meta.appendChild(actions);
card.appendChild(thumb);
card.appendChild(meta);
resultsEl.appendChild(card);
}
} catch(e){
console.error(e);
msgEl.textContent = 'Ошибка: ' + e.message;
}
}
function playInline(item){
try {
msgEl.textContent = 'Подключение к потоку...';
const src = '/play/youtube/' + encodeURIComponent(item.videoId);
audio.src = src;
audio.crossOrigin = "anonymous";
audio.load(); // начнём загрузку
audio.play().then(()=> {
// OK
setPauseIcon();
}).catch(err => {
console.warn('play() failed:', err);
// всё равно покажем pause icon if not started? оставим play icon
});
currentVideoId = item.videoId;
currentThumbnail = item.thumbnail || '';
playerTitle.textContent = item.title || '';
playerArtist.textContent = item.artist || '';
if(currentThumbnail) {
playerThumb.style.backgroundImage = 'url(' + currentThumbnail + ')';
} else {
playerThumb.style.backgroundImage = '';
}
player.style.display = 'flex';
setPauseIcon(); // переключаем иконку в pause (пока проигрываем)
downloadCurrent.href = '/download/youtube/' + encodeURIComponent(item.videoId);
msgEl.textContent = '';
} catch(e){
console.error(e);
msgEl.textContent = 'Ошибка воспроизведения: ' + e.message;
}
}
function setPlayIcon(){
playPauseBtn.innerHTML = SVG_PLAY;
playPauseBtn.setAttribute('aria-label','Play');
}
function setPauseIcon(){
playPauseBtn.innerHTML = SVG_PAUSE;
playPauseBtn.setAttribute('aria-label','Pause');
}
playPauseBtn.addEventListener('click', async () => {
if(audio.paused){
try{ await audio.play(); setPauseIcon(); }catch(e){ console.error(e); }
} else {
audio.pause(); setPlayIcon();
}
});
audio.addEventListener('play', () => {
setPauseIcon();
});
audio.addEventListener('pause', () => {
setPlayIcon();
});
audio.addEventListener('timeupdate', () => {
const dur = audio.duration || 0;
const cur = audio.currentTime || 0;
if(dur > 0){
const pct = (cur/dur)*100;
playBar.style.width = pct + '%';
currentTimeEl.textContent = formatTime(cur);
durationEl.textContent = formatTime(dur);
} else {
currentTimeEl.textContent = formatTime(audio.currentTime);
}
});
audio.addEventListener('progress', () => {
const dur = audio.duration || 0;
if(dur > 0 && audio.buffered.length){
const bufferedEnd = audio.buffered.end(audio.buffered.length - 1);
const pct = Math.min(100, (bufferedEnd / dur) * 100);
bufferBar.style.width = pct + '%';
}
});
audio.addEventListener('loadedmetadata', () => {
durationEl.textContent = formatTime(audio.duration);
});
// Инициализация: ставим play иконку по умолчанию
setPlayIcon();
audio.addEventListener('error', (e) => {
console.error('Audio error', e);
msgEl.textContent = 'Ошибка воспроизведения. Проверьте логи сервера.';
});
</script>
</body>
</html>
"""
@app.route("/")
def index():
return render_template_string(HTML)
# Поиск
@app.route('/search/youtube', methods=['GET'])
def search_youtube_music():
query = request.args.get('query', '').strip()
limit = int(request.args.get('limit', 10))
if not query:
return jsonify({'success': False, 'error': 'Поисковый запрос не указан'}), 400
try:
ytmusic = YTMusic()
results = ytmusic.search(query, filter='songs', limit=limit)
formatted = []
for r in results:
if r.get('resultType') != 'song':
continue
title = r.get('title') or ''
artists = r.get('artists') or []
artist_name = artists[0]['name'] if artists and isinstance(artists, list) and 'name' in artists[0] else ''
duration = r.get('duration', '')
videoId = r.get('videoId', '')
thumbnails = r.get('thumbnails') or []
thumb = thumbnails[-1]['url'] if thumbnails else ''
formatted.append({
'title': title,
'artist': artist_name,
'duration': duration,
'videoId': videoId,
'thumbnail': thumb
})
return jsonify({'success': True, 'results': formatted})
except Exception as e:
app.logger.exception("Ошибка при поиске в YTMusic")
return jsonify({'success': False, 'error': str(e)}), 500
# Прокси-стрим с поддержкой Range и низкоуровневой передачей
@app.route('/play/youtube/<video_id>', methods=['GET', 'HEAD'])
def play_proxy(video_id):
if not video_id:
return ("video_id не указан", 400)
try:
# Получаем прямой URL аудиопотока через yt-dlp -g
cmd = [YTDLP, '-f', 'bestaudio', '-g', f'https://www.youtube.com/watch?v={video_id}']
rc, out, err = run_subprocess(cmd, cwd=str(BASE_DIR), timeout=20)
if rc != 0:
app.logger.error("yt-dlp -g error: %s", err)
return (f"Ошибка получения аудиопотока: {err}", 500)
audio_url = out.strip().splitlines()[0] if out else ''
if not audio_url:
app.logger.error("Не получили URL из yt-dlp -g")
return ("Не удалось получить URL аудиопотока", 500)
# Пробрасываем Range если пришёл
headers = {"User-Agent": request.headers.get("User-Agent", "ytmusic-downloader-proxy/1.0")}
range_header = request.headers.get('Range')
if range_header:
headers['Range'] = range_header
# Делаем запрос к upstream (stream=True) и используем raw для чтения
upstream = requests.get(audio_url, headers=headers, stream=True, allow_redirects=True, timeout=15)
app.logger.info("Upstream headers: %s", dict(upstream.headers))
if upstream.status_code not in (200, 206):
app.logger.error("Upstream returned status %s", upstream.status_code)
return (f"Upstream returned {upstream.status_code}", 500)
# Формируем заголовки ответа на основе upstream
resp_headers = {}
content_type = upstream.headers.get('Content-Type', 'audio/mpeg')
resp_headers['Content-Type'] = content_type
if 'Content-Length' in upstream.headers:
resp_headers['Content-Length'] = upstream.headers['Content-Length']
if 'Content-Range' in upstream.headers:
resp_headers['Content-Range'] = upstream.headers['Content-Range']
resp_headers['Accept-Ranges'] = upstream.headers.get('Accept-Ranges', 'bytes')
resp_headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
if request.method == 'HEAD':
return Response(status=upstream.status_code, headers=resp_headers)
# Низкоуровневая генерация байтов — upstream.raw.read
upstream.raw.decode_content = True
def generate():
try:
while True:
chunk = upstream.raw.read(16384)
if not chunk:
break
yield chunk
finally:
try:
upstream.close()
except Exception:
pass
status_code = upstream.status_code if upstream.status_code in (200,206) else 200
return Response(stream_with_context(generate()), headers=resp_headers, status=status_code)
except requests.exceptions.RequestException as e:
app.logger.exception("Ошибка запроса к upstream")
return (f"Ошибка запроса к upstream: {str(e)}", 500)
except Exception as e:
app.logger.exception("Ошибка stream proxy")
return (f"Ошибка сервера: {str(e)}", 500)
# favicon чтобы убрать 404
@app.route('/favicon.ico')
def favicon():
return ('', 204)
# Скачать трек и вернуть как attachment
@app.route('/download/youtube/<video_id>', methods=['GET','POST'])
def download_youtube(video_id):
if not video_id:
return jsonify({'success': False, 'error': 'video_id не указан'}), 400
try:
title = None
artist = None
try:
ytmusic = YTMusic()
info = ytmusic.get_song(video_id)
if info:
title = info.get('videoDetails', {}).get('title') or title
author = info.get('videoDetails', {}).get('author')
if author:
artist = author
for k in ('artists','artist'):
if k in info and isinstance(info[k], list):
names = []
for a in info[k]:
if isinstance(a, dict) and a.get('name'):
names.append(a.get('name'))
elif isinstance(a, str):
names.append(a)
if names:
artist = ', '.join(names)
except Exception:
app.logger.info("YTMusic info failed, fallback to id")
safe_title = clean_filename(title) if title else video_id
safe_artist = clean_filename(artist) if artist else "unknown"
final_name = f"{safe_artist} - {safe_title}.mp3"
final_name = secure_filename(final_name)
temp_template = TEMP_DIR / f"temp_{video_id}_{os.getpid()}.%(ext)s"
temp_template_str = str(temp_template)
music_url = f"https://music.youtube.com/watch?v={video_id}"
cmd = [
YTDLP,
'--no-playlist',
'--extract-audio',
'--audio-format', 'mp3',
'--audio-quality', '0',
'--add-metadata',
'--embed-thumbnail',
'--no-keep-video',
'--no-overwrites',
'--prefer-ffmpeg',
'--output', temp_template_str,
music_url
]
rc, out, err = run_subprocess(cmd, cwd=str(BASE_DIR))
if rc != 0:
app.logger.error("yt-dlp error: %s", err)
return jsonify({'success': False, 'error': err}), 500
created_files = list(TEMP_DIR.glob(f"temp_{video_id}_*.*"))
created = None
if created_files:
created = sorted(created_files, key=lambda p: p.stat().st_mtime, reverse=True)[0]
else:
mp3s = sorted(TEMP_DIR.glob("*.mp3"), key=lambda p: p.stat().st_mtime, reverse=True)
if mp3s:
created = mp3s[0]
if not created or not created.exists():
app.logger.error("mp3 not found after yt-dlp")
return jsonify({'success': False, 'error': 'mp3 not found after yt-dlp'}), 500
send_path = TEMP_DIR / final_name
try:
created.replace(send_path)
except Exception:
shutil.copy2(created, send_path)
@after_this_request
def cleanup(response):
try:
if send_path.exists():
send_path.unlink()
if created.exists():
try:
created.unlink()
except Exception:
pass
except Exception:
app.logger.exception("cleanup error")
return response
return send_file(str(send_path), as_attachment=True, download_name=final_name)
except Exception as e:
app.logger.exception("download error")
return jsonify({'success': False, 'error': str(e)}), 500
if __name__ == "__main__":
TEMP_DIR.mkdir(parents=True, exist_ok=True)
app.run(host="0.0.0.0", port=5100, debug=True)