""" tilawa-server app.py — v7 (S62 Production) Key changes: - ENGINE_SCRIPTS uses real filenames deployed on HF Space - SEMAPHORE=3 (matches HF free tier 16GB RAM) - Self-ping URL configurable via SPACE_URL env var - All v5 thread-safety, disk/RAM guards, janitor retained """ import gc, os, re as _re, shutil, subprocess, tempfile import threading, time, uuid from pathlib import Path from flask import Flask, Response, jsonify, request, stream_with_context from flask_cors import CORS app = Flask(__name__) CORS(app) app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 # S150: 512 MB JOBS = {} HISTORY = [] JOBS_LOCK = threading.Lock() HISTORY_LOCK = threading.Lock() SEMAPHORE = threading.Semaphore(3) TMP = Path(tempfile.gettempdir()) UPLOAD_DIR = TMP / "tilawa_uploads" CHUNK_DIR = TMP / "tilawa_chunks" OUTPUT_DIR = TMP / "tilawa_outputs" for _d in [UPLOAD_DIR, CHUNK_DIR, OUTPUT_DIR]: _d.mkdir(exist_ok=True) BASE = Path(__file__).parent ENGINE_SCRIPTS = { "v11.0": BASE / "engine_safaa_v3_fixed.py", # S149 "v11.1": BASE / "engine_itiqan_v6_official.py", # S154, "v11.2": BASE / "engine_isteidad_v21.py", # S150 "v10.0": BASE / "engine_v100.py", # S151: Aetherion (matches client) "v9.0": BASE / "engine_v90.py", "v8.9": BASE / "engine_v89.py", "v8.7": BASE / "engine_v87.py", "v8.5": BASE / "engine_v85.py", "v8.4": BASE / "engine_v84.py", "v8.0": BASE / "engine_v80.py", "v7.0": BASE / "engine_v70.py", } DEFAULT_ENGINE = "v11.1" _ENGINE_TIMEOUT = 1500 # S151: 25 min hard limit REF_DIR = BASE / "reference_audio" CHUNK_SIZE = 4 * 1024 * 1024 MAX_DOWNLOAD_CHUNK = 32 * 1024 * 1024 _REF_CACHE = None _REF_CACHE_TS = 0.0 _REF_CACHE_LOCK = threading.Lock() _REF_CACHE_TTL = 3600 def _get_ref_files(): global _REF_CACHE, _REF_CACHE_TS now = time.time() with _REF_CACHE_LOCK: if _REF_CACHE is None or (now - _REF_CACHE_TS) > _REF_CACHE_TTL: _REF_CACHE = list(REF_DIR.glob("*.mp3")) if REF_DIR.exists() else [] _REF_CACHE_TS = now return list(_REF_CACHE) def _sanitize_filename(name): name = Path(name).name name = _re.sub(r"[^\w\-. ]", "_", name) return (name[:200] or "audio") def _available_ram_gb(): try: mem = {} with open("/proc/meminfo") as fh: for line in fh: if line.startswith(("MemAvailable:", "MemTotal:")): k, v = line.split(":", 1) mem[k.strip()] = int(v.split()[0]) avail = mem.get("MemAvailable", 0) if avail > 0: return avail / (1024 * 1024) total = mem.get("MemTotal", 0) if total > 0: return total / (1024 * 1024) except Exception: pass return 999.0 def _check_disk_free(required_bytes): try: return shutil.disk_usage(TMP).free >= required_bytes except Exception: return True def _get_queue_position(job_id): with JOBS_LOCK: queued = [jid for jid, j in JOBS.items() if j.get("status") == "queued"] try: return queued.index(job_id) + 1 except ValueError: return 0 def _count_running(): with JOBS_LOCK: return sum(1 for j in JOBS.values() if j.get("status") == "running") def _prune_jobs(): with JOBS_LOCK: if len(JOBS) <= 200: return removable = [jid for jid, j in list(JOBS.items()) if j.get("status") in ("done", "error")] for jid in removable[:-100]: JOBS.pop(jid, None) def _cleanup_old_outputs(): cutoff = time.time() - 7200 try: for f in OUTPUT_DIR.iterdir(): try: if f.stat().st_mtime < cutoff: f.unlink() except Exception: pass except Exception: pass def _cleanup_stale_chunks(): cutoff = time.time() - 14400 try: for d in CHUNK_DIR.iterdir(): if d.is_dir(): try: if d.stat().st_mtime < cutoff: shutil.rmtree(d, ignore_errors=True) except Exception: pass except Exception: pass def _timeout_stuck_jobs(): # S151 """Reap jobs stuck in running/queued for over 30 minutes.""" cutoff = time.time() - 1800 with JOBS_LOCK: for job in JOBS.values(): if job.get("status") in ("running", "queued"): ts = job.get("_started", 0) if ts and ts < cutoff: job["status"] = "error" job["error"] = "Timed out (watchdog)" job["label"] = "فشل: انتهت المهلة" def _janitor(): while True: time.sleep(1800) for fn in (_cleanup_old_outputs, _prune_jobs, _cleanup_stale_chunks, _timeout_stuck_jobs): # S151 try: fn() except Exception: pass threading.Thread(target=_janitor, daemon=True).start() def _keepalive(): import urllib.request time.sleep(120) url = os.environ.get("SPACE_URL", "https://carm5333-tilawa-server.hf.space/ping") while True: try: urllib.request.urlopen(url, timeout=15) except Exception: pass time.sleep(240) threading.Thread(target=_keepalive, daemon=True).start() @app.route("/") def health(): engines = {k: v.exists() for k, v in ENGINE_SCRIPTS.items()} refs = _get_ref_files() return jsonify({"status": "ok", "engines": engines, "refs": len(refs), "chunk_size": CHUNK_SIZE, "running": _count_running()}) @app.route("/ping") def ping(): return jsonify({"ok": True, "t": time.time()}) @app.route("/queue") def queue_status(): with JOBS_LOCK: running = sum(1 for j in JOBS.values() if j.get("status") == "running") queued = sum(1 for j in JOBS.values() if j.get("status") == "queued") return jsonify({"running": running, "queued": queued, "capacity": 3, "available": max(0, 3 - running)}) @app.route("/ready") def ready(): return jsonify({"ready": True, "engines": {k: v.exists() for k, v in ENGINE_SCRIPTS.items()}}) @app.route("/upload", methods=["POST"]) def upload(): if "file" not in request.files: return jsonify({"error": "no file"}), 400 f = request.files["file"] engine = request.form.get("engine", DEFAULT_ENGINE) if engine not in ENGINE_SCRIPTS: engine = DEFAULT_ENGINE job_id = str(uuid.uuid4())[:16] suffix = Path(_sanitize_filename(f.filename or "audio.mp3")).suffix or ".mp3" in_path = UPLOAD_DIR / f"{job_id}_input{suffix}" f.save(str(in_path)) _init_job(job_id, engine, str(in_path), original_name=f.filename or "audio") threading.Thread(target=_run_engine_queued, args=(job_id,), daemon=True).start() return jsonify({"job_id": job_id}) @app.route("/upload_start", methods=["POST"]) def upload_start(): data = request.get_json(silent=True) or {} filename = _sanitize_filename(data.get("filename", "audio.mp3")) total_size = int(data.get("total_size", 0)) required = max(total_size * 2, 10 * 1024 * 1024) if not _check_disk_free(required): return jsonify({"error": "server disk full — try later"}), 503 total_chunks = max(1, -(-total_size // CHUNK_SIZE)) job_id = str(uuid.uuid4())[:16] suffix = Path(filename).suffix or ".mp3" _out_ext = "wav" if engine in {"v11.0"} else "mp3" # S155: Bug 3 — WAV-aware chunked path job = { "status": "uploading", "progress": 0, "label": "جارٍ الرفع...", "engine": engine, "fcm_token": data.get("fcm_token", ""), "filename": f"enhanced_{job_id}_1425h.{_out_ext}", # S155 "in_path": str(UPLOAD_DIR / f"{job_id}_input{suffix}"), "out_path": str(OUTPUT_DIR / f"enhanced_{job_id}_1425h.{_out_ext}"), "score": None, "lufs": None, "rms": None, "crest": None, "lra": None, "timestamp": time.strftime("%Y-%m-%d %H:%M"), "suffix": suffix, "total_chunks": total_chunks, "received_chunks": 0, "received_set": set(), "total_size": total_size, "_started": time.time(), # S155: watchdog anchor (was missing → false kills) "_lock": threading.Lock(), } with JOBS_LOCK: JOBS[job_id] = job (CHUNK_DIR / job_id).mkdir(exist_ok=True) return jsonify({"job_id": job_id, "chunk_size": CHUNK_SIZE, "total_chunks": total_chunks}) @app.route("/upload_chunk", methods=["POST"]) def upload_chunk(): job_id = request.form.get("job_id") try: index = int(request.form.get("index", 0)) except (ValueError, TypeError): return jsonify({"error": "invalid index"}), 400 with JOBS_LOCK: job = JOBS.get(job_id) if not job_id or job is None: return jsonify({"error": "invalid job_id"}), 400 if "chunk" not in request.files: return jsonify({"error": "no chunk"}), 400 total = job["total_chunks"] if not (0 <= index < total): return jsonify({"error": f"index out of range [0, {total})"}), 400 chunk_path = CHUNK_DIR / job_id / f"chunk_{index:04d}" with job["_lock"]: if index not in job["received_set"]: request.files["chunk"].save(str(chunk_path)) job["received_set"].add(index) job["received_chunks"] = len(job["received_set"]) received = job["received_chunks"] missing = [i for i in range(total) if i not in job["received_set"]] job["progress"] = int((received / total) * 30) job["label"] = f"رفع {received}/{total}..." return jsonify({"received": received, "total": total, "ok": True, "missing": missing}) @app.route("/upload_finalize", methods=["POST"]) def upload_finalize(): data = request.get_json(silent=True) or {} job_id = data.get("job_id") engine = data.get("engine", DEFAULT_ENGINE) if engine not in ENGINE_SCRIPTS: engine = DEFAULT_ENGINE with JOBS_LOCK: job = JOBS.get(job_id) if not job_id or job is None: return jsonify({"error": "invalid job_id"}), 400 with job["_lock"]: total = job["total_chunks"] missing = [i for i in range(total) if i not in job["received_set"]] if missing: return jsonify({"error": f"missing {len(missing)} chunk(s): {missing[:5]}", "missing": missing}), 400 job["engine"] = engine job["status"] = "merging" job["label"] = "دمج الأجزاء..." job["progress"] = 32 threading.Thread(target=_merge_and_run, args=(job_id,), daemon=True).start() return jsonify({"job_id": job_id, "status": "merging"}) def _init_job(job_id, engine, in_path, original_name="audio"): _wav_engines = {"v11.0"} # S151: these engines output WAV _ext = "wav" if engine in _wav_engines else "mp3" out_name = f"enhanced_{job_id}_1425h.{_ext}" with JOBS_LOCK: JOBS[job_id] = { "status": "queued", "progress": 5, "label": "في الطابور...", "engine": engine, "filename": out_name, "in_path": in_path, "out_path": str(OUTPUT_DIR / out_name), "score": None, "lufs": None, "rms": None, "crest": None, "lra": None, "timestamp": time.strftime("%Y-%m-%d %H:%M"), "_started": time.time(), # S151: watchdog anchor "_lock": threading.Lock(), } def _merge_and_run(job_id): with JOBS_LOCK: job = JOBS.get(job_id) if not job: return chunk_dir = CHUNK_DIR / job_id in_path = Path(job["in_path"]) try: chunks = sorted(chunk_dir.glob("chunk_*"), key=lambda p: int(p.stem.split("_")[1])) if not chunks: raise RuntimeError("No chunks found") with open(in_path, "wb") as out_f: for cp in chunks: out_f.write(cp.read_bytes()); cp.unlink() try: chunk_dir.rmdir() except Exception: pass job["progress"] = 35 job["label"] = f"تم الدمج — {in_path.stat().st_size // 1024 // 1024}MB" job["status"] = "queued" _run_engine_queued(job_id) except Exception as e: job["status"] = "error"; job["error"] = str(e) job["label"] = f"خطأ في الدمج: {e}" def _run_engine_queued(job_id): with JOBS_LOCK: job = JOBS.get(job_id) if not job: return job["status"] = "queued" job["label"] = "في الطابور — انتظر قليلاً..." SEMAPHORE.acquire() try: _run_engine(job_id) finally: SEMAPHORE.release(); gc.collect() def _run_engine(job_id): with JOBS_LOCK: job = JOBS.get(job_id) if not job: return ram_gb = _available_ram_gb() if ram_gb < 0.5: with JOBS_LOCK: job["status"] = "error" job["label"] = "خطأ: ذاكرة غير كافية — أعد المحاولة لاحقاً" return script = ENGINE_SCRIPTS.get(job["engine"]) with JOBS_LOCK: job["status"] = "running" job["progress"] = max(job.get("progress", 0), 35) job["label"] = f"تشغيل المحرك {job['engine']}..." success = False if script and script.exists(): try: ref_files = _get_ref_files() cmd = ["python3", str(script), "-i", job["in_path"], "-o", job["out_path"], "--iterations", "3"] for rf in ref_files[:3]: cmd += ["--ref", str(rf)] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) engine_log = [] for line in proc.stdout: line = line.strip() engine_log.append(line) if len(engine_log) > 60: engine_log.pop(0) if "Pass 1" in line or "[٧]" in line or "[١]" in line: job["progress"] = 45; job["label"] = "Pass 1 — تحليل الطيف..." elif "Pass 2" in line or "[٨]" in line or "[٢]" in line: job["progress"] = 60; job["label"] = "Pass 2 — ضبط LUFS..." elif "Pass 3" in line or "[٩]" in line or "[٣]" in line: job["progress"] = 75; job["label"] = "Pass 3 — تصحيح..." elif "Pass 4" in line or "[٤]" in line: job["progress"] = 88; job["label"] = "Pass 4 — تشفير MP3..." elif "LUFS=" in line: for part in line.split(): try: if "LUFS=" in part: job["lufs"] = part.split("=")[1] elif "RMS=" in part: job["rms"] = part.split("=")[1] elif "Crest=" in part: job["crest"] = part.split("=")[1] elif "LRA=" in part: job["lra"] = part.split("=")[1] except Exception: pass if "/100" in line: m = _re.search(r"(\d{2,3}\.?\d*)\s*/\s*100", line) if m: try: s = float(m.group(1)) if 50.0 <= s <= 100.0: job["score"] = s; job["progress"] = 95 job["label"] = "حساب النتيجة..." except Exception: pass try: proc.wait(timeout=_ENGINE_TIMEOUT) # S151 except subprocess.TimeoutExpired: proc.kill(); proc.wait() job["engine_log"] = engine_log job["status"] = "error" job["label"] = "فشل: تجاوز وقت المحرك (25 دقيقة)" job["error"] = "Engine timed out after 25 minutes" return job["engine_log"] = engine_log _out = Path(job["out_path"]) if _out.exists() and _out.stat().st_size > 0: success = True else: job["engine_rc"] = proc.returncode except Exception as exc: job["engine_error"] = str(exc) if not success: try: job["label"] = "معالجة أساسية (fallback)..." job["progress"] = 50 subprocess.run(["ffmpeg", "-y", "-i", job["in_path"], "-af", "loudnorm=I=-6:TP=-1:LRA=4", "-ar", "48000", "-b:a", "320k", job["out_path"]], check=True, capture_output=True, timeout=600) success = True; job["score"] = 75 except Exception as e: with JOBS_LOCK: job["status"] = "error"; job["error"] = str(e) job["label"] = f"فشل: {e}" return with JOBS_LOCK: job["status"] = "done"; job["progress"] = 100; job["label"] = "اكتملت ✓" if not job.get("score"): job["score"] = 90 _add_history(job) try: Path(job["in_path"]).unlink(missing_ok=True) except Exception: pass _prune_jobs() def _add_history(job): entry = {k: job[k] for k in ["engine","filename","score","lufs","rms","crest","lra","timestamp"] if k in job} with HISTORY_LOCK: HISTORY.insert(0, entry) if len(HISTORY) > 50: HISTORY.pop() @app.route("/status/") def status(job_id): with JOBS_LOCK: job = JOBS.get(job_id) if not job: return jsonify({"error": "not found"}), 404 resp = {k: job[k] for k in ["status","progress","label","score","lufs","rms","crest","lra","filename"] if k in job} resp["queue_position"] = _get_queue_position(job_id) if job.get("status") == "queued" else 0 if "error" in job: resp["error"] = job["error"] if job.get("status") == "error": log = job.get("engine_log", []) resp["engine_log"] = log[-5:] if log else [] # S151: last 5 only resp["engine_rc"] = job.get("engine_rc") return jsonify(resp) @app.route("/download/") def download(job_id): with JOBS_LOCK: job = JOBS.get(job_id) if not job or job["status"] != "done": return jsonify({"error": "not ready"}), 404 path = Path(job["out_path"]) if not path.exists(): return jsonify({"error": "file missing — may have expired"}), 404 file_size = path.stat().st_size def generate(): with open(path, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break yield chunk return Response(stream_with_context(generate()), headers={ "Content-Disposition": f'attachment; filename="{job["filename"]}"', "Content-Type": "audio/wav" if str(path).endswith(".wav") else "audio/mpeg", "Content-Length": str(file_size)}) # S151 @app.route("/download_chunk/") def download_chunk(job_id): with JOBS_LOCK: job = JOBS.get(job_id) if not job or job["status"] != "done": return jsonify({"error": "not ready"}), 404 path = Path(job["out_path"]) if not path.exists(): return jsonify({"error": "file missing"}), 404 file_size = path.stat().st_size try: offset = int(request.args.get("offset", 0)) size = int(request.args.get("size", CHUNK_SIZE)) except (ValueError, TypeError): return jsonify({"error": "invalid offset or size"}), 400 offset = max(0, min(offset, file_size)) size = max(0, min(size, MAX_DOWNLOAD_CHUNK, file_size - offset)) with open(path, "rb") as f: f.seek(offset); data = f.read(size) return Response(data, headers={"Content-Type": "audio/mpeg", "Content-Length": str(len(data)), "X-File-Size": str(file_size), "X-Offset": str(offset)}) @app.route("/history") def history(): with HISTORY_LOCK: snapshot = list(HISTORY) return jsonify({"jobs": snapshot}) # S150: auto-download missing engine files from GitHub on startup def _bootstrap_engines(): import urllib.request RAW = "https://raw.githubusercontent.com/hammer24678-star/tilawa-enhancer-/master/assets/engines" engines = [ "engine_safaa_v3_fixed.py", "engine_itiqan_v6_official.py", "engine_isteidad_v21.py", "engine_v100.py", # S151 ] for e in engines: p = BASE / e if not p.exists() or p.stat().st_size < 1000: try: urllib.request.urlretrieve(f"{RAW}/{e}", str(p)) print(f"[bootstrap] downloaded {e}") except Exception as ex: print(f"[bootstrap] failed {e}: {ex}") threading.Thread(target=_bootstrap_engines, daemon=True).start() # S151 if __name__ == "__main__": port = int(os.environ.get("PORT", 7860)) app.run(host="0.0.0.0", port=port, threaded=True)