carm5333 commited on
Commit
3f6b8b6
·
verified ·
1 Parent(s): aadc5fd

S146: fix ENGINE_SCRIPTS v11.2→v21, bootstrap, keepalive self-ping

Browse files
Files changed (1) hide show
  1. app.py +208 -489
app.py CHANGED
@@ -1,86 +1,28 @@
1
  """
2
- tilawa-server app.py — v5 (S32 Production)
3
-
4
- Changes from v4:
5
-
6
- Thread Safety:
7
- - Per-job _lock: received_set / received_chunks mutations are now atomic.
8
- upload_chunk, upload_finalize, and _merge_and_run all hold job["_lock"]
9
- before touching received_set.
10
- - HISTORY_LOCK: _add_history() and /history can no longer race.
11
- - Status transitions in _run_engine wrapped in JOBS_LOCK (queued→running,
12
- running→done/error). Progress-only writes remain lock-free (single
13
- Python dict assignment; GIL makes it atomic).
14
-
15
- Input Validation:
16
- - upload_chunk: int(index) in try/except → 400 instead of unhandled 500.
17
- - upload_chunk: index range check against total_chunks.
18
- - download_chunk: offset/size parsed with try/except, then clamped to
19
- [0, file_size] and [0, MAX_DOWNLOAD_CHUNK=32MB].
20
- - upload_start: filename sanitized via _sanitize_filename()
21
- (strips path components, removes non-safe chars) → path traversal closed.
22
- - upload / upload_finalize: engine name validated against ENGINE_SCRIPTS
23
- before being stored; falls back to "v10.0" if unknown.
24
-
25
- Disk & Memory:
26
- - upload_start: _check_disk_free() requires 2× total_size free before
27
- creating the job; returns 503 immediately if disk is full.
28
- - _run_engine: _available_ram_gb() requires ≥ 3.5 GB free before
29
- launching subprocess; returns error if RAM is insufficient.
30
- - _cleanup_old_outputs / _prune_jobs now called by the background janitor
31
- every 30 min regardless of job outcome (not only after success).
32
- - _cleanup_stale_chunks: janitor removes CHUNK_DIR subdirs abandoned
33
- for more than 4 hours.
34
-
35
- Reliability:
36
- - _REF_CACHE: reference audio glob is cached in memory and refreshed at
37
- most once per hour. No disk hit on every engine job.
38
- - _prune_jobs called from janitor — JOBS no longer grows unboundedly if
39
- all jobs are failing.
40
- - Job IDs extended to 16 hex chars (64-bit entropy, up from 32-bit).
41
- - Background _janitor thread started at module load.
42
-
43
- Endpoints (unchanged from v4):
44
- GET / — health check + engine status
45
- GET /ping — lightweight keepalive
46
- GET /queue — current queue depth and active jobs
47
- POST /upload — small files (legacy)
48
- POST /upload_start — start chunked session → {job_id}
49
- POST /upload_chunk — upload one chunk
50
- POST /upload_finalize — merge chunks + start engine
51
- GET /status/<job_id> — poll progress + queue position
52
- GET /download/<job_id> — stream output file
53
- GET /download_chunk/<job_id> — chunked download
54
- GET /history — last 50 jobs
55
- GET /ready — warmup / ref-cache check
56
- """
57
 
58
- import gc
59
- import os
60
- import re as _re
61
- import shutil
62
- import subprocess
63
- import tempfile
64
- import threading
65
- import time
66
- import uuid
67
  from pathlib import Path
68
-
69
  from flask import Flask, Response, jsonify, request, stream_with_context
70
  from flask_cors import CORS
71
 
72
  app = Flask(__name__)
73
  CORS(app)
74
- app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 # 512 MB
75
 
76
- # ── Global state ────────────────────────────────────────────────────────────────
77
  JOBS = {}
78
  HISTORY = []
79
- JOBS_LOCK = threading.Lock() # guards all reads/writes to JOBS
80
- HISTORY_LOCK = threading.Lock() # guards all reads/writes to HISTORY
 
81
 
82
- import gc # S76
83
- _SEMAPHORE = threading.Semaphore(4) # S97: max 4 concurrent engines (16GB/3.5GB)
84
  TMP = Path(tempfile.gettempdir())
85
  UPLOAD_DIR = TMP / "tilawa_uploads"
86
  CHUNK_DIR = TMP / "tilawa_chunks"
@@ -89,11 +31,12 @@ for _d in [UPLOAD_DIR, CHUNK_DIR, OUTPUT_DIR]:
89
  _d.mkdir(exist_ok=True)
90
 
91
  BASE = Path(__file__).parent
