Starchik1 commited on
Commit
ea43d7c
·
verified ·
1 Parent(s): c0c6729

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +548 -24
main.py CHANGED
@@ -1,24 +1,548 @@
1
- from flask import Flask, render_template, send_file
2
- import os
3
-
4
- app = Flask(__name__)
5
-
6
- # Путь к папке с музыкой (предполагается, что файлы уже скачаны)
7
- MUSIC_FOLDER = 'static/music'
8
-
9
- @app.route('/')
10
- def index():
11
- # Получаем список музыкальных файлов
12
- music_files = []
13
- for file in os.listdir(MUSIC_FOLDER):
14
- if file.endswith(('.mp3', '.wav', '.ogg')):
15
- music_files.append(file)
16
- return render_template('player.html', music_files=music_files)
17
-
18
- @app.route('/music/<path:filename>')
19
- def serve_music(filename):
20
- # Безопасная отправка файла
21
- return send_file(os.path.join(MUSIC_FOLDER, filename))
22
-
23
- if __name__ == '__main__':
24
- app.run(debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ # Single-file Flask app:
3
+ # - Поиск в YouTube Music (ytmusicapi)
4
+ # - Встроенный интерфейс + плеер (играет прямо в интерфейсе)
5
+ # - Прокси-стрим через сервер (/play/youtube/<video_id>) с поддержкой Range (progressive streaming)
6
+ # - Скачивание трека (/download/youtube/<video_id>) -> attachment
7
+ #
8
+ # Требования:
9
+ # pip install flask ytmusicapi requests mutagen
10
+ # yt-dlp и ffmpeg должны лежать в той же папке, что и app.py (или быть в PATH).
11
+ #
12
+ # Запуск:
13
+ # python app.py
14
+ # Открой http://localhost:5100
15
+
16
+ from flask import (
17
+ Flask, request, jsonify, render_template_string, send_file,
18
+ after_this_request, Response, stream_with_context
19
+ )
20
+ from ytmusicapi import YTMusic
21
+ import os, subprocess, tempfile, pathlib, logging, re, shutil, requests
22
+ from werkzeug.utils import secure_filename
23
+
24
+ app = Flask(__name__)
25
+ app.logger.setLevel(logging.INFO)
26
+
27
+ BASE_DIR = pathlib.Path(__file__).resolve().parent
28
+ TEMP_DIR = pathlib.Path(tempfile.gettempdir()) / "ytmusic_downloader"
29
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
30
+
31
+ def find_executable(name: str) -> str:
32
+ exe_win = BASE_DIR / f"{name}.exe"
33
+ exe_nix = BASE_DIR / name
34
+ if exe_win.exists() and os.access(exe_win, os.X_OK):
35
+ return str(exe_win)
36
+ if exe_nix.exists() and os.access(exe_nix, os.X_OK):
37
+ return str(exe_nix)
38
+ return name
39
+
40
+ YTDLP = find_executable("yt-dlp")
41
+ FFMPEG = find_executable("ffmpeg")
42
+
43
+ def clean_filename(s: str) -> str:
44
+ if not s:
45
+ return ""
46
+ s = re.sub(r'[\/:*?"<>|]', ' ', s)
47
+ s = re.sub(r'\s+', ' ', s)
48
+ return s.strip()
49
+
50
+ def run_subprocess(cmd, cwd=None, timeout=None):
51
+ app.logger.info("Run: %s", " ".join(cmd) if isinstance(cmd, (list,tuple)) else str(cmd))
52
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)
53
+ try:
54
+ stdout, stderr = proc.communicate(timeout=timeout)
55
+ except subprocess.TimeoutExpired:
56
+ proc.kill()
57
+ stdout, stderr = proc.communicate()
58
+ return proc.returncode, stdout.decode(errors='ignore'), stderr.decode(errors='ignore')
59
+
60
+ # --- HTML шаблон (single file) ---
61
+ HTML = """
62
+ <!doctype html>
63
+ <html lang="ru">
64
+ <head>
65
+ <meta charset="utf-8" />
66
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
67
+ <title>YouTube Music — Поиск и встроенный плеер</title>
68
+ <style>
69
+ :root{--bg:#0f1115;--card:#15181d;--accent:#1db954;--text:#e6eef1}
70
+ body{margin:0;font-family:Inter,Arial,Helvetica,sans-serif;background:var(--bg);color:var(--text)}
71
+ .container{max-width:980px;margin:28px auto;padding:20px}
72
+ h1{margin:0 0 14px;font-weight:600}
73
+ .search{display:flex;gap:8px}
74
+ 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}
75
+ button{background:var(--accent);border:none;color:#fff;padding:10px 14px;border-radius:10px;cursor:pointer;font-weight:600}
76
+ .results{margin-top:20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
77
+ .card{background:var(--card);padding:12px;border-radius:12px;display:flex;gap:12px;align-items:flex-start;min-height:104px;overflow:hidden}
78
+ .thumb{width:84px;height:84px;border-radius:8px;flex:0 0 84px;background:#222;background-size:cover;background-position:center}
79
+ .meta{flex:1;display:flex;flex-direction:column;min-width:0}
80
+ .title{font-size:15px;font-weight:700;margin-bottom:6px;line-height:1.15;
81
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis}
82
+ .sub{font-size:13px;color:#9fb0a0;margin-bottom:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
83
+ .actions{display:flex;gap:8px;align-items:center;margin-top:auto}
84
+ .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}
85
+ .download{background:var(--accent);border:none;color:#05120b;padding:8px 10px;border-radius:8px;font-weight:700;text-decoration:none}
86
+ .small{font-size:12px;color:#94a3a1}
87
+ .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)}
88
+ .player .info{flex:1;min-width:0}
89
+ .player .controls{display:flex;gap:8px;align-items:center}
90
+ .progress-wrap{width:100%;height:8px;background:#0b0c0f;border-radius:6px;overflow:hidden;margin-top:8px;position:relative}
91
+ .buffer-bar{height:100%;width:0%;background:linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.04));position:relative}
92
+ .play-bar{height:100%;width:0%;background:var(--accent);position:absolute;left:0;top:0}
93
+ .time{font-size:12px;color:#9fb0a0;margin-left:8px;min-width:70px;text-align:right}
94
+ footer{margin-top:28px;text-align:center;color:#6f7c78;font-size:13px}
95
+
96
+ /* --- Важное: отдельный стиль для обложки текущего трека --- */
97
+ #playerThumb{
98
+ width:64px;
99
+ height:64px;
100
+ border-radius:6px;
101
+ background-color:#0b0c0f;
102
+ background-size:contain; /* показываем обложку целиком (без обрезки) */
103
+ background-repeat:no-repeat;
104
+ background-position:center center;
105
+ flex:0 0 64px;
106
+ box-shadow: 0 2px 8px rgba(0,0,0,0.6);
107
+ }
108
+
109
+ /* Иконка-кнопка стилей */
110
+ .icon-btn{
111
+ display:inline-flex;
112
+ align-items:center;
113
+ justify-content:center;
114
+ width:40px;
115
+ height:40px;
116
+ border-radius:8px;
117
+ background:transparent;
118
+ border:1px solid rgba(255,255,255,0.06);
119
+ cursor:pointer;
120
+ padding:6px;
121
+ }
122
+ .icon-btn svg{width:20px;height:20px;display:block;fill:currentColor; color:var(--text);}
123
+
124
+ @media (max-width:600px){
125
+ .player{left:10px;right:10px;bottom:10px;padding:8px}
126
+ .thumb{width:56px;height:56px}
127
+ #playerThumb{width:56px;height:56px;flex:0 0 56px}
128
+ }
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <div class="container">
133
+ <h1>🔎 YouTube Music — Поиск и встроенный плеер</h1>
134
+ <div class="search">
135
+ <input id="q" type="search" placeholder="Введите исполнителя, название трека или альбома" />
136
+ <button id="btnSearch">Поиск</button>
137
+ </div>
138
+ <div id="msg" class="small" style="margin-top:10px"></div>
139
+ <div id="results" class="results"></div>
140
+ <footer>Интерфейс в одном файле. yt-dlp и ffmpeg должны быть в той же папке, что и сервер.</footer>
141
+ </div>
142
+
143
+ <div id="player" class="player" style="display:none;">
144
+ <div id="playerThumb"></div>
145
+ <div class="info">
146
+ <div id="playerTitle" style="font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>
147
+ <div id="playerArtist" class="small"></div>
148
+ <div class="progress-wrap" aria-hidden="true">
149
+ <div class="buffer-bar" id="bufferBar"><div class="play-bar" id="playBar"></div></div>
150
+ </div>
151
+ </div>
152
+ <div class="controls" style="flex-direction:column;align-items:flex-end">
153
+ <div style="display:flex;gap:8px;align-items:center">
154
+ <!-- Кнопка теперь с иконкой, accessible aria-label -->
155
+ <button id="playPause" class="icon-btn" aria-label="Play">
156
+ <!-- иконка вставляется JS (play by default) -->
157
+ <svg id="playIcon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
158
+ <path d="M8 5v14l11-7z"></path>
159
+ </svg>
160
+ </button>
161
+ <a id="downloadCurrent" class="download" href="#" >⬇ Save</a>
162
+ </div>
163
+ <div style="display:flex;align-items:center;margin-top:6px">
164
+ <div id="currentTime" class="small">0:00</div>
165
+ <div class="small" style="margin:0 6px">/</div>
166
+ <div id="duration" class="small">0:00</div>
167
+ </div>
168
+ </div>
169
+ <audio id="audio" preload="none"></audio>
170
+ </div>
171
+
172
+ <script>
173
+ const resultsEl = document.getElementById('results');
174
+ const msgEl = document.getElementById('msg');
175
+ const audio = document.getElementById('audio');
176
+ const player = document.getElementById('player');
177
+ const playerTitle = document.getElementById('playerTitle');
178
+ const playerArtist = document.getElementById('playerArtist');
179
+ const playerThumb = document.getElementById('playerThumb');
180
+ const playPauseBtn = document.getElementById('playPause');
181
+ const downloadCurrent = document.getElementById('downloadCurrent');
182
+ const bufferBar = document.getElementById('bufferBar');
183
+ const playBar = document.getElementById('playBar');
184
+ const currentTimeEl = document.getElementById('currentTime');
185
+ const durationEl = document.getElementById('duration');
186
+
187
+ // SVGs для переключения (используем innerHTML)
188
+ 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>';
189
+ 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>';
190
+
191
+ document.getElementById('btnSearch').addEventListener('click', doSearch);
192
+ document.getElementById('q').addEventListener('keydown', (e) => { if(e.key === 'Enter') doSearch(); });
193
+
194
+ let currentVideoId = null;
195
+ let currentThumbnail = '';
196
+
197
+ function formatTime(secs){
198
+ if(!secs || isNaN(secs)) return '0:00';
199
+ const s = Math.floor(secs % 60).toString().padStart(2,'0');
200
+ const m = Math.floor(secs/60);
201
+ return m + ':' + s;
202
+ }
203
+
204
+ async function doSearch(){
205
+ const q = document.getElementById('q').value.trim();
206
+ resultsEl.innerHTML = '';
207
+ msgEl.textContent = '';
208
+ if(!q){ msgEl.textContent = 'Введите текст для поиска'; return; }
209
+ msgEl.textContent = 'Идёт поиск...';
210
+ try {
211
+ const resp = await fetch('/search/youtube?query=' + encodeURIComponent(q) + '&limit=12');
212
+ const data = await resp.json();
213
+ if(!data.success){ msgEl.textContent = 'Ошибка поиска: ' + (data.error || 'unknown'); return; }
214
+ const items = data.results;
215
+ if(items.length === 0){ msgEl.textContent = 'Ничего не найдено'; return; }
216
+ msgEl.textContent = 'Найдено ' + items.length + ' результатов';
217
+ for(const it of items){
218
+ const card = document.createElement('div'); card.className = 'card';
219
+ const thumb = document.createElement('div'); thumb.className = 'thumb';
220
+ if(it.thumbnail) thumb.style.backgroundImage = 'url(' + it.thumbnail + ')';
221
+ const meta = document.createElement('div'); meta.className = 'meta';
222
+ const title = document.createElement('div'); title.className = 'title'; title.textContent = it.title;
223
+ const sub = document.createElement('div'); sub.className = 'sub'; sub.textContent = (it.artist ? it.artist + ' · ' : '') + (it.duration || '');
224
+ const actions = document.createElement('div'); actions.className = 'actions';
225
+ const play = document.createElement('button'); play.className = 'linkbtn'; play.textContent = '▶ Play';
226
+ play.addEventListener('click', () => playInline(it));
227
+ const dl = document.createElement('a'); dl.className = 'download'; dl.textContent = '⬇ Download';
228
+ dl.href = '/download/youtube/' + encodeURIComponent(it.videoId);
229
+ dl.setAttribute('download','');
230
+ actions.appendChild(play);
231
+ actions.appendChild(dl);
232
+ meta.appendChild(title);
233
+ meta.appendChild(sub);
234
+ meta.appendChild(actions);
235
+ card.appendChild(thumb);
236
+ card.appendChild(meta);
237
+ resultsEl.appendChild(card);
238
+ }
239
+ } catch(e){
240
+ console.error(e);
241
+ msgEl.textContent = 'Ошибка: ' + e.message;
242
+ }
243
+ }
244
+
245
+ function playInline(item){
246
+ try {
247
+ msgEl.textContent = 'Подключение к потоку...';
248
+ const src = '/play/youtube/' + encodeURIComponent(item.videoId);
249
+ audio.src = src;
250
+ audio.crossOrigin = "anonymous";
251
+ audio.load(); // начнём загрузку
252
+ audio.play().then(()=> {
253
+ // OK
254
+ setPauseIcon();
255
+ }).catch(err => {
256
+ console.warn('play() failed:', err);
257
+ // всё равно покажем pause icon if not started? оставим play icon
258
+ });
259
+ currentVideoId = item.videoId;
260
+ currentThumbnail = item.thumbnail || '';
261
+ playerTitle.textContent = item.title || '';
262
+ playerArtist.textContent = item.artist || '';
263
+ if(currentThumbnail) {
264
+ playerThumb.style.backgroundImage = 'url(' + currentThumbnail + ')';
265
+ } else {
266
+ playerThumb.style.backgroundImage = '';
267
+ }
268
+ player.style.display = 'flex';
269
+ setPauseIcon(); // переключаем иконку в pause (пока проигрываем)
270
+ downloadCurrent.href = '/download/youtube/' + encodeURIComponent(item.videoId);
271
+ msgEl.textContent = '';
272
+ } catch(e){
273
+ console.error(e);
274
+ msgEl.textContent = 'Ошибка воспроизведения: ' + e.message;
275
+ }
276
+ }
277
+
278
+ function setPlayIcon(){
279
+ playPauseBtn.innerHTML = SVG_PLAY;
280
+ playPauseBtn.setAttribute('aria-label','Play');
281
+ }
282
+ function setPauseIcon(){
283
+ playPauseBtn.innerHTML = SVG_PAUSE;
284
+ playPauseBtn.setAttribute('aria-label','Pause');
285
+ }
286
+
287
+ playPauseBtn.addEventListener('click', async () => {
288
+ if(audio.paused){
289
+ try{ await audio.play(); setPauseIcon(); }catch(e){ console.error(e); }
290
+ } else {
291
+ audio.pause(); setPlayIcon();
292
+ }
293
+ });
294
+
295
+ audio.addEventListener('play', () => {
296
+ setPauseIcon();
297
+ });
298
+ audio.addEventListener('pause', () => {
299
+ setPlayIcon();
300
+ });
301
+
302
+ audio.addEventListener('timeupdate', () => {
303
+ const dur = audio.duration || 0;
304
+ const cur = audio.currentTime || 0;
305
+ if(dur > 0){
306
+ const pct = (cur/dur)*100;
307
+ playBar.style.width = pct + '%';
308
+ currentTimeEl.textContent = formatTime(cur);
309
+ durationEl.textContent = formatTime(dur);
310
+ } else {
311
+ currentTimeEl.textContent = formatTime(audio.currentTime);
312
+ }
313
+ });
314
+
315
+ audio.addEventListener('progress', () => {
316
+ const dur = audio.duration || 0;
317
+ if(dur > 0 && audio.buffered.length){
318
+ const bufferedEnd = audio.buffered.end(audio.buffered.length - 1);
319
+ const pct = Math.min(100, (bufferedEnd / dur) * 100);
320
+ bufferBar.style.width = pct + '%';
321
+ }
322
+ });
323
+
324
+ audio.addEventListener('loadedmetadata', () => {
325
+ durationEl.textContent = formatTime(audio.duration);
326
+ });
327
+
328
+ // Инициализация: ставим play иконку по умолчанию
329
+ setPlayIcon();
330
+
331
+ audio.addEventListener('error', (e) => {
332
+ console.error('Audio error', e);
333
+ msgEl.textContent = 'Ошибка воспроизведения. Проверьте логи сервера.';
334
+ });
335
+ </script>
336
+ </body>
337
+ </html>
338
+ """
339
+
340
+ @app.route("/")
341
+ def index():
342
+ return render_template_string(HTML)
343
+
344
+ # Поиск
345
+ @app.route('/search/youtube', methods=['GET'])
346
+ def search_youtube_music():
347
+ query = request.args.get('query', '').strip()
348
+ limit = int(request.args.get('limit', 10))
349
+ if not query:
350
+ return jsonify({'success': False, 'error': 'Поисковый запрос не указан'}), 400
351
+ try:
352
+ ytmusic = YTMusic()
353
+ results = ytmusic.search(query, filter='songs', limit=limit)
354
+ formatted = []
355
+ for r in results:
356
+ if r.get('resultType') != 'song':
357
+ continue
358
+ title = r.get('title') or ''
359
+ artists = r.get('artists') or []
360
+ artist_name = artists[0]['name'] if artists and isinstance(artists, list) and 'name' in artists[0] else ''
361
+ duration = r.get('duration', '')
362
+ videoId = r.get('videoId', '')
363
+ thumbnails = r.get('thumbnails') or []
364
+ thumb = thumbnails[-1]['url'] if thumbnails else ''
365
+ formatted.append({
366
+ 'title': title,
367
+ 'artist': artist_name,
368
+ 'duration': duration,
369
+ 'videoId': videoId,
370
+ 'thumbnail': thumb
371
+ })
372
+ return jsonify({'success': True, 'results': formatted})
373
+ except Exception as e:
374
+ app.logger.exception("Ошибка при поиске в YTMusic")
375
+ return jsonify({'success': False, 'error': str(e)}), 500
376
+
377
+ # Прокси-стрим с поддержкой Range и низкоуровневой передачей
378
+ @app.route('/play/youtube/<video_id>', methods=['GET', 'HEAD'])
379
+ def play_proxy(video_id):
380
+ if not video_id:
381
+ return ("video_id не указан", 400)
382
+ try:
383
+ # Получаем прямой URL аудиопотока через yt-dlp -g
384
+ cmd = [YTDLP, '-f', 'bestaudio', '-g', f'https://www.youtube.com/watch?v={video_id}']
385
+ rc, out, err = run_subprocess(cmd, cwd=str(BASE_DIR), timeout=20)
386
+ if rc != 0:
387
+ app.logger.error("yt-dlp -g error: %s", err)
388
+ return (f"Ошибка получения аудиопотока: {err}", 500)
389
+ audio_url = out.strip().splitlines()[0] if out else ''
390
+ if not audio_url:
391
+ app.logger.error("Не получили URL из yt-dlp -g")
392
+ return ("Не удалось получить URL аудиопотока", 500)
393
+
394
+ # Пробрасываем Range если пришёл
395
+ headers = {"User-Agent": request.headers.get("User-Agent", "ytmusic-downloader-proxy/1.0")}
396
+ range_header = request.headers.get('Range')
397
+ if range_header:
398
+ headers['Range'] = range_header
399
+
400
+ # Делаем запрос к upstream (stream=True) и используем raw для чтения
401
+ upstream = requests.get(audio_url, headers=headers, stream=True, allow_redirects=True, timeout=15)
402
+ app.logger.info("Upstream headers: %s", dict(upstream.headers))
403
+
404
+ if upstream.status_code not in (200, 206):
405
+ app.logger.error("Upstream returned status %s", upstream.status_code)
406
+ return (f"Upstream returned {upstream.status_code}", 500)
407
+
408
+ # Формируем заголовки ответа на основе upstream
409
+ resp_headers = {}
410
+ content_type = upstream.headers.get('Content-Type', 'audio/mpeg')
411
+ resp_headers['Content-Type'] = content_type
412
+ if 'Content-Length' in upstream.headers:
413
+ resp_headers['Content-Length'] = upstream.headers['Content-Length']
414
+ if 'Content-Range' in upstream.headers:
415
+ resp_headers['Content-Range'] = upstream.headers['Content-Range']
416
+ resp_headers['Accept-Ranges'] = upstream.headers.get('Accept-Ranges', 'bytes')
417
+ resp_headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
418
+
419
+ if request.method == 'HEAD':
420
+ return Response(status=upstream.status_code, headers=resp_headers)
421
+
422
+ # Низкоуровневая генерация байтов — upstream.raw.read
423
+ upstream.raw.decode_content = True
424
+ def generate():
425
+ try:
426
+ while True:
427
+ chunk = upstream.raw.read(16384)
428
+ if not chunk:
429
+ break
430
+ yield chunk
431
+ finally:
432
+ try:
433
+ upstream.close()
434
+ except Exception:
435
+ pass
436
+
437
+ status_code = upstream.status_code if upstream.status_code in (200,206) else 200
438
+ return Response(stream_with_context(generate()), headers=resp_headers, status=status_code)
439
+ except requests.exceptions.RequestException as e:
440
+ app.logger.exception("Ошибка запроса к upstream")
441
+ return (f"Ошибка запроса к upstream: {str(e)}", 500)
442
+ except Exception as e:
443
+ app.logger.exception("Ошибка stream proxy")
444
+ return (f"Ошибка сервера: {str(e)}", 500)
445
+
446
+ # favicon чтобы убрать 404
447
+ @app.route('/favicon.ico')
448
+ def favicon():
449
+ return ('', 204)
450
+
451
+ # Скачать трек и вернуть как attachment
452
+ @app.route('/download/youtube/<video_id>', methods=['GET','POST'])
453
+ def download_youtube(video_id):
454
+ if not video_id:
455
+ return jsonify({'success': False, 'error': 'video_id не указан'}), 400
456
+ try:
457
+ title = None
458
+ artist = None
459
+ try:
460
+ ytmusic = YTMusic()
461
+ info = ytmusic.get_song(video_id)
462
+ if info:
463
+ title = info.get('videoDetails', {}).get('title') or title
464
+ author = info.get('videoDetails', {}).get('author')
465
+ if author:
466
+ artist = author
467
+ for k in ('artists','artist'):
468
+ if k in info and isinstance(info[k], list):
469
+ names = []
470
+ for a in info[k]:
471
+ if isinstance(a, dict) and a.get('name'):
472
+ names.append(a.get('name'))
473
+ elif isinstance(a, str):
474
+ names.append(a)
475
+ if names:
476
+ artist = ', '.join(names)
477
+ except Exception:
478
+ app.logger.info("YTMusic info failed, fallback to id")
479
+
480
+ safe_title = clean_filename(title) if title else video_id
481
+ safe_artist = clean_filename(artist) if artist else "unknown"
482
+ final_name = f"{safe_artist} - {safe_title}.mp3"
483
+ final_name = secure_filename(final_name)
484
+
485
+ temp_template = TEMP_DIR / f"temp_{video_id}_{os.getpid()}.%(ext)s"
486
+ temp_template_str = str(temp_template)
487
+ music_url = f"https://music.youtube.com/watch?v={video_id}"
488
+
489
+ cmd = [
490
+ YTDLP,
491
+ '--no-playlist',
492
+ '--extract-audio',
493
+ '--audio-format', 'mp3',
494
+ '--audio-quality', '0',
495
+ '--add-metadata',
496
+ '--embed-thumbnail',
497
+ '--no-keep-video',
498
+ '--no-overwrites',
499
+ '--prefer-ffmpeg',
500
+ '--output', temp_template_str,
501
+ music_url
502
+ ]
503
+ rc, out, err = run_subprocess(cmd, cwd=str(BASE_DIR))
504
+ if rc != 0:
505
+ app.logger.error("yt-dlp error: %s", err)
506
+ return jsonify({'success': False, 'error': err}), 500
507
+
508
+ created_files = list(TEMP_DIR.glob(f"temp_{video_id}_*.*"))
509
+ created = None
510
+ if created_files:
511
+ created = sorted(created_files, key=lambda p: p.stat().st_mtime, reverse=True)[0]
512
+ else:
513
+ mp3s = sorted(TEMP_DIR.glob("*.mp3"), key=lambda p: p.stat().st_mtime, reverse=True)
514
+ if mp3s:
515
+ created = mp3s[0]
516
+
517
+ if not created or not created.exists():
518
+ app.logger.error("mp3 not found after yt-dlp")
519
+ return jsonify({'success': False, 'error': 'mp3 not found after yt-dlp'}), 500
520
+
521
+ send_path = TEMP_DIR / final_name
522
+ try:
523
+ created.replace(send_path)
524
+ except Exception:
525
+ shutil.copy2(created, send_path)
526
+
527
+ @after_this_request
528
+ def cleanup(response):
529
+ try:
530
+ if send_path.exists():
531
+ send_path.unlink()
532
+ if created.exists():
533
+ try:
534
+ created.unlink()
535
+ except Exception:
536
+ pass
537
+ except Exception:
538
+ app.logger.exception("cleanup error")
539
+ return response
540
+
541
+ return send_file(str(send_path), as_attachment=True, download_name=final_name)
542
+ except Exception as e:
543
+ app.logger.exception("download error")
544
+ return jsonify({'success': False, 'error': str(e)}), 500
545
+
546
+ if __name__ == "__main__":
547
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
548
+ app.run(host="0.0.0.0", port=5100, debug=True)