Spaces:
Running
Running
S146: fix ENGINE_SCRIPTS v11.2→v21, bootstrap, keepalive self-ping
Browse files
app.py
CHANGED
|
@@ -1,86 +1,28 @@
|
|
| 1 |
"""
|
| 2 |
-
tilawa-server app.py —
|
| 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 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
import
|
| 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"] =
|
| 75 |
|
| 76 |
-
# ── Global state ────────────────────────────────────────────────────────────────
|
| 77 |
JOBS = {}
|
| 78 |
HISTORY = []
|
| 79 |
-
JOBS_LOCK = threading.Lock()
|
| 80 |
-
HISTORY_LOCK = threading.Lock()
|
|
|
|
| 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 |
-
|
|
|
|
| 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 / "
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
_REF_CACHE_TS = 0.0 # last refresh epoch
|
| 112 |
_REF_CACHE_LOCK = threading.Lock()
|
| 113 |
-
_REF_CACHE_TTL = 3600
|
| 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)
|
| 125 |
-
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 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 |
-
|
| 147 |
-
if
|
| 148 |
-
return available / (1024 * 1024)
|
| 149 |
-
# Fallback: MemTotal (container may not expose MemAvailable)
|
| 150 |
total = mem.get("MemTotal", 0)
|
| 151 |
-
if total > 0:
|
| 152 |
-
|
| 153 |
-
except Exception:
|
| 154 |
-
pass
|
| 155 |
return 999.0
|
| 156 |
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
-
def
|
| 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 |
-
|
| 172 |
-
except ValueError:
|
| 173 |
-
return 0
|
| 174 |
-
|
| 175 |
|
| 176 |
-
def _count_running()
|
| 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 |
-
|
| 186 |
-
|
| 187 |
-
|
| 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 |
-
|
| 202 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 252 |
while True:
|
| 253 |
-
try:
|
| 254 |
-
|
| 255 |
-
|
| 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 |
-
|
| 269 |
-
|
| 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 |
-
|
| 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",
|
| 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 |
-
|
| 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":
|
| 341 |
-
"fcm_token":
|
| 342 |
-
"
|
| 343 |
-
"
|
| 344 |
-
"
|
| 345 |
-
"
|
| 346 |
-
"
|
| 347 |
-
"
|
| 348 |
-
"
|
| 349 |
-
"
|
| 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 |
-
|
| 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
|
| 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",
|
| 414 |
-
if engine not in ENGINE_SCRIPTS:
|
| 415 |
-
|
| 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 |
-
|
| 429 |
-
|
| 430 |
-
|
| 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] =
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
|
| 458 |
-
# ── Internal: merge chunks then run engine ──────────────────────────────────────
|
| 459 |
def _merge_and_run(job_id):
|
| 460 |
-
with JOBS_LOCK:
|
| 461 |
-
|
| 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 |
-
|
| 471 |
-
|
| 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 |
-
|
| 480 |
-
|
| 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 |
-
|
| 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"] =
|
| 511 |
-
|
| 512 |
SEMAPHORE.acquire()
|
| 513 |
-
try:
|
| 514 |
-
|
| 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 |
-
|
| 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 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 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 |
-
|
| 568 |
for line in proc.stdout:
|
| 569 |
line = line.strip()
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
job["progress"] =
|
| 574 |
-
elif
|
| 575 |
-
job["progress"] =
|
| 576 |
-
elif
|
| 577 |
-
job["progress"] =
|
|
|
|
|
|
|
| 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"]
|
| 594 |
-
job["progress"] = 95
|
| 595 |
job["label"] = "حساب النتيجة..."
|
| 596 |
-
except Exception:
|
| 597 |
-
|
| 598 |
-
|
| 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 >
|
| 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 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
with JOBS_LOCK:
|
| 626 |
-
job["status"]
|
| 627 |
-
job["
|
| 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 |
-
|
| 645 |
-
|
| 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 |
-
|
| 659 |
-
|
| 660 |
-
|
| 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 "
|
| 670 |
-
resp["
|
| 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 |
-
|
| 702 |
-
|
| 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
|
| 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 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 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()
|