Starchik commited on
Commit
0c62ff0
·
verified ·
1 Parent(s): 792d8fe

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +159 -197
main.py CHANGED
@@ -1,35 +1,32 @@
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
- main.py — single-file Flask app with a polished web UI and real-time processing updates (SSE).
5
- Save as main.py and run in Docker (see earlier Dockerfile with ffmpeg installed).
 
6
  Requirements (requirements.txt):
7
  flask
8
  pydub
9
  gunicorn
10
  python-multipart
11
 
12
- Запуск локально:
13
- python main.py
14
- В контейнере (gunicorn):
15
- gunicorn -b 0.0.0.0:7860 main:app
16
  """
17
-
18
  import os
19
  import io
20
  import uuid
21
  import time
22
- import queue
23
  import threading
24
  from pathlib import Path
 
25
  from flask import (
26
  Flask, request, jsonify, url_for, send_file,
27
- render_template_string, abort, Response
28
  )
29
  from pydub import AudioSegment, effects, silence
30
 
31
  # ----------------------------
32
- # Configuration
33
  # ----------------------------
34
  TMP_DIR = Path("./tmp")
35
  TMP_DIR.mkdir(exist_ok=True)
@@ -39,16 +36,17 @@ MAX_UPLOAD_MB = 200
39
  app = Flask(__name__)
40
  app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
41
 
42
- # job_queues: job_id -> Queue of event dicts
43
- job_queues = {}
44
- # job_files: job_id -> output Path
45
- job_files = {}
46
- # simple lock for dicts
 
47
  jobs_lock = threading.Lock()
48
 
49
 
50
  # ----------------------------
51
- # Utility helpers & processors
52
  # ----------------------------
53
  def random_filename(ext: str) -> Path:
54
  return TMP_DIR / (str(uuid.uuid4()) + "." + ext)
@@ -86,25 +84,26 @@ def load_audio_from_filestorage(filestorage):
86
  ext = ""
87
  if "." in filename:
88
  ext = filename.rsplit(".", 1)[1].lower()
89
- # Try with extension first, else try common formats
90
  if ext:
91
  try:
92
  buf.seek(0)
93
  return AudioSegment.from_file(buf, format=ext)
94
  except Exception:
95
  pass
 
96
  for fmt in ("mp3", "wav", "ogg", "flac", "m4a", "aac"):
97
  try:
98
  buf.seek(0)
99
  return AudioSegment.from_file(buf, format=fmt)
100
  except Exception:
101
  continue
102
- # final attempt letting ffmpeg autodetect
103
  buf.seek(0)
104
  return AudioSegment.from_file(buf)
105
 
106
 
107
- # Processing steps (return AudioSegment)
108
  def apply_trim_silence(seg: AudioSegment, silence_thresh_db=-50, chunk_len_ms=10):
109
  non_silence = silence.detect_nonsilent(seg, min_silence_len=chunk_len_ms, silence_thresh=silence_thresh_db)
110
  if not non_silence:
@@ -130,7 +129,6 @@ def apply_speed_change(seg: AudioSegment, speed: float):
130
  if speed <= 0 or abs(speed - 1.0) < 1e-6:
131
  return seg
132
  try:
133
- # small speed changes: use pydub.effects.speedup
134
  return effects.speedup(seg, playback_speed=speed)
135
  except Exception:
136
  new_frame_rate = int(seg.frame_rate * speed)
@@ -142,127 +140,116 @@ def apply_normalize(seg: AudioSegment):
142
 
143
 
144
  # ----------------------------
145
- # Background processing worker
146
  # ----------------------------
147
- def processing_worker(job_id, fs_file, options):
 
 
 
 
 
 
148
  """
