Spaces:
Paused
Paused
| """ | |
| 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 # S146: 512MB — supports up to 200MB files | |
| BASE = Path(__file__).parent # S149: moved before JOBS_FILE — was NameError | |
| 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) | |
| JOBS = {} | |
| HISTORY = [] | |
| JOBS_LOCK = threading.Lock() | |
| HISTORY_LOCK = threading.Lock() | |
| SEMAPHORE = threading.Semaphore(3) | |
| # ── Imp-1: Job persistence — survives space restart/sleep ───────────────────── | |
| JOBS_FILE = BASE / "tilawa_jobs.json" | |
| def _save_jobs(): | |
| """Persist active jobs to disk (excludes locks and file handles).""" | |
| import json | |
| _SKIP = {"_lock"} | |
| try: | |
| with JOBS_LOCK: | |
| snapshot = {} | |
| for jid, j in JOBS.items(): | |
| if j.get("status") in ("done", "error"): | |
| continue # completed jobs don't need persistence | |
| row = {} | |
| for k, v in j.items(): | |
| if k in _SKIP: | |
| continue | |
| if isinstance(v, set): | |
| row[k] = list(v) | |
| else: | |
| row[k] = v | |
| snapshot[jid] = row | |
| with open(JOBS_FILE, "w") as f: | |
| json.dump(snapshot, f) | |
| except Exception as e: | |
| print(f"[persist] save failed: {e}") | |
| def _load_jobs(): | |
| """Reload jobs from disk on startup. Mark running/queued as error.""" | |
| import json | |
| if not JOBS_FILE.exists(): | |
| return | |
| try: | |
| with open(JOBS_FILE) as f: | |
| snapshot = json.load(f) | |
| loaded = 0 | |
| with JOBS_LOCK: | |
| for jid, j in snapshot.items(): | |
| # recreate threading.Lock | |
| j["_lock"] = threading.Lock() | |
| # restore set | |
| if "received_set" in j: | |
| j["received_set"] = set(j["received_set"]) | |
| # jobs that were mid-run can't resume — mark as error | |
| if j.get("status") in ("running", "queued", "merging", "uploading"): | |
| j["status"] = "error" | |
| j["label"] = "فشل: أُعيد تشغيل الخادم — أرسل الملف مجدداً" | |
| j["error"] = "Server restarted — please resubmit" | |
| JOBS[jid] = j | |
| loaded += 1 | |
| print(f"[persist] loaded {loaded} job(s) from disk") | |
| except Exception as e: | |
| print(f"[persist] load failed: {e}") | |
| _load_jobs() | |
| ENGINE_SCRIPTS = { | |
| "v11.0": BASE / "engine_tajalli_v1.py", | |
| "v11.1": BASE / "true_engine_itiqan_v2_fixed.py", | |
| "v11.2": BASE / "engine_isteidad_v21.py", | |
| "v10.0": BASE / "engine_tajalli_v1.py", | |
| "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.0" | |
| 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(): | |
| """Mark jobs stuck in running/queued/merging > 30min as error.""" | |
| cutoff = time.time() - 1800 | |
| with JOBS_LOCK: | |
| for job in JOBS.values(): | |
| if job.get("status") in ("running", "queued", "merging"): | |
| ts = job.get("_started_at", time.time()) | |
| if ts < cutoff: | |
| job["status"] = "error" | |
| job["error"] = "Job timed out after 30 minutes" | |
| job["label"] = "فشل: انتهت مهلة المعالجة (30 دقيقة)" | |
| def _janitor(): | |
| while True: | |
| time.sleep(1800) | |
| for fn in (_cleanup_old_outputs, _prune_jobs, | |
| _cleanup_stale_chunks, _timeout_stuck_jobs): # Imp-A | |
| try: fn() | |
| except Exception: pass | |
| threading.Thread(target=_janitor, daemon=True).start() | |
| def _keepalive(): | |
| import urllib.request | |
| time.sleep(120) | |
| # S146: use SPACE_HOST so every space pings itself, not just tilawa-server | |
| host = os.environ.get("SPACE_HOST", "carm5333-tilawa-server.hf.space") | |
| url = os.environ.get("SPACE_URL", f"https://{host}/ping") | |
| while True: | |
| try: urllib.request.urlopen(url, timeout=15) | |
| except Exception: pass | |
| time.sleep(240) | |
| threading.Thread(target=_keepalive, daemon=True).start() | |
| def health(): | |
| engines = {k: v.exists() for k, v in ENGINE_SCRIPTS.items()} | |
| refs = _get_ref_files() | |
| ram_gb = _available_ram_gb() | |
| try: | |
| disk_gb = shutil.disk_usage(TMP).free / (1024**3) | |
| except Exception: | |
| disk_gb = -1 | |
| with JOBS_LOCK: | |
| queued = sum(1 for j in JOBS.values() if j.get("status") == "queued") | |
| return jsonify({"status": "ok", "engines": engines, | |
| "refs": len(refs), "chunk_size": CHUNK_SIZE, | |
| "running": _count_running(), "queued": queued, | |
| "ram_gb": round(ram_gb, 2), | |
| "disk_free_gb": round(disk_gb, 2)}) # Imp-C | |
| def ping(): | |
| return jsonify({"ok": True, "t": time.time()}) | |
| # Imp-3: Flutter pre-warm endpoint | |
| def wake(): | |
| engines = {k: v.exists() for k, v in ENGINE_SCRIPTS.items()} | |
| return jsonify({"ok": True, "t": time.time(), | |
| "engines": engines, "running": _count_running()}) | |
| 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)}) | |
| def ready(): | |
| return jsonify({"ready": True, | |
| "engines": {k: v.exists() for k, v in ENGINE_SCRIPTS.items()}}) | |
| def upload(): | |
| if "file" not in request.files: | |
| return jsonify({"error": "no file"}), 400 | |
| content_length = request.content_length or 0 | |
| if content_length > 0 and not _check_disk_free(content_length * 2): | |
| return jsonify({"error": "server disk full — try later"}), 503 # Imp-D | |
| 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}) | |
| 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" | |
| job = { | |
| "status": "uploading", "progress": 0, "label": "جارٍ الرفع...", | |
| "engine": DEFAULT_ENGINE, "fcm_token": data.get("fcm_token", ""), | |
| "filename": f"enhanced_{job_id}_1425h.mp3", | |
| "in_path": str(UPLOAD_DIR / f"{job_id}_input{suffix}"), | |
| "out_path": str(OUTPUT_DIR / f"enhanced_{job_id}_1425h.mp3"), | |
| "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, | |
| "_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}) | |
| 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}) | |
| 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"): | |
| out_name = f"enhanced_{job_id}_1425h.mp3" | |
| 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"), | |
| "_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["_started_at"] = time.time() # Imp-A | |
| 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)] | |
| _ENGINE_TIMEOUT = 25 * 60 # Imp-B: 25min hard limit | |
| 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) # Imp-B | |
| 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" | |
| _save_jobs() | |
| 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}" | |
| _save_jobs() # Imp-1: persist error state | |
| return | |
| with JOBS_LOCK: | |
| job["status"] = "done"; job["progress"] = 100; job["label"] = "اكتملت ✓" | |
| if not job.get("score"): job["score"] = 90 | |
| job.pop("engine_log", None) # Imp-4: trim log after done — save RAM | |
| _add_history(job) | |
| _save_jobs() # Imp-1: persist | |
| 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() | |
| 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 [] # Imp-E: last 5 lines | |
| resp["engine_rc"] = job.get("engine_rc") | |
| return jsonify(resp) | |
| 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/mpeg", "Content-Length": str(file_size)}) | |
| 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)}) | |
| def history(): | |
| with HISTORY_LOCK: snapshot = list(HISTORY) | |
| return jsonify({"jobs": snapshot}) | |
| # S68: 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_tajalli_v1.py", | |
| "true_engine_itiqan_v2_fixed.py", | |
| "engine_isteidad_v21.py", | |
| "engine_v90.py", | |
| "engine_v85.py", | |
| "engine_v80.py", | |
| "engine_v70.py", | |
| ] | |
| 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() | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| app.run(host="0.0.0.0", port=port, threaded=True) | |