92
- ENGINE_SCRIPTS = { # S76
 
93
  "v11.0": BASE / "engine_tajalli_v1.py",
94
  "v11.1": BASE / "true_engine_itiqan_v2_fixed.py",
95
  "v11.2": BASE / "engine_isteidad_v21.py",
96
- "v10.0": BASE / "engine_v100.py",
97
  "v9.0": BASE / "engine_v90.py",
98
  "v8.9": BASE / "engine_v89.py",
99
  "v8.7": BASE / "engine_v87.py",
@@ -102,40 +45,31 @@ ENGINE_SCRIPTS = { # S76
102
  "v8.0": BASE / "engine_v80.py",
103
  "v7.0": BASE / "engine_v70.py",
104
  }
105
- REF_DIR = BASE / "reference_audio"
106
- CHUNK_SIZE = 4 * 1024 * 1024 # 4 MB upload chunks
107
- MAX_DOWNLOAD_CHUNK = 32 * 1024 * 1024 # 32 MB max per download_chunk call
 
108
 
109
- # ── Reference-audio cache ───────────────────────────────────────────────────────
110
- _REF_CACHE = None # list[Path] | None
111
- _REF_CACHE_TS = 0.0 # last refresh epoch
112
  _REF_CACHE_LOCK = threading.Lock()
113
- _REF_CACHE_TTL = 3600 # refresh at most once per hour
114
 
115
-
116
- def _get_ref_files() -> list:
117
- """Return cached list of .mp3 files in REF_DIR, refreshing hourly."""
118
  global _REF_CACHE, _REF_CACHE_TS
119
  now = time.time()
120
  with _REF_CACHE_LOCK:
121
  if _REF_CACHE is None or (now - _REF_CACHE_TS) > _REF_CACHE_TTL:
122
  _REF_CACHE = list(REF_DIR.glob("*.mp3")) if REF_DIR.exists() else []
123
  _REF_CACHE_TS = now
124
- return list(_REF_CACHE) # shallow copy — callers must not mutate
125
-
126
 
127
- # ── Utility helpers ─────────────────────────────────────────────────────────────
128
- def _sanitize_filename(name: str) -> str:
129
- """Strip path traversal and non-safe characters. Limit to 200 chars."""
130
- name = Path(name).name # drop any directory component
131
- name = _re.sub(r"[^\w\-. ]", "_", name) # keep safe chars only
132
  return (name[:200] or "audio")
133
 
134
-
135
- def _available_ram_gb() -> float:
136
- """Return available RAM in GB via /proc/meminfo.
137
- HuggingFace Docker containers report MemAvailable=0 due to cgroup quirk;
138
- fall back to MemTotal in that case. Returns 999 on any failure."""
139
  try:
140
  mem = {}
141
  with open("/proc/meminfo") as fh:
@@ -143,70 +77,44 @@ def _available_ram_gb() -> float:
143
  if line.startswith(("MemAvailable:", "MemTotal:")):
144
  k, v = line.split(":", 1)
145
  mem[k.strip()] = int(v.split()[0])
146
- available = mem.get("MemAvailable", 0)
147
- if available > 0:
148
- return available / (1024 * 1024)
149
- # Fallback: MemTotal (container may not expose MemAvailable)
150
  total = mem.get("MemTotal", 0)
151
- if total > 0:
152
- return total / (1024 * 1024)
153
- except Exception:
154
- pass
155
  return 999.0
156
 
 
 
 
157
 
158
- def _check_disk_free(required_bytes: int) -> bool:
159
- """True if the tmp filesystem has at least required_bytes free."""
160
- try:
161
- return shutil.disk_usage(TMP).free >= required_bytes
162
- except Exception:
163
- return True # optimistic fallback; let the OS raise later if really full
164
-
165
-
166
- def _get_queue_position(job_id: str) -> int:
167
- """1-based position among queued jobs, or 0 if not queued."""
168
  with JOBS_LOCK:
169
  queued = [jid for jid, j in JOBS.items() if j.get("status") == "queued"]
170
- try:
171
- return queued.index(job_id) + 1
172
- except ValueError:
173
- return 0
174
-
175
 
176
- def _count_running() -> int:
177
  with JOBS_LOCK:
178
  return sum(1 for j in JOBS.values() if j.get("status") == "running")
179
 
180
-
181
  def _prune_jobs():
182
- """Remove oldest done/error jobs once JOBS exceeds 200 entries."""
183
  with JOBS_LOCK:
184
- if len(JOBS) <= 200:
185
- return
186
- removable = [
187
- jid for jid, j in list(JOBS.items())
188
- if j.get("status") in ("done", "error")
189
- ]
190
- for jid in removable[:-100]:
191
- JOBS.pop(jid, None)
192
-
193
 
194
  def _cleanup_old_outputs():
195
- """Delete output files older than 2 hours."""
196
  cutoff = time.time() - 7200
197
  try:
198
  for f in OUTPUT_DIR.iterdir():
199
  try:
200
- if f.stat().st_mtime < cutoff:
201
- f.unlink()
202
- except Exception:
203
- pass
204
- except Exception:
205
- pass
206
-
207
 
208
  def _cleanup_stale_chunks():
209
- """Remove chunk directories that have been abandoned for more than 4 hours."""
210
  cutoff = time.time() - 14400
211
  try:
212
  for d in CHUNK_DIR.iterdir():
@@ -214,367 +122,228 @@ def _cleanup_stale_chunks():
214
  try:
215
  if d.stat().st_mtime < cutoff:
216
  shutil.rmtree(d, ignore_errors=True)
217
- except Exception:
218
- pass
219
- except Exception:
220
- pass
221
 
222
-
223
- # ── Background janitor ──────────────────────────────────────────────────────────
224
  def _janitor():
225
- """Runs every 30 minutes: prune jobs, clean outputs, purge stale chunks.
226
- This ensures cleanup happens even when no jobs complete successfully.
227
- """
228
  while True:
229
  time.sleep(1800)
230
  for fn in (_cleanup_old_outputs, _prune_jobs, _cleanup_stale_chunks):
231
- try:
232
- fn()
233
- except Exception:
234
- pass
235
-
236
 
237
  threading.Thread(target=_janitor, daemon=True).start()
238
 
239
-
240
- # ── Self-ping keep-alive ────────────────────────────────────────────────────────
241
  def _keepalive():
242
- """Ping the public HF Space URL every 4 min.
243
- IMPORTANT: must use the public URL, not loopback.
244
- HF sleep detection is at the CDN/router layer — loopback pings
245
- (127.0.0.1) are invisible to HF infrastructure and do NOT prevent
246
- the space from sleeping. Only external requests count.
247
- """
248
  import urllib.request
249
- # S33: wait 120s so gunicorn is fully ready before first external ping
250
  time.sleep(120)
251
- _PUBLIC = "https://carm5333-tilawa-server.hf.space/ping"
 
 
252
  while True:
253
- try:
254
- urllib.request.urlopen(_PUBLIC, timeout=15)
255
- except Exception:
256
- pass
257
- time.sleep(240) # every 4 min
258
 
259
  threading.Thread(target=_keepalive, daemon=True).start()
260
 
261
-
262
- # ── Health ──────────────────────────────────────────────────────────────────────
263
  @app.route("/")
264
  def health():
265
  engines = {k: v.exists() for k, v in ENGINE_SCRIPTS.items()}
266
  refs = _get_ref_files()
267
- return jsonify({
268
- "status": "ok",
269
- "engines": engines,
270
- "refs": len(refs),
271
- "chunk_size": CHUNK_SIZE,
272
- "running": _count_running(),
273
- })
274
-
275
 
276
  @app.route("/ping")
277
  def ping():
278
  return jsonify({"ok": True, "t": time.time()})
279
 
280
-
281
  @app.route("/queue")
282
  def queue_status():
283
  with JOBS_LOCK:
284
  running = sum(1 for j in JOBS.values() if j.get("status") == "running")
285
  queued = sum(1 for j in JOBS.values() if j.get("status") == "queued")
286
- return jsonify({
287
- "running": running,
288
- "queued": queued,
289
- "capacity": 3,
290
- "available": max(0, 3 - running),
291
- })
292
 
 
 
 
 
293
 
294
- # ── Legacy small upload ─────────────────────────────────────────────────────────
295
  @app.route("/upload", methods=["POST"])
296
  def upload():
297
  if "file" not in request.files:
298
  return jsonify({"error": "no file"}), 400
299
-
300
  f = request.files["file"]
301
- engine = request.form.get("engine", "v11.0")
302
- if engine not in ENGINE_SCRIPTS:
303
- engine = "v11.0"
304
-
305
  job_id = str(uuid.uuid4())[:16]
306
  suffix = Path(_sanitize_filename(f.filename or "audio.mp3")).suffix or ".mp3"
307
  in_path = UPLOAD_DIR / f"{job_id}_input{suffix}"
308
  f.save(str(in_path))
309
-
310
- _init_job(job_id, engine, str(in_path),
311
- original_name=f.filename or "audio")
312
- def _queued(jid): # S76: semaphore wrapper
313
- _SEMAPHORE.acquire()
314
- try:
315
- _run_engine(jid)
316
- finally:
317
- gc.collect()
318
- _SEMAPHORE.release()
319
- threading.Thread(target=_queued, args=(job_id,), daemon=True).start()
320
  return jsonify({"job_id": job_id})
321
 
322
-
323
- # ── Chunked upload ──────────────────────────────────────────────────────────────
324
  @app.route("/upload_start", methods=["POST"])
325
  def upload_start():
326
  data = request.get_json(silent=True) or {}
327
  filename = _sanitize_filename(data.get("filename", "audio.mp3"))
328
  total_size = int(data.get("total_size", 0))
329
-
330
- # Disk guard: require 2× the upload size free (space for input + output).
331
- required = max(total_size * 2, 10 * 1024 * 1024)
332
  if not _check_disk_free(required):
333
  return jsonify({"error": "server disk full — try later"}), 503
334
-
335
  total_chunks = max(1, -(-total_size // CHUNK_SIZE))
336
  job_id = str(uuid.uuid4())[:16]
337
  suffix = Path(filename).suffix or ".mp3"
338
-
339
  job = {
340
- "status": "uploading",
341
- "fcm_token": data.get("fcm_token", ""),
342
- "progress": 0,
343
- "label": "جارٍ الرفع...",
344
- "engine": "v11.0",
345
- "filename": f"enhanced_{job_id}_1425h.mp3",
346
- "in_path": str(UPLOAD_DIR / f"{job_id}_input{suffix}"),
347
- "out_path": str(OUTPUT_DIR / f"enhanced_{job_id}_1425h.mp3"),
348
- "score": None, "lufs": None, "rms": None,
349
- "crest": None, "lra": None,
350
- "timestamp": time.strftime("%Y-%m-%d %H:%M"),
351
- "suffix": suffix,
352
- "total_chunks": total_chunks,
353
- "received_chunks": 0,
354
- "received_set": set(),
355
- "total_size": total_size,
356
- "_lock": threading.Lock(), # per-job lock for received_set ops
357
  }
358
- with JOBS_LOCK:
359
- JOBS[job_id] = job
360
-
361
  (CHUNK_DIR / job_id).mkdir(exist_ok=True)
362
- return jsonify({
363
- "job_id": job_id,
364
- "chunk_size": CHUNK_SIZE,
365
- "total_chunks": total_chunks,
366
- })
367
-
368
 
369
  @app.route("/upload_chunk", methods=["POST"])
370
  def upload_chunk():
371
  job_id = request.form.get("job_id")
372
-
373
- try:
374
- index = int(request.form.get("index", 0))
375
  except (ValueError, TypeError):
376
- return jsonify({"error": "invalid index — must be integer"}), 400
377
-
378
- with JOBS_LOCK:
379
- job = JOBS.get(job_id)
380
  if not job_id or job is None:
381
  return jsonify({"error": "invalid job_id"}), 400
382
  if "chunk" not in request.files:
383
  return jsonify({"error": "no chunk"}), 400
384
-
385
  total = job["total_chunks"]
386
  if not (0 <= index < total):
387
  return jsonify({"error": f"index out of range [0, {total})"}), 400
388
-
389
  chunk_path = CHUNK_DIR / job_id / f"chunk_{index:04d}"
390
-
391
- # Hold the per-job lock for the entire read-modify-write on received_set.
392
  with job["_lock"]:
393
  if index not in job["received_set"]:
394
  request.files["chunk"].save(str(chunk_path))
395
  job["received_set"].add(index)
396
  job["received_chunks"] = len(job["received_set"])
397
-
398
  received = job["received_chunks"]
399
  missing = [i for i in range(total) if i not in job["received_set"]]
400
-
401
- # Progress update is a plain scalar write — safe outside the lock.
402
  job["progress"] = int((received / total) * 30)
403
  job["label"] = f"رفع {received}/{total}..."
404
-
405
  return jsonify({"received": received, "total": total,
406
  "ok": True, "missing": missing})
407
 
408
-
409
  @app.route("/upload_finalize", methods=["POST"])
410
  def upload_finalize():
411
  data = request.get_json(silent=True) or {}
412
  job_id = data.get("job_id")
413
- engine = data.get("engine", "v11.0")
414
- if engine not in ENGINE_SCRIPTS:
415
- engine = "v11.0"
416
-
417
- with JOBS_LOCK:
418
- job = JOBS.get(job_id)
419
  if not job_id or job is None:
420
  return jsonify({"error": "invalid job_id"}), 400
421
-
422
  with job["_lock"]:
423
  total = job["total_chunks"]
424
  missing = [i for i in range(total) if i not in job["received_set"]]
425
-
426
  if missing:
427
- return jsonify({
428
- "error": f"missing {len(missing)} chunk(s): {missing[:5]}",
429
- "missing": missing,
430
- }), 400
431
-
432
- job["engine"] = engine
433
- job["status"] = "merging"
434
- job["label"] = "دمج الأجزاء..."
435
  job["progress"] = 32
436
-
437
  threading.Thread(target=_merge_and_run, args=(job_id,), daemon=True).start()
438
  return jsonify({"job_id": job_id, "status": "merging"})
439
 
440
-
441
- # ── Internal: init job ──────────────────────────────────────────────────────────
442
  def _init_job(job_id, engine, in_path, original_name="audio"):
443
  out_name = f"enhanced_{job_id}_1425h.mp3"
444
- job = {
445
- "status": "queued", "progress": 5,
446
- "label": "في الطابور...", "engine": engine,
447
- "filename": out_name, "in_path": in_path,
448
- "out_path": str(OUTPUT_DIR / out_name),
449
- "score": None, "lufs": None, "rms": None,
450
- "crest": None, "lra": None,
451
- "timestamp": time.strftime("%Y-%m-%d %H:%M"),
452
- "_lock": threading.Lock(), # not used for small-upload jobs but keeps interface uniform
453
- }
454
  with JOBS_LOCK:
455
- JOBS[job_id] = job
456
-
 
 
 
 
 
 
457
 
458
- # ── Internal: merge chunks then run engine ──────────────────────────────────────
459
  def _merge_and_run(job_id):
460
- with JOBS_LOCK:
461
- job = JOBS.get(job_id)
462
- if not job:
463
- return
464
-
465
  chunk_dir = CHUNK_DIR / job_id
466
  in_path = Path(job["in_path"])
467
-
468
  try:
469
- chunks = sorted(
470
- chunk_dir.glob("chunk_*"),
471
- key=lambda p: int(p.stem.split("_")[1]),
472
- )
473
- if not chunks:
474
- raise RuntimeError("No chunks found")
475
-
476
  with open(in_path, "wb") as out_f:
477
  for cp in chunks:
478
- out_f.write(cp.read_bytes())
479
- cp.unlink()
480
- try:
481
- chunk_dir.rmdir()
482
- except Exception:
483
- pass
484
-
485
  job["progress"] = 35
486
  job["label"] = f"تم الدمج — {in_path.stat().st_size // 1024 // 1024}MB"
487
  job["status"] = "queued"
488
-
489
  _run_engine_queued(job_id)
490
-
491
  except Exception as e:
492
- job["status"] = "error"
493
- job["error"] = str(e)
494
  job["label"] = f"خطأ في الدمج: {e}"
495
 
496
-
497
- # ── Internal: queue-aware engine runner ─────────────────────────────────────────
498
  def _run_engine_queued(job_id):
499
- """
500
- Acquires SEMAPHORE before running the engine.
501
- If all 3 slots are busy, this thread blocks until one frees up.
502
- The job shows status='queued' while waiting.
503
- """
504
- with JOBS_LOCK:
505
- job = JOBS.get(job_id)
506
- if not job:
507
- return
508
-
509
  job["status"] = "queued"
510
- job["label"] = f"في الطابور — {_count_running()} تشغيل، انتظر قليلاً..."
511
-
512
  SEMAPHORE.acquire()
513
- try:
514
- _run_engine(job_id)
515
- finally:
516
- SEMAPHORE.release()
517
- gc.collect()
518
 
519
-
520
- # ── Internal: engine runner ─────────────────────────────────────────��───────────
521
  def _run_engine(job_id):
522
- with JOBS_LOCK:
523
- job = JOBS.get(job_id)
524
- if not job:
525
- return
526
-
527
- # ── RAM guard: require ≥ 3.5 GB available before launching subprocess ──
528
  ram_gb = _available_ram_gb()
529
  if ram_gb < 0.5:
530
  with JOBS_LOCK:
531
  job["status"] = "error"
532
- job["error"] = f"Insufficient RAM ({ram_gb:.1f} GB free, need 0.5 GB)"
533
  job["label"] = "خطأ: ذاكرة غير كافية — أعد المحاولة لاحقاً"
534
  return
535
-
536
  script = ENGINE_SCRIPTS.get(job["engine"])
537
-
538
- # Status transition: queued → running (held under lock)
539
  with JOBS_LOCK:
540
  job["status"] = "running"
541
  job["progress"] = max(job.get("progress", 0), 35)
542
  job["label"] = f"تشغيل المحرك {job['engine']}..."
543
-
544
  success = False
545
-
546
  if script and script.exists():
547
  try:
548
  ref_files = _get_ref_files()
549
- if not ref_files:
550
- with JOBS_LOCK:
551
- job["status"] = "error"
552
- job["error"] = "Reference audio not found redeploy or retry after warmup"
553
- job["label"] = "خطأ: ملفات المرجع غير موجودة"
554
- return
555
-
556
- cmd = [
557
- "python3", str(script),
558
- "-i", job["in_path"],
559
- "-o", job["out_path"],
560
- "--iterations", "3",
561
- ]
562
- for rf in ref_files[:3]:
563
- cmd += ["--ref", str(rf)]
564
-
565
  proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
566
- stderr=subprocess.STDOUT, text=True,
567
- bufsize=1) # S76: line-buffered
568
  for line in proc.stdout:
569
  line = line.strip()
570
- if any(x in line for x in ("Pass 1","[٧]","[١]","[T-0]","Tier:","TIER_","تصنيف")):
571
- job["progress"] = 40; job["label"] = "تحليل الملف..."
572
- elif any(x in line for x in ("Pass 2","[٨]","[٢]","[E1]","[E2]","الإتقان","الاسترداد")):
573
- job["progress"] = 55; job["label"] = "معالجة المحرك..."
574
- elif any(x in line for x in ("Pass 3","[٩]","[٣]","[B4]","البيان","VQS")):
575
- job["progress"] = 70; job["label"] = "تحسين الصوت..."
576
- elif any(x in line for x in ("Pass 4","[٤]","[B5]","النور","[T-6]","[T-7]")):
577
- job["progress"] = 85; job["label"] = "الترميز النهائي..."
 
 
578
  elif "LUFS=" in line:
579
  for part in line.split():
580
  try:
@@ -582,190 +351,140 @@ def _run_engine(job_id):
582
  elif "RMS=" in part: job["rms"] = part.split("=")[1]
583
  elif "Crest=" in part: job["crest"] = part.split("=")[1]
584
  elif "LRA=" in part: job["lra"] = part.split("=")[1]
585
- except Exception:
586
- pass
587
  if "/100" in line:
588
  m = _re.search(r"(\d{2,3}\.?\d*)\s*/\s*100", line)
589
  if m:
590
  try:
591
  s = float(m.group(1))
592
  if 50.0 <= s <= 100.0:
593
- job["score"] = s
594
- job["progress"] = 95
595
  job["label"] = "حساب النتيجة..."
596
- except Exception:
597
- pass
598
- try: # S76: 90-min hard timeout
599
- proc.wait(timeout=5400)
600
- except subprocess.TimeoutExpired:
601
- proc.kill(); proc.wait()
602
- job["status"] = "error"
603
- job["error"] = "Engine timed out after 90 min"
604
- job["label"] = "خطأ: انتهت مهلة المعالجة"
605
- return
606
  _out = Path(job["out_path"])
607
- if _out.exists() and _out.stat().st_size > 500: # S76
608
  success = True
609
  else:
610
  job["engine_rc"] = proc.returncode
611
-
612
  except Exception as exc:
613
  job["engine_error"] = str(exc)
614
-
615
  if not success:
616
- # Status transition: running → error (held under lock)
617
- with JOBS_LOCK:
618
- if job.get("status") != "error": # watchdog may have set it already
619
- job["status"] = "error"
620
- job["error"] = "Engine failed — please retry"
621
- job["label"] = "خطأ: فشل المحرك — أعد المحاولة"
622
- return
623
-
624
- # Status transition: running → done (held under lock)
 
 
 
 
625
  with JOBS_LOCK:
626
- job["status"] = "done"
627
- job["progress"] = 100
628
- job["label"] = "اكتملت ✓"
629
- if not job.get("score"):
630
- job["score"] = 90
631
-
632
  _add_history(job)
 
 
 
633
 
634
- # Cleanup input file
635
- try:
636
- Path(job["in_path"]).unlink(missing_ok=True)
637
- except Exception:
638
- pass
639
-
640
-
641
- # ── History ─────────────────────────────────────────────────────────────────────
642
  def _add_history(job):
643
- entry = {
644
- k: job[k]
645
- for k in ["engine", "filename", "score", "lufs", "rms", "crest", "lra", "timestamp"]
646
- if k in job
647
- }
648
  with HISTORY_LOCK:
649
  HISTORY.insert(0, entry)
650
- if len(HISTORY) > 50:
651
- HISTORY.pop()
652
 
653
-
654
- # ── Status ───────────────────────────────────────────────────────────────────────
655
  @app.route("/status/<job_id>")
656
  def status(job_id):
657
- with JOBS_LOCK:
658
- job = JOBS.get(job_id)
659
- if not job:
660
- return jsonify({"error": "not found"}), 404
661
-
662
- resp = {
663
- k: job[k]
664
- for k in ["status", "progress", "label", "score", "lufs", "rms", "crest", "lra", "filename"]
665
- if k in job
666
- }
667
  resp["queue_position"] = _get_queue_position(job_id) if job.get("status") == "queued" else 0
668
-
669
- if "error" in job:
670
- resp["error"] = job["error"]
671
-
672
- # DEBUG: expose last engine output lines so we can see the crash
673
- if job.get("status") == "error" and "engine_log" in job:
674
- resp["engine_log"] = job["engine_log"]
675
  resp["engine_rc"] = job.get("engine_rc")
676
-
677
  return jsonify(resp)
678
 
679
-
680
- # ── Download ─────────────────────────────────────────────────────────────────────
681
  @app.route("/download/<job_id>")
682
  def download(job_id):
683
- with JOBS_LOCK:
684
- job = JOBS.get(job_id)
685
  if not job or job["status"] != "done":
686
  return jsonify({"error": "not ready"}), 404
687
  path = Path(job["out_path"])
688
  if not path.exists():
689
  return jsonify({"error": "file missing — may have expired"}), 404
690
-
691
  file_size = path.stat().st_size
692
-
693
  def generate():
694
  with open(path, "rb") as f:
695
  while True:
696
  chunk = f.read(1024 * 1024)
697
- if not chunk:
698
- break
699
  yield chunk
700
-
701
- return Response(
702
- stream_with_context(generate()),
703
- headers={
704
- "Content-Disposition": f'attachment; filename="{job["filename"]}"',
705
- "Content-Type": "audio/mpeg",
706
- "Content-Length": str(file_size),
707
- },
708
- )
709
-
710
 
711
  @app.route("/download_chunk/<job_id>")
712
  def download_chunk(job_id):
713
- with JOBS_LOCK:
714
- job = JOBS.get(job_id)
715
  if not job or job["status"] != "done":
716
  return jsonify({"error": "not ready"}), 404
717
  path = Path(job["out_path"])
718
- if not path.exists():
719
- return jsonify({"error": "file missing"}), 404
720
-
721
  file_size = path.stat().st_size
722
-
723
  try:
724
  offset = int(request.args.get("offset", 0))
725
  size = int(request.args.get("size", CHUNK_SIZE))
726
  except (ValueError, TypeError):
727
- return jsonify({"error": "invalid offset or size — must be integers"}), 400
728
-
729
- # Clamp: offset within [0, file_size], size within [0, MAX_DOWNLOAD_CHUNK]
730
  offset = max(0, min(offset, file_size))
731
  size = max(0, min(size, MAX_DOWNLOAD_CHUNK, file_size - offset))
732
-
733
  with open(path, "rb") as f:
734
- f.seek(offset)
735
- data = f.read(size)
736
-
737
- return Response(
738
- data,
739
- headers={
740
- "Content-Type": "audio/mpeg",
741
- "Content-Length": str(len(data)),
742
- "X-File-Size": str(file_size),
743
- "X-Offset": str(offset),
744
- },
745
- )
746
-
747
 
748
  @app.route("/history")
749
  def history():
750
- with HISTORY_LOCK:
751
- snapshot = list(HISTORY)
752
  return jsonify({"jobs": snapshot})
753
 
754
-
755
- @app.route("/ready")
756
- def ready():
757
- refs_warm = len(list(REF_DIR.glob("*.mp3"))) >= 1 if REF_DIR.exists() else False
758
- engines_ok = {k: v.exists() for k, v in ENGINE_SCRIPTS.items()}
759
- return jsonify({
760
- "ready": refs_warm,
761
- "refs_warm": refs_warm,
762
- "engines": engines_ok,
763
- "default": "v11.0",
764
- })
765
-
766
-
767
  if __name__ == "__main__":
768
  port = int(os.environ.get("PORT", 7860))
769
  app.run(host="0.0.0.0", port=port, threaded=True)
770
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
 
 
 
1
  """
2
+ tilawa-server app.py — v7 (S62 Production)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ Key changes:
5
+ - ENGINE_SCRIPTS uses real filenames deployed on HF Space
6
+ - SEMAPHORE=3 (matches HF free tier 16GB RAM)
7
+ - Self-ping URL configurable via SPACE_URL env var
8
+ - All v5 thread-safety, disk/RAM guards, janitor retained
9
+ """
10
+ import gc, os, re as _re, shutil, subprocess, tempfile
11
+ import threading, time, uuid
 
12
  from pathlib import Path
 
13
  from flask import Flask, Response, jsonify, request, stream_with_context
14
  from flask_cors import CORS
15
 
16
  app = Flask(__name__)
17
  CORS(app)
18
+ app.config["MAX_CONTENT_LENGTH"] = 6 * 1024 * 1024
19
 
 
20
  JOBS = {}
21
  HISTORY = []
22
+ JOBS_LOCK = threading.Lock()
23
+ HISTORY_LOCK = threading.Lock()
24
+ SEMAPHORE = threading.Semaphore(3)
25
 
 
 
26
  TMP = Path(tempfile.gettempdir())
27
  UPLOAD_DIR = TMP / "tilawa_uploads"
28
  CHUNK_DIR = TMP / "tilawa_chunks"
 
31
  _d.mkdir(exist_ok=True)
32
 
33
  BASE = Path(__file__).parent
34
+
35
+ ENGINE_SCRIPTS = {
36
  "v11.0": BASE / "engine_tajalli_v1.py",
37
  "v11.1": BASE / "true_engine_itiqan_v2_fixed.py",
38
  "v11.2": BASE / "engine_isteidad_v21.py",
39
+ "v10.0": BASE / "engine_tajalli_v1.py",
40
  "v9.0": BASE / "engine_v90.py",
41
  "v8.9": BASE / "engine_v89.py",
42
  "v8.7": BASE / "engine_v87.py",
 
45
  "v8.0": BASE / "engine_v80.py",
46
  "v7.0": BASE / "engine_v70.py",
47
  }
48
+ DEFAULT_ENGINE = "v11.0"
49
+ REF_DIR = BASE / "reference_audio"
50
+ CHUNK_SIZE = 4 * 1024 * 1024
51
+ MAX_DOWNLOAD_CHUNK = 32 * 1024 * 1024
52
 
53
+ _REF_CACHE = None
54
+ _REF_CACHE_TS = 0.0
 
55
  _REF_CACHE_LOCK = threading.Lock()
56
+ _REF_CACHE_TTL = 3600
57
 
58
+ def _get_ref_files():
 
 
59
  global _REF_CACHE, _REF_CACHE_TS
60
  now = time.time()
61
  with _REF_CACHE_LOCK:
62
  if _REF_CACHE is None or (now - _REF_CACHE_TS) > _REF_CACHE_TTL:
63
  _REF_CACHE = list(REF_DIR.glob("*.mp3")) if REF_DIR.exists() else []
64
  _REF_CACHE_TS = now
65
+ return list(_REF_CACHE)
 
66
 
67
+ def _sanitize_filename(name):
68
+ name = Path(name).name
69
+ name = _re.sub(r"[^\w\-. ]", "_", name)
 
 
70
  return (name[:200] or "audio")
71
 
72
+ def _available_ram_gb():
 
 
 
 
73
  try:
74
  mem = {}
75
  with open("/proc/meminfo") as fh:
 
77
  if line.startswith(("MemAvailable:", "MemTotal:")):
78
  k, v = line.split(":", 1)
79
  mem[k.strip()] = int(v.split()[0])
80
+ avail = mem.get("MemAvailable", 0)
81
+ if avail > 0: return avail / (1024 * 1024)
 
 
82
  total = mem.get("MemTotal", 0)
83
+ if total > 0: return total / (1024 * 1024)
84
+ except Exception: pass
 
 
85
  return 999.0
86
 
87
+ def _check_disk_free(required_bytes):
88
+ try: return shutil.disk_usage(TMP).free >= required_bytes
89
+ except Exception: return True
90
 
91
+ def _get_queue_position(job_id):
 
 
 
 
 
 
 
 
 
92
  with JOBS_LOCK:
93
  queued = [jid for jid, j in JOBS.items() if j.get("status") == "queued"]
94
+ try: return queued.index(job_id) + 1
95
+ except ValueError: return 0
 
 
 
96
 
97
+ def _count_running():
98
  with JOBS_LOCK:
99
  return sum(1 for j in JOBS.values() if j.get("status") == "running")
100
 
 
101
  def _prune_jobs():
 
102
  with JOBS_LOCK:
103
+ if len(JOBS) <= 200: return
104
+ removable = [jid for jid, j in list(JOBS.items())
105
+ if j.get("status") in ("done", "error")]
106
+ for jid in removable[:-100]: JOBS.pop(jid, None)
 
 
 
 
 
107
 
108
  def _cleanup_old_outputs():
 
109
  cutoff = time.time() - 7200
110
  try:
111
  for f in OUTPUT_DIR.iterdir():
112
  try:
113
+ if f.stat().st_mtime < cutoff: f.unlink()
114
+ except Exception: pass
115
+ except Exception: pass
 
 
 
 
116
 
117
  def _cleanup_stale_chunks():
 
118
  cutoff = time.time() - 14400
119
  try:
120
  for d in CHUNK_DIR.iterdir():
 
122
  try:
123
  if d.stat().st_mtime < cutoff:
124
  shutil.rmtree(d, ignore_errors=True)
125
+ except Exception: pass
126
+ except Exception: pass
 
 
127
 
 
 
128
  def _janitor():
 
 
 
129
  while True:
130
  time.sleep(1800)
131
  for fn in (_cleanup_old_outputs, _prune_jobs, _cleanup_stale_chunks):
132
+ try: fn()
133
+ except Exception: pass
 
 
 
134
 
135
  threading.Thread(target=_janitor, daemon=True).start()
136
 
 
 
137
  def _keepalive():
 
 
 
 
 
 
138
  import urllib.request
 
139
  time.sleep(120)
140
+ # S146: use SPACE_HOST so every space pings itself, not just tilawa-server
141
+ host = os.environ.get("SPACE_HOST", "carm5333-tilawa-server.hf.space")
142
+ url = os.environ.get("SPACE_URL", f"https://{host}/ping")
143
  while True:
144
+ try: urllib.request.urlopen(url, timeout=15)
145
+ except Exception: pass
146
+ time.sleep(240)
 
 
147
 
148
  threading.Thread(target=_keepalive, daemon=True).start()
149
 
 
 
150
  @app.route("/")
151
  def health():
152
  engines = {k: v.exists() for k, v in ENGINE_SCRIPTS.items()}
153
  refs = _get_ref_files()
154
+ return jsonify({"status": "ok", "engines": engines,
155
+ "refs": len(refs), "chunk_size": CHUNK_SIZE,
156
+ "running": _count_running()})
 
 
 
 
 
157
 
158
  @app.route("/ping")
159
  def ping():
160
  return jsonify({"ok": True, "t": time.time()})
161
 
 
162
  @app.route("/queue")
163
  def queue_status():
164
  with JOBS_LOCK:
165
  running = sum(1 for j in JOBS.values() if j.get("status") == "running")
166
  queued = sum(1 for j in JOBS.values() if j.get("status") == "queued")
167
+ return jsonify({"running": running, "queued": queued,
168
+ "capacity": 3, "available": max(0, 3 - running)})
 
 
 
 
169
 
170
+ @app.route("/ready")
171
+ def ready():
172
+ return jsonify({"ready": True,
173
+ "engines": {k: v.exists() for k, v in ENGINE_SCRIPTS.items()}})
174
 
 
175
  @app.route("/upload", methods=["POST"])
176
  def upload():
177
  if "file" not in request.files:
178
  return jsonify({"error": "no file"}), 400
 
179
  f = request.files["file"]
180
+ engine = request.form.get("engine", DEFAULT_ENGINE)
181
+ if engine not in ENGINE_SCRIPTS: engine = DEFAULT_ENGINE
 
 
182
  job_id = str(uuid.uuid4())[:16]
183
  suffix = Path(_sanitize_filename(f.filename or "audio.mp3")).suffix or ".mp3"
184
  in_path = UPLOAD_DIR / f"{job_id}_input{suffix}"
185
  f.save(str(in_path))
186
+ _init_job(job_id, engine, str(in_path), original_name=f.filename or "audio")
187
+ threading.Thread(target=_run_engine_queued, args=(job_id,), daemon=True).start()
 
 
 
 
 
 
 
 
 
188
  return jsonify({"job_id": job_id})
189
 
 
 
190
  @app.route("/upload_start", methods=["POST"])
191
  def upload_start():
192
  data = request.get_json(silent=True) or {}
193
  filename = _sanitize_filename(data.get("filename", "audio.mp3"))
194
  total_size = int(data.get("total_size", 0))
195
+ required = max(total_size * 2, 10 * 1024 * 1024)
 
 
196
  if not _check_disk_free(required):
197
  return jsonify({"error": "server disk full — try later"}), 503
 
198
  total_chunks = max(1, -(-total_size // CHUNK_SIZE))
199
  job_id = str(uuid.uuid4())[:16]
200
  suffix = Path(filename).suffix or ".mp3"
 
201
  job = {
202
+ "status": "uploading", "progress": 0, "label": "جارٍ الرفع...",
203
+ "engine": DEFAULT_ENGINE, "fcm_token": data.get("fcm_token", ""),
204
+ "filename": f"enhanced_{job_id}_1425h.mp3",
205
+ "in_path": str(UPLOAD_DIR / f"{job_id}_input{suffix}"),
206
+ "out_path": str(OUTPUT_DIR / f"enhanced_{job_id}_1425h.mp3"),
207
+ "score": None, "lufs": None, "rms": None, "crest": None, "lra": None,
208
+ "timestamp": time.strftime("%Y-%m-%d %H:%M"),
209
+ "suffix": suffix, "total_chunks": total_chunks,
210
+ "received_chunks": 0, "received_set": set(), "total_size": total_size,
211
+ "_lock": threading.Lock(),
 
 
 
 
 
 
 
212
  }
213
+ with JOBS_LOCK: JOBS[job_id] = job
 
 
214
  (CHUNK_DIR / job_id).mkdir(exist_ok=True)
215
+ return jsonify({"job_id": job_id, "chunk_size": CHUNK_SIZE,
216
+ "total_chunks": total_chunks})
 
 
 
 
217
 
218
  @app.route("/upload_chunk", methods=["POST"])
219
  def upload_chunk():
220
  job_id = request.form.get("job_id")
221
+ try: index = int(request.form.get("index", 0))
 
 
222
  except (ValueError, TypeError):
223
+ return jsonify({"error": "invalid index"}), 400
224
+ with JOBS_LOCK: job = JOBS.get(job_id)
 
 
225
  if not job_id or job is None:
226
  return jsonify({"error": "invalid job_id"}), 400
227
  if "chunk" not in request.files:
228
  return jsonify({"error": "no chunk"}), 400
 
229
  total = job["total_chunks"]
230
  if not (0 <= index < total):
231
  return jsonify({"error": f"index out of range [0, {total})"}), 400
 
232
  chunk_path = CHUNK_DIR / job_id / f"chunk_{index:04d}"
 
 
233
  with job["_lock"]:
234
  if index not in job["received_set"]:
235
  request.files["chunk"].save(str(chunk_path))
236
  job["received_set"].add(index)
237
  job["received_chunks"] = len(job["received_set"])
 
238
  received = job["received_chunks"]
239
  missing = [i for i in range(total) if i not in job["received_set"]]
 
 
240
  job["progress"] = int((received / total) * 30)
241
  job["label"] = f"رفع {received}/{total}..."
 
242
  return jsonify({"received": received, "total": total,
243
  "ok": True, "missing": missing})
244
 
 
245
  @app.route("/upload_finalize", methods=["POST"])
246
  def upload_finalize():
247
  data = request.get_json(silent=True) or {}
248
  job_id = data.get("job_id")
249
+ engine = data.get("engine", DEFAULT_ENGINE)
250
+ if engine not in ENGINE_SCRIPTS: engine = DEFAULT_ENGINE
251
+ with JOBS_LOCK: job = JOBS.get(job_id)
 
 
 
252
  if not job_id or job is None:
253
  return jsonify({"error": "invalid job_id"}), 400
 
254
  with job["_lock"]:
255
  total = job["total_chunks"]
256
  missing = [i for i in range(total) if i not in job["received_set"]]
 
257
  if missing:
258
+ return jsonify({"error": f"missing {len(missing)} chunk(s): {missing[:5]}",
259
+ "missing": missing}), 400
260
+ job["engine"] = engine
261
+ job["status"] = "merging"
262
+ job["label"] = "دمج الأجزاء..."
 
 
 
263
  job["progress"] = 32
 
264
  threading.Thread(target=_merge_and_run, args=(job_id,), daemon=True).start()
265
  return jsonify({"job_id": job_id, "status": "merging"})
266
 
 
 
267
  def _init_job(job_id, engine, in_path, original_name="audio"):
268
  out_name = f"enhanced_{job_id}_1425h.mp3"
 
 
 
 
 
 
 
 
 
 
269
  with JOBS_LOCK:
270
+ JOBS[job_id] = {
271
+ "status": "queued", "progress": 5, "label": "في الطابور...",
272
+ "engine": engine, "filename": out_name, "in_path": in_path,
273
+ "out_path": str(OUTPUT_DIR / out_name),
274
+ "score": None, "lufs": None, "rms": None, "crest": None, "lra": None,
275
+ "timestamp": time.strftime("%Y-%m-%d %H:%M"),
276
+ "_lock": threading.Lock(),
277
+ }
278
 
 
279
  def _merge_and_run(job_id):
280
+ with JOBS_LOCK: job = JOBS.get(job_id)
281
+ if not job: return
 
 
 
282
  chunk_dir = CHUNK_DIR / job_id
283
  in_path = Path(job["in_path"])
 
284
  try:
285
+ chunks = sorted(chunk_dir.glob("chunk_*"),
286
+ key=lambda p: int(p.stem.split("_")[1]))
287
+ if not chunks: raise RuntimeError("No chunks found")
 
 
 
 
288
  with open(in_path, "wb") as out_f:
289
  for cp in chunks:
290
+ out_f.write(cp.read_bytes()); cp.unlink()
291
+ try: chunk_dir.rmdir()
292
+ except Exception: pass
 
 
 
 
293
  job["progress"] = 35
294
  job["label"] = f"تم الدمج — {in_path.stat().st_size // 1024 // 1024}MB"
295
  job["status"] = "queued"
 
296
  _run_engine_queued(job_id)
 
297
  except Exception as e:
298
+ job["status"] = "error"; job["error"] = str(e)
 
299
  job["label"] = f"خطأ في الدمج: {e}"
300
 
 
 
301
  def _run_engine_queued(job_id):
302
+ with JOBS_LOCK: job = JOBS.get(job_id)
303
+ if not job: return
 
 
 
 
 
 
 
 
304
  job["status"] = "queued"
305
+ job["label"] = "في الطابور — انتظر قليلاً..."
 
306
  SEMAPHORE.acquire()
307
+ try: _run_engine(job_id)
308
+ finally: SEMAPHORE.release(); gc.collect()
 
 
 
309
 
 
 
310
  def _run_engine(job_id):
311
+ with JOBS_LOCK: job = JOBS.get(job_id)
312
+ if not job: return
 
 
 
 
313
  ram_gb = _available_ram_gb()
314
  if ram_gb < 0.5:
315
  with JOBS_LOCK:
316
  job["status"] = "error"
 
317
  job["label"] = "خطأ: ذاكرة غير كافية — أعد المحاولة لاحقاً"
318
  return
 
319
  script = ENGINE_SCRIPTS.get(job["engine"])
 
 
320
  with JOBS_LOCK:
321
  job["status"] = "running"
322
  job["progress"] = max(job.get("progress", 0), 35)
323
  job["label"] = f"تشغيل المحرك {job['engine']}..."
 
324
  success = False
 
325
  if script and script.exists():
326
  try:
327
  ref_files = _get_ref_files()
328
+ cmd = ["python3", str(script),
329
+ "-i", job["in_path"], "-o", job["out_path"],
330
+ "--iterations", "3"]
331
+ for rf in ref_files[:3]: cmd += ["--ref", str(rf)]
 
 
 
 
 
 
 
 
 
 
 
 
332
  proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
333
+ stderr=subprocess.STDOUT, text=True)
334
+ engine_log = []
335
  for line in proc.stdout:
336
  line = line.strip()
337
+ engine_log.append(line)
338
+ if len(engine_log) > 60: engine_log.pop(0)
339
+ if "Pass 1" in line or "[٧]" in line or "[١]" in line:
340
+ job["progress"] = 45; job["label"] = "Pass 1 — تحليل الطيف..."
341
+ elif "Pass 2" in line or "[٨]" in line or "[٢]" in line:
342
+ job["progress"] = 60; job["label"] = "Pass 2 — ضبط LUFS..."
343
+ elif "Pass 3" in line or "[٩]" in line or "[٣]" in line:
344
+ job["progress"] = 75; job["label"] = "Pass 3 — تصحيح..."
345
+ elif "Pass 4" in line or "[٤]" in line:
346
+ job["progress"] = 88; job["label"] = "Pass 4 — تشفير MP3..."
347
  elif "LUFS=" in line:
348
  for part in line.split():
349
  try:
 
351
  elif "RMS=" in part: job["rms"] = part.split("=")[1]
352
  elif "Crest=" in part: job["crest"] = part.split("=")[1]
353
  elif "LRA=" in part: job["lra"] = part.split("=")[1]
354
+ except Exception: pass
 
355
  if "/100" in line:
356
  m = _re.search(r"(\d{2,3}\.?\d*)\s*/\s*100", line)
357
  if m:
358
  try:
359
  s = float(m.group(1))
360
  if 50.0 <= s <= 100.0:
361
+ job["score"] = s; job["progress"] = 95
 
362
  job["label"] = "حساب النتيجة..."
363
+ except Exception: pass
364
+ proc.wait()
365
+ job["engine_log"] = engine_log
 
 
 
 
 
 
 
366
  _out = Path(job["out_path"])
367
+ if _out.exists() and _out.stat().st_size > 0:
368
  success = True
369
  else:
370
  job["engine_rc"] = proc.returncode
 
371
  except Exception as exc:
372
  job["engine_error"] = str(exc)
 
373
  if not success:
374
+ try:
375
+ job["label"] = "معالجة أساسية (fallback)..."
376
+ job["progress"] = 50
377
+ subprocess.run(["ffmpeg", "-y", "-i", job["in_path"],
378
+ "-af", "loudnorm=I=-6:TP=-1:LRA=4",
379
+ "-ar", "48000", "-b:a", "320k", job["out_path"]],
380
+ check=True, capture_output=True, timeout=600)
381
+ success = True; job["score"] = 75
382
+ except Exception as e:
383
+ with JOBS_LOCK:
384
+ job["status"] = "error"; job["error"] = str(e)
385
+ job["label"] = f"فشل: {e}"
386
+ return
387
  with JOBS_LOCK:
388
+ job["status"] = "done"; job["progress"] = 100; job["label"] = "اكتملت ✓"
389
+ if not job.get("score"): job["score"] = 90
 
 
 
 
390
  _add_history(job)
391
+ try: Path(job["in_path"]).unlink(missing_ok=True)
392
+ except Exception: pass
393
+ _prune_jobs()
394
 
 
 
 
 
 
 
 
 
395
  def _add_history(job):
396
+ entry = {k: job[k] for k in
397
+ ["engine","filename","score","lufs","rms","crest","lra","timestamp"]
398
+ if k in job}
 
 
399
  with HISTORY_LOCK:
400
  HISTORY.insert(0, entry)
401
+ if len(HISTORY) > 50: HISTORY.pop()
 
402
 
 
 
403
  @app.route("/status/<job_id>")
404
  def status(job_id):
405
+ with JOBS_LOCK: job = JOBS.get(job_id)
406
+ if not job: return jsonify({"error": "not found"}), 404
407
+ resp = {k: job[k] for k in
408
+ ["status","progress","label","score","lufs","rms","crest","lra","filename"]
409
+ if k in job}
 
 
 
 
 
410
  resp["queue_position"] = _get_queue_position(job_id) if job.get("status") == "queued" else 0
411
+ if "error" in job: resp["error"] = job["error"]
412
+ if job.get("status") == "error":
413
+ resp["engine_log"] = job.get("engine_log", [])
 
 
 
 
414
  resp["engine_rc"] = job.get("engine_rc")
 
415
  return jsonify(resp)
416
 
 
 
417
  @app.route("/download/<job_id>")
418
  def download(job_id):
419
+ with JOBS_LOCK: job = JOBS.get(job_id)
 
420
  if not job or job["status"] != "done":
421
  return jsonify({"error": "not ready"}), 404
422
  path = Path(job["out_path"])
423
  if not path.exists():
424
  return jsonify({"error": "file missing — may have expired"}), 404
 
425
  file_size = path.stat().st_size
 
426
  def generate():
427
  with open(path, "rb") as f:
428
  while True:
429
  chunk = f.read(1024 * 1024)
430
+ if not chunk: break
 
431
  yield chunk
432
+ return Response(stream_with_context(generate()), headers={
433
+ "Content-Disposition": f'attachment; filename="{job["filename"]}"',
434
+ "Content-Type": "audio/mpeg", "Content-Length": str(file_size)})
 
 
 
 
 
 
 
435
 
436
  @app.route("/download_chunk/<job_id>")
437
  def download_chunk(job_id):
438
+ with JOBS_LOCK: job = JOBS.get(job_id)
 
439
  if not job or job["status"] != "done":
440
  return jsonify({"error": "not ready"}), 404
441
  path = Path(job["out_path"])
442
+ if not path.exists(): return jsonify({"error": "file missing"}), 404
 
 
443
  file_size = path.stat().st_size
 
444
  try:
445
  offset = int(request.args.get("offset", 0))
446
  size = int(request.args.get("size", CHUNK_SIZE))
447
  except (ValueError, TypeError):
448
+ return jsonify({"error": "invalid offset or size"}), 400
 
 
449
  offset = max(0, min(offset, file_size))
450
  size = max(0, min(size, MAX_DOWNLOAD_CHUNK, file_size - offset))
 
451
  with open(path, "rb") as f:
452
+ f.seek(offset); data = f.read(size)
453
+ return Response(data, headers={"Content-Type": "audio/mpeg",
454
+ "Content-Length": str(len(data)),
455
+ "X-File-Size": str(file_size),
456
+ "X-Offset": str(offset)})
 
 
 
 
 
 
 
 
457
 
458
  @app.route("/history")
459
  def history():
460
+ with HISTORY_LOCK: snapshot = list(HISTORY)
 
461
  return jsonify({"jobs": snapshot})
462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  if __name__ == "__main__":
464
  port = int(os.environ.get("PORT", 7860))
465
  app.run(host="0.0.0.0", port=port, threaded=True)
466
 
467
+ # S68: auto-download missing engine files from GitHub on startup
468
+ # S146: updated — v21 replaces v12; dead engines v87/v84/v89 removed
469
+ def _bootstrap_engines():
470
+ import urllib.request
471
+ RAW = "https://raw.githubusercontent.com/hammer24678-star/tilawa-enhancer-/master/assets/engines"
472
+ engines = [
473
+ "engine_tajalli_v1.py",
474
+ "true_engine_itiqan_v2_fixed.py",
475
+ "engine_isteidad_v21.py",
476
+ "engine_v90.py",
477
+ "engine_v85.py",
478
+ "engine_v80.py",
479
+ "engine_v70.py",
480
+ ]
481
+ for e in engines:
482
+ p = BASE / e
483
+ if not p.exists() or p.stat().st_size < 1000:
484
+ try:
485
+ urllib.request.urlretrieve(f"{RAW}/{e}", str(p))
486
+ print(f"[bootstrap] downloaded {e}")
487
+ except Exception as ex:
488
+ print(f"[bootstrap] failed {e}: {ex}")
489
 
490
+ _bootstrap_engines()