149
- Runs in a separate thread. Posts progress events into the job queue.
150
- Each event is a dict: {"type": "progress"/"error"/"done", "msg": "...", "percent": 0-100, "file": filename_if_done}
151
  """
152
- q = job_queues.get(job_id)
153
- if q is None:
154
- return
155
-
156
- def post(event_type, msg=None, percent=None, filename=None):
157
- payload = {"type": event_type}
158
- if msg is not None:
159
- payload["msg"] = msg
160
- if percent is not None:
161
- payload["percent"] = percent
162
- if filename is not None:
163
- payload["file"] = filename
164
- try:
165
- q.put(payload, timeout=1)
166
- except Exception:
167
- pass
168
-
169
  try:
170
- post("progress", "Получаю файл...", 2)
171
  # load audio
172
- fs_file.stream.seek(0)
173
  try:
174
- seg = load_audio_from_filestorage(fs_file)
 
 
 
 
 
 
 
 
175
  except Exception as e:
176
- post("error", f"Не удалось загрузить аудио: {e}")
177
  return
178
 
179
- post("progress", "Анализ аудио...", 10)
180
- time.sleep(0.2)
181
 
182
- # trim
183
  if options.get("trim_silence"):
184
- post("progress", "Обрезаю тишину...", 20)
185
  try:
186
  seg = apply_trim_silence(seg, silence_thresh_db=int(options.get("sil_thresh", -50)))
187
  except Exception as exc:
188
- post("error", f"Ошибка при обрезке тишины: {exc}")
189
  return
190
  time.sleep(0.1)
191
 
192
- # filters
193
  lp = float(options.get("lowpass", 0) or 0)
194
  hp = float(options.get("highpass", 0) or 0)
195
  if lp > 0:
196
- post("progress", f"Применяю low-pass {int(lp)} Hz...", 40)
197
  try:
198
  seg = apply_lowpass(seg, lp)
199
  except Exception as exc:
200
- post("error", f"Ошибка low-pass: {exc}")
201
  return
202
  time.sleep(0.1)
203
  if hp > 0:
204
- post("progress", f"Применяю high-pass {int(hp)} Hz...", 50)
205
  try:
206
  seg = apply_highpass(seg, hp)
207
  except Exception as exc:
208
- post("error", f"Ошибка high-pass: {exc}")
209
  return
210
  time.sleep(0.1)
211
 
212
- # speed
213
  speed = float(options.get("speed", 1.0) or 1.0)
214
  if abs(speed - 1.0) > 1e-6:
215
- post("progress", f"Изменяю скорость x{speed}...", 60)
216
  try:
217
  seg = apply_speed_change(seg, speed)
218
  except Exception as exc:
219
- post("error", f"Ошибка изменения скорости: {exc}")
220
  return
221
  time.sleep(0.1)
222
 
223
- # normalize
224
  if options.get("normalize"):
225
- post("progress", "Нормализация громкости...", 75)
226
  try:
227
  seg = apply_normalize(seg)
228
  except Exception as exc:
229
- post("error", f"Ошибка при нормализации: {exc}")
230
  return
231
  time.sleep(0.1)
232
 
233
- # export
234
  out_fmt = secure_extension_for_format(options.get("out_format", "wav"))
235
  out_path = random_filename(out_fmt)
236
- post("progress", "Экспортирую файл...", 90)
237
  try:
238
  export_kwargs = {}
239
  if out_fmt == "mp3":
240
  export_kwargs["bitrate"] = "192k"
241
  seg.export(out_path.as_posix(), format=out_fmt, **export_kwargs)
242
  except Exception as exc:
243
- post("error", f"Ошибка при экспорте: {exc}")
244
  return
245
 
246
  # register file
247
  with jobs_lock:
248
  job_files[job_id] = out_path
249
 
250
- file_url = url_for("download_file", filename=out_path.name, _external=True)
251
- post("done", "Готово", 100, filename=out_path.name)
252
- post("file_url", file_url)
253
  except Exception as e:
254
- post("error", f"Внутренняя ошибка: {e}")
255
 
256
 
257
  # ----------------------------
258
- # Flask routes
259
  # ----------------------------
260
  INDEX_HTML = """
261
  <!doctype html>
262
  <html lang="ru">
263
  <head>
264
  <meta charset="utf-8" />
265
- <title>Music Processor — Онлайн</title>
266
  <meta name="viewport" content="width=device-width,initial-scale=1" />
267
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
268
  <style>
@@ -273,6 +260,7 @@ INDEX_HTML = """
273
  .progress { height: 18px; }
274
  .controls label { font-weight: 600; color: #cdeefb; }
275
  footer { margin-top: 24px; color: #8aa7bd; }
 
276
  </style>
277
  </head>
278
  <body>
@@ -280,8 +268,8 @@ INDEX_HTML = """
280
  <div class="row justify-content-center">
281
  <div class="col-lg-8">
282
  <div class="card p-4">
283
- <h2 class="mb-1 accent">Онлайн обработка музыки</h2>
284
- <p class="small-muted mb-3">Загрузите трек, примените эффекты в реальном времени — прогресс будет отображаться прямо в интерфейсе.</p>
285
 
286
  <form id="uploadForm" enctype="multipart/form-data" class="mb-3">
287
  <div class="mb-3">
@@ -341,7 +329,7 @@ INDEX_HTML = """
341
  <div id="progressBar" class="progress-bar bg-info" role="progressbar" style="width: 0%">0%</div>
342
  </div>
343
  </div>
344
- <div id="log" class="small-muted" style="min-height:48px;"></div>
345
 
346
  <div id="resultArea" class="mt-3" style="display:none;">
347
  <h5 class="accent">Готово — прослушать / скачать</h5>
@@ -353,11 +341,11 @@ INDEX_HTML = """
353
  </div>
354
 
355
  <hr class="text-muted mt-4">
356
- <div class="small-muted">Подсказка: обработка выполняется в контейнере с ffmpeg. Если загрузка не проходит — убедитесь, что файл меньше {{max_mb}} МБ.</div>
357
  </div>
358
 
359
  <footer class="text-center">
360
- <small>Made with ♥ — быстрый аудио-пайплайн (pydub + ffmpeg). Временные файлы хранятся ~1 час.</small>
361
  </footer>
362
  </div>
363
  </div>
@@ -380,6 +368,9 @@ function log(msg) {
380
  logEl.innerHTML = `<div>[${time}] ${msg}</div>` + logEl.innerHTML;
381
  }
382
 
 
 
 
383
  uploadForm.addEventListener('submit', async function(e) {
384
  e.preventDefault();
385
  logEl.innerHTML = '';
@@ -387,6 +378,7 @@ uploadForm.addEventListener('submit', async function(e) {
387
  statusArea.style.display = 'block';
388
  progressBar.style.width = '0%';
389
  progressBar.textContent = '0%';
 
390
 
391
  const fileInput = document.getElementById('audio_file');
392
  if (!fileInput.files || fileInput.files.length === 0) {
@@ -401,7 +393,6 @@ uploadForm.addEventListener('submit', async function(e) {
401
 
402
  submitBtn.disabled = true;
403
 
404
- // Prepare form
405
  const form = new FormData();
406
  form.append('audio_file', file);
407
  form.append('normalize', document.getElementById('normalize').checked ? '1' : '');
@@ -424,51 +415,72 @@ uploadForm.addEventListener('submit', async function(e) {
424
  const job_id = js.job_id;
425
  log('Задача создана: ' + job_id);
426
 
427
- // Connect to SSE for live updates
428
- const evtSource = new EventSource('/events/' + job_id);
429
- evtSource.onmessage = function(e) {
430
  try {
431
- const data = JSON.parse(e.data);
432
- if (data.type === 'progress') {
433
- const pct = data.percent || 0;
434
- progressBar.style.width = pct + '%';
435
- progressBar.textContent = pct + '%';
436
- if (data.msg) log(data.msg);
437
- } else if (data.type === 'done') {
438
- progressBar.style.width = '100%';
439
- progressBar.textContent = '100%';
440
- log(data.msg || 'Готово');
441
- } else if (data.type === 'file_url') {
442
- const fileurl = data;
443
- // in case server sends a full object with file_url
444
- let url = data;
445
- if (data.file) url = '/download/' + data.file;
446
- if (typeof data === 'object' && data.file_url) url = data.file_url;
447
- // Set player and download link
448
- const finalUrl = url;
449
- player.src = finalUrl;
450
- downloadLink.href = finalUrl;
451
- downloadLink.download = 'processed_' + file.name;
452
- resultArea.style.display = 'block';
453
- log('Файл доступен: ' + finalUrl);
454
- evtSource.close();
455
- submitBtn.disabled = false;
456
- } else if (data.type === 'error') {
457
- log('Ошибка: ' + (data.msg || 'unknown'));
458
- alert('Ошибка обработки: ' + (data.msg || 'неизвестно'));
459
- evtSource.close();
460
  submitBtn.disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  }
462
  } catch (err) {
463
- console.error('SSE parse error', err, e.data);
 
 
 
464
  }
465
- };
466
- evtSource.onerror = function(ev) {
467
- console.warn('SSE connection error', ev);
468
- log('Соединение SSE прервано');
469
- evtSource.close();
470
- submitBtn.disabled = false;
471
- };
472
  } catch (err) {
473
  alert('Ошибка при отправке: ' + err);
474
  submitBtn.disabled = false;
@@ -482,6 +494,8 @@ resetBtn.addEventListener('click', function() {
482
  logEl.innerHTML = '';
483
  progressBar.style.width = '0%';
484
  progressBar.textContent = '0%';
 
 
485
  });
486
  </script>
487
  </body>
@@ -492,8 +506,7 @@ resetBtn.addEventListener('click', function() {
492
  @app.route("/", methods=["GET"])
493
  def index():
494
  cleanup_old_files()
495
- rendered = render_template_string(INDEX_HTML, max_mb=MAX_UPLOAD_MB)
496
- return rendered
497
 
498
 
499
  @app.route("/start", methods=["POST"])
@@ -506,11 +519,9 @@ def start_job():
506
  return "No selected file", 400
507
 
508
  job_id = str(uuid.uuid4())
509
- q = queue.Queue()
510
  with jobs_lock:
511
- job_queues[job_id] = q
512
 
513
- # gather options
514
  options = {
515
  "normalize": bool(request.form.get("normalize")),
516
  "speed": request.form.get("speed", "1.0"),
@@ -521,88 +532,42 @@ def start_job():
521
  "out_format": request.form.get("out_format", "wav"),
522
  }
523
 
524
- # We must keep a copy of the uploaded file stream for the background worker.
525
- # Easiest: read bytes into BytesIO-like object stored on disk or memory.
526
- # We'll save the uploaded file into memory-backed object but keep a small temp file on disk to avoid memory blow.
527
- # Save a temp copy to pass into thread as a FileStorage-like object:
528
  temp_in = TMP_DIR / (job_id + "_in")
529
  fs.stream.seek(0)
530
  fs.save(temp_in)
531
- # reopen as a file object with necessary attributes for loader
532
- class _FSWrapper:
533
- def __init__(self, path, filename):
534
- self.path = path
535
- self.filename = filename
536
- self.stream = open(path, "rb")
537
- def read(self):
538
- self.stream.seek(0)
539
- return self.stream.read()
540
- fs_wrapper = _FSWrapper(temp_in, fs.filename)
541
-
542
- # start thread
543
- t = threading.Thread(target=processing_worker, args=(job_id, fs_wrapper, options), daemon=True)
544
  t.start()
545
 
546
  return jsonify({"job_id": job_id})
547
 
548
 
549
- @app.route("/events/<job_id>")
550
- def events(job_id):
551
- # Stream Server-Sent Events from the job queue
552
- def gen():
553
- q = None
554
- with jobs_lock:
555
- q = job_queues.get(job_id)
556
- if q is None:
557
- yield f"data: {json_safe({'type': 'error', 'msg': 'Job not found'})}\n\n"
558
- return
559
- # keep streaming until we see done or error and queue empties
560
- finished = False
561
- while True:
562
- try:
563
- item = q.get(timeout=0.5)
564
- except queue.Empty:
565
- # if job completed and queue empty, break
566
- with jobs_lock:
567
- if job_files.get(job_id) is not None and q.empty():
568
- break
569
- continue
570
- # send JSON string
571
- yield f"data: {json_safe(item)}\n\n"
572
- if item.get("type") in ("done", "error"):
573
- # continue loop to potentially send file_url event after done
574
- if item.get("type") == "error":
575
- break
576
- # after done, if file exists, send file_url
577
- with jobs_lock:
578
- outp = job_files.get(job_id)
579
- if outp:
580
- file_url = url_for("download_file", filename=outp.name, _external=True)
581
- yield f"data: {json_safe({'type': 'file_url', 'file_url': file_url})}\n\n"
582
- # cleanup queue and small temp input file
583
- with jobs_lock:
584
- job_queues.pop(job_id, None)
585
- # remove temp input file if present
586
- try:
587
- temp_in = TMP_DIR / (job_id + "_in")
588
- if temp_in.exists():
589
- temp_in.unlink()
590
- except Exception:
591
- pass
592
-
593
- return Response(gen(), mimetype="text/event-stream")
594
-
595
-
596
- def json_safe(obj):
597
  """
598
- Very small JSON serialization without importing json in the template string path.
599
- Use python's json.dumps normally, but keep this helper for convenience.
 
600
  """
601
- import json
602
- return json.dumps(obj, ensure_ascii=False)
603
-
604
-
605
- @app.route("/download/<filename>")
 
 
 
 
 
 
 
 
 
 
 
 
606
  def download_file(filename):
607
  cleanup_old_files()
608
  safe_path = (TMP_DIR / filename).resolve()
@@ -616,14 +581,11 @@ def download_file(filename):
616
  return f"Error serving file: {e}", 500
617
 
618
 
619
- @app.route("/healthz")
620
  def health():
621
  return "ok", 200
622
 
623
 
624
- # ----------------------------
625
- # Run (for dev)
626
- # ----------------------------
627
  if __name__ == "__main__":
628
  port = int(os.environ.get("PORT", 7860))
629
  host = os.environ.get("HOST", "0.0.0.0")
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
+ main.py — single-file Flask app с AJAX-поллингом для реального статуса обработки аудио.
5
+ Save as main.py and run in Docker.
6
+
7
  Requirements (requirements.txt):
8
  flask
9
  pydub
10
  gunicorn
11
  python-multipart
12
 
13
+ Убедитесь, что в контейнере установлен ffmpeg.
 
 
 
14
  """
 
15
  import os
16
  import io
17
  import uuid
18
  import time
 
19
  import threading
20
  from pathlib import Path
21
+ from typing import List, Dict, Any
22
  from flask import (
23
  Flask, request, jsonify, url_for, send_file,
24
+ render_template_string, abort
25
  )
26
  from pydub import AudioSegment, effects, silence
27
 
28
  # ----------------------------
29
+ # Config
30
  # ----------------------------
31
  TMP_DIR = Path("./tmp")
32
  TMP_DIR.mkdir(exist_ok=True)
 
36
  app = Flask(__name__)
37
  app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
38
 
39
+ # job state storage
40
+ # job_logs[job_id] = [event_dict, ...]
41
+ job_logs: Dict[str, List[Dict[str, Any]]] = {}
42
+ # job_files[job_id] = Path to output file
43
+ job_files: Dict[str, Path] = {}
44
+ # lock for thread-safety
45
  jobs_lock = threading.Lock()
46
 
47
 
48
  # ----------------------------
49
+ # Helpers
50
  # ----------------------------
51
  def random_filename(ext: str) -> Path:
52
  return TMP_DIR / (str(uuid.uuid4()) + "." + ext)
 
84
  ext = ""
85
  if "." in filename:
86
  ext = filename.rsplit(".", 1)[1].lower()
87
+ # Try with extension first
88
  if ext:
89
  try:
90
  buf.seek(0)
91
  return AudioSegment.from_file(buf, format=ext)
92
  except Exception:
93
  pass
94
+ # Try common formats
95
  for fmt in ("mp3", "wav", "ogg", "flac", "m4a", "aac"):
96
  try:
97
  buf.seek(0)
98
  return AudioSegment.from_file(buf, format=fmt)
99
  except Exception:
100
  continue
101
+ # Final attempt: rely on ffmpeg autodetect
102
  buf.seek(0)
103
  return AudioSegment.from_file(buf)
104
 
105
 
106
+ # Processing functions
107
  def apply_trim_silence(seg: AudioSegment, silence_thresh_db=-50, chunk_len_ms=10):
108
  non_silence = silence.detect_nonsilent(seg, min_silence_len=chunk_len_ms, silence_thresh=silence_thresh_db)
109
  if not non_silence:
 
129
  if speed <= 0 or abs(speed - 1.0) < 1e-6:
130
  return seg
131
  try:
 
132
  return effects.speedup(seg, playback_speed=speed)
133
  except Exception:
134
  new_frame_rate = int(seg.frame_rate * speed)
 
140
 
141
 
142
  # ----------------------------
143
+ # Worker thread
144
  # ----------------------------
145
+ def post_log(job_id: str, item: dict):
146
+ with jobs_lock:
147
+ if job_id in job_logs:
148
+ job_logs[job_id].append(item)
149
+
150
+
151
+ def processing_worker(job_id: str, input_path: Path, original_filename: str, options: dict):
152
  """
153
+ Background worker: reads input_path, performs processing, writes output file,
154
+ and appends events into job_logs[job_id].
155
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  try:
157
+ post_log(job_id, {"type": "progress", "msg": "Получаю файл...", "percent": 2})
158
  # load audio
 
159
  try:
160
+ with open(input_path, "rb") as f:
161
+ class _FS:
162
+ def __init__(self, data, filename):
163
+ self._data = data
164
+ self.filename = filename
165
+ def read(self):
166
+ return self._data
167
+ fs_wrapper = _FS(f.read(), original_filename)
168
+ seg = load_audio_from_filestorage(fs_wrapper)
169
  except Exception as e:
170
+ post_log(job_id, {"type": "error", "msg": f"Не удалось загрузить аудио: {e}"})
171
  return
172
 
173
+ post_log(job_id, {"type": "progress", "msg": "Анализ аудио...", "percent": 10})
174
+ time.sleep(0.15)
175
 
 
176
  if options.get("trim_silence"):
177
+ post_log(job_id, {"type": "progress", "msg": "Обрезаю тишину...", "percent": 20})
178
  try:
179
  seg = apply_trim_silence(seg, silence_thresh_db=int(options.get("sil_thresh", -50)))
180
  except Exception as exc:
181
+ post_log(job_id, {"type": "error", "msg": f"Ошибка при обрезке тишины: {exc}"})
182
  return
183
  time.sleep(0.1)
184
 
 
185
  lp = float(options.get("lowpass", 0) or 0)
186
  hp = float(options.get("highpass", 0) or 0)
187
  if lp > 0:
188
+ post_log(job_id, {"type": "progress", "msg": f"Применяю low-pass {int(lp)} Hz...", "percent": 40})
189
  try:
190
  seg = apply_lowpass(seg, lp)
191
  except Exception as exc:
192
+ post_log(job_id, {"type": "error", "msg": f"Ошибка low-pass: {exc}"})
193
  return
194
  time.sleep(0.1)
195
  if hp > 0:
196
+ post_log(job_id, {"type": "progress", "msg": f"Применяю high-pass {int(hp)} Hz...", "percent": 50})
197
  try:
198
  seg = apply_highpass(seg, hp)
199
  except Exception as exc:
200
+ post_log(job_id, {"type": "error", "msg": f"Ошибка high-pass: {exc}"})
201
  return
202
  time.sleep(0.1)
203
 
 
204
  speed = float(options.get("speed", 1.0) or 1.0)
205
  if abs(speed - 1.0) > 1e-6:
206
+ post_log(job_id, {"type": "progress", "msg": f"Изменяю скорость x{speed}...", "percent": 60})
207
  try:
208
  seg = apply_speed_change(seg, speed)
209
  except Exception as exc:
210
+ post_log(job_id, {"type": "error", "msg": f"Ошибка изменения скорости: {exc}"})
211
  return
212
  time.sleep(0.1)
213
 
 
214
  if options.get("normalize"):
215
+ post_log(job_id, {"type": "progress", "msg": "Нормализация громкости...", "percent": 75})
216
  try:
217
  seg = apply_normalize(seg)
218
  except Exception as exc:
219
+ post_log(job_id, {"type": "error", "msg": f"Ошибка при нормализации: {exc}"})
220
  return
221
  time.sleep(0.1)
222
 
 
223
  out_fmt = secure_extension_for_format(options.get("out_format", "wav"))
224
  out_path = random_filename(out_fmt)
225
+ post_log(job_id, {"type": "progress", "msg": "Экспортирую файл...", "percent": 90})
226
  try:
227
  export_kwargs = {}
228
  if out_fmt == "mp3":
229
  export_kwargs["bitrate"] = "192k"
230
  seg.export(out_path.as_posix(), format=out_fmt, **export_kwargs)
231
  except Exception as exc:
232
+ post_log(job_id, {"type": "error", "msg": f"Ошибка при экспорте: {exc}"})
233
  return
234
 
235
  # register file
236
  with jobs_lock:
237
  job_files[job_id] = out_path
238
 
239
+ post_log(job_id, {"type": "done", "msg": "Готово", "percent": 100, "file": out_path.name})
 
 
240
  except Exception as e:
241
+ post_log(job_id, {"type": "error", "msg": f"Внутренняя ошибка: {e}"})
242
 
243
 
244
  # ----------------------------
245
+ # Routes
246
  # ----------------------------
247
  INDEX_HTML = """
248
  <!doctype html>
249
  <html lang="ru">
250
  <head>
251
  <meta charset="utf-8" />
252
+ <title>Music Processor — AJAX</title>
253
  <meta name="viewport" content="width=device-width,initial-scale=1" />
254
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
255
  <style>
 
260
  .progress { height: 18px; }
261
  .controls label { font-weight: 600; color: #cdeefb; }
262
  footer { margin-top: 24px; color: #8aa7bd; }
263
+ #log { max-height: 220px; overflow:auto; }
264
  </style>
265
  </head>
266
  <body>
 
268
  <div class="row justify-content-center">
269
  <div class="col-lg-8">
270
  <div class="card p-4">
271
+ <h2 class="mb-1 accent">Онлайн обработка музыки (AJAX)</h2>
272
+ <p class="small-muted mb-3">Загрузите трек, прогресс обновляется через AJAX-поллинг.</p>
273
 
274
  <form id="uploadForm" enctype="multipart/form-data" class="mb-3">
275
  <div class="mb-3">
 
329
  <div id="progressBar" class="progress-bar bg-info" role="progressbar" style="width: 0%">0%</div>
330
  </div>
331
  </div>
332
+ <div id="log" class="small-muted mb-2" style="min-height:64px;"></div>
333
 
334
  <div id="resultArea" class="mt-3" style="display:none;">
335
  <h5 class="accent">Готово — прослушать / скачать</h5>
 
341
  </div>
342
 
343
  <hr class="text-muted mt-4">
344
+ <div class="small-muted">Максимальный размер файла: {{max_mb}} МБ. Временные файлы хранятся ~1 час.</div>
345
  </div>
346
 
347
  <footer class="text-center">
348
+ <small>Made with ♥ — pydub + ffmpeg. Используется AJAX-поллинг для статуса.</small>
349
  </footer>
350
  </div>
351
  </div>
 
368
  logEl.innerHTML = `<div>[${time}] ${msg}</div>` + logEl.innerHTML;
369
  }
370
 
371
+ let pollInterval = null;
372
+ let last_idx = 0;
373
+
374
  uploadForm.addEventListener('submit', async function(e) {
375
  e.preventDefault();
376
  logEl.innerHTML = '';
 
378
  statusArea.style.display = 'block';
379
  progressBar.style.width = '0%';
380
  progressBar.textContent = '0%';
381
+ last_idx = 0;
382
 
383
  const fileInput = document.getElementById('audio_file');
384
  if (!fileInput.files || fileInput.files.length === 0) {
 
393
 
394
  submitBtn.disabled = true;
395
 
 
396
  const form = new FormData();
397
  form.append('audio_file', file);
398
  form.append('normalize', document.getElementById('normalize').checked ? '1' : '');
 
415
  const job_id = js.job_id;
416
  log('Задача создана: ' + job_id);
417
 
418
+ // start polling
419
+ if (pollInterval) clearInterval(pollInterval);
420
+ pollInterval = setInterval(async () => {
421
  try {
422
+ const sresp = await fetch('/status/' + job_id + '?last_idx=' + last_idx);
423
+ if (!sresp.ok) {
424
+ const t = await sresp.text();
425
+ log('Ошибка статуса: ' + sresp.status + ' ' + t);
426
+ clearInterval(pollInterval);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  submitBtn.disabled = false;
428
+ return;
429
+ }
430
+ const data = await sresp.json();
431
+ const events = data.events || [];
432
+ last_idx = data.next_index || last_idx;
433
+ for (const ev of events) {
434
+ if (ev.type === 'progress') {
435
+ const pct = ev.percent || 0;
436
+ progressBar.style.width = pct + '%';
437
+ progressBar.textContent = pct + '%';
438
+ if (ev.msg) log(ev.msg);
439
+ } else if (ev.type === 'done') {
440
+ progressBar.style.width = '100%';
441
+ progressBar.textContent = '100%';
442
+ if (ev.msg) log(ev.msg);
443
+ // waiting for file_url event below (server will include file in done event)
444
+ } else if (ev.type === 'error') {
445
+ log('Ошибка: ' + (ev.msg || 'unknown'));
446
+ alert('Ошибка обработки: ' + (ev.msg || 'неизвестно'));
447
+ clearInterval(pollInterval);
448
+ submitBtn.disabled = false;
449
+ return;
450
+ }
451
+ // If server included file name (or file_url) in event, open it
452
+ if (ev.file) {
453
+ // construct download url
454
+ const url = window.location.origin + '/download/' + ev.file;
455
+ player.src = url;
456
+ downloadLink.href = url;
457
+ downloadLink.download = 'processed_' + (file.name || 'file.' + (ev.file.split('.').pop() || 'wav'));
458
+ resultArea.style.display = 'block';
459
+ log('Файл доступен: ' + url);
460
+ clearInterval(pollInterval);
461
+ submitBtn.disabled = false;
462
+ break;
463
+ }
464
+ if (ev.file_url) {
465
+ const url = ev.file_url;
466
+ player.src = url;
467
+ downloadLink.href = url;
468
+ downloadLink.download = 'processed_' + (file.name || 'file');
469
+ resultArea.style.display = 'block';
470
+ log('Файл доступен: ' + url);
471
+ clearInterval(pollInterval);
472
+ submitBtn.disabled = false;
473
+ break;
474
+ }
475
  }
476
  } catch (err) {
477
+ console.error('poll err', err);
478
+ log('Ошибка запроса статуса: ' + err);
479
+ clearInterval(pollInterval);
480
+ submitBtn.disabled = false;
481
  }
482
+ }, 800); // poll every 800ms
483
+
 
 
 
 
 
484
  } catch (err) {
485
  alert('Ошибка при отправке: ' + err);
486
  submitBtn.disabled = false;
 
494
  logEl.innerHTML = '';
495
  progressBar.style.width = '0%';
496
  progressBar.textContent = '0%';
497
+ if (pollInterval) clearInterval(pollInterval);
498
+ submitBtn.disabled = false;
499
  });
500
  </script>
501
  </body>
 
506
  @app.route("/", methods=["GET"])
507
  def index():
508
  cleanup_old_files()
509
+ return render_template_string(INDEX_HTML, max_mb=MAX_UPLOAD_MB)
 
510
 
511
 
512
  @app.route("/start", methods=["POST"])
 
519
  return "No selected file", 400
520
 
521
  job_id = str(uuid.uuid4())
 
522
  with jobs_lock:
523
+ job_logs[job_id] = []
524
 
 
525
  options = {
526
  "normalize": bool(request.form.get("normalize")),
527
  "speed": request.form.get("speed", "1.0"),
 
532
  "out_format": request.form.get("out_format", "wav"),
533
  }
534
 
535
+ # Save uploaded file to tmp
 
 
 
536
  temp_in = TMP_DIR / (job_id + "_in")
537
  fs.stream.seek(0)
538
  fs.save(temp_in)
539
+
540
+ # Start background worker
541
+ t = threading.Thread(target=processing_worker, args=(job_id, temp_in, fs.filename, options), daemon=True)
 
 
 
 
 
 
 
 
 
 
542
  t.start()
543
 
544
  return jsonify({"job_id": job_id})
545
 
546
 
547
+ @app.route("/status/<job_id>", methods=["GET"])
548
+ def status(job_id):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  """
550
+ AJAX polling endpoint.
551
+ Query param: last_idx (int) index of last event client already received.
552
+ Response: {"events": [...], "next_index": N}
553
  """
554
+ last_idx = int(request.args.get("last_idx", "0") or 0)
555
+ with jobs_lock:
556
+ logs = job_logs.get(job_id, [])
557
+ # copy slice
558
+ events = logs[last_idx:]
559
+ next_index = last_idx + len(events)
560
+ # If any event has 'file' (filename), convert it to full URL as file_url
561
+ for ev in events:
562
+ if ev.get("file") and not ev.get("file_url"):
563
+ # build full URL
564
+ download_path = url_for("download_file", filename=ev["file"], _external=False)
565
+ host = request.host_url.rstrip('/')
566
+ ev["file_url"] = host + download_path
567
+ return jsonify({"events": events, "next_index": next_index})
568
+
569
+
570
+ @app.route("/download/<filename>", methods=["GET"])
571
  def download_file(filename):
572
  cleanup_old_files()
573
  safe_path = (TMP_DIR / filename).resolve()
 
581
  return f"Error serving file: {e}", 500
582
 
583
 
584
+ @app.route("/healthz", methods=["GET"])
585
  def health():
586
  return "ok", 200
587
 
588
 
 
 
 
589
  if __name__ == "__main__":
590
  port = int(os.environ.get("PORT", 7860))
591
  host = os.environ.get("HOST", "0.0.0.0")