Update App.py
Browse files
App.py
CHANGED
|
@@ -1,506 +1,158 @@
|
|
| 1 |
-
import os, re,
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
out = subprocess.run(["keytool", "-list", "-v", "-keystore", str(path), "-alias", alias,
|
| 30 |
-
"-storepass", password], capture_output=True, text=True, timeout=30)
|
| 31 |
-
sha1 = re.search(r"SHA1:\s*([0-9A-F:]+)", out.stdout)
|
| 32 |
-
sha256 = re.search(r"SHA256:\s*([0-9A-F:]+)", out.stdout)
|
| 33 |
-
if not sha1:
|
| 34 |
-
return None
|
| 35 |
-
return {"sha1": sha1.group(1), "sha256": sha256.group(1) if sha256 else ""}
|
| 36 |
-
except Exception:
|
| 37 |
-
return None
|
| 38 |
-
|
| 39 |
-
build_lock = threading.Lock() # one build at a time (RAM)
|
| 40 |
-
JOB, PROCS = {}, {}
|
| 41 |
-
ID_RE = re.compile(r"^[0-9a-f]{10}$")
|
| 42 |
-
ACTIVE = ("extracting", "queued", "preparing", "building")
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
# ---------------------------------------------------------------- state
|
| 46 |
-
def save(j):
|
| 47 |
try:
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
if not d.is_dir(): continue
|
| 55 |
-
f = d / "state.json"
|
| 56 |
-
if f.exists():
|
| 57 |
try:
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
return out
|
| 91 |
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
return None
|
| 100 |
-
|
| 101 |
-
def summarize_error(text):
|
| 102 |
-
lines = text.splitlines()
|
| 103 |
-
out = [l for l in lines if l.startswith("e: ")][:15]
|
| 104 |
-
for i, l in enumerate(lines):
|
| 105 |
-
if l.startswith("* What went wrong:"):
|
| 106 |
-
out += lines[i:i + 6]
|
| 107 |
-
break
|
| 108 |
-
if not out:
|
| 109 |
-
out = lines[-25:]
|
| 110 |
-
return "\n".join(out)
|
| 111 |
-
|
| 112 |
-
def run_build(jid):
|
| 113 |
-
j = JOB[jid]; d = JOBS / jid; logp = d / "build.log"
|
| 114 |
-
lf = open(logp, "ab", buffering=0)
|
| 115 |
-
def w(s): lf.write(s.encode("utf-8", "replace"))
|
| 116 |
-
def setj(**kw): j.update(kw); save(j)
|
| 117 |
-
t0 = time.time()
|
| 118 |
-
def el():
|
| 119 |
-
n = int(time.time() - t0); return f"+{n // 60}:{n % 60:02d}"
|
| 120 |
-
def finish(status, error=""):
|
| 121 |
-
KEYS.pop(jid, None)
|
| 122 |
-
try: (d / "upload.jks").unlink(missing_ok=True)
|
| 123 |
-
except Exception: pass
|
| 124 |
-
w(f"\n[{el()}] {status.upper()}\n")
|
| 125 |
-
setj(status=status, error=error, finished=time.time(), current="")
|
| 126 |
-
lf.close()
|
| 127 |
|
| 128 |
-
def gradle(args, on_line):
|
| 129 |
-
p = subprocess.Popen([GRADLE, *args], cwd=j["root"], stdout=subprocess.PIPE,
|
| 130 |
-
stderr=subprocess.STDOUT, text=True, bufsize=1, errors="replace",
|
| 131 |
-
start_new_session=True, env=genv)
|
| 132 |
-
PROCS[jid] = p
|
| 133 |
-
for line in p.stdout:
|
| 134 |
-
w(line); on_line(line.rstrip("\n"))
|
| 135 |
-
rc = p.wait(); PROCS.pop(jid, None)
|
| 136 |
-
return rc
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
keyfile = key["path"] if key else (d / "upload.jks")
|
| 143 |
|
| 144 |
-
def drop_key():
|
| 145 |
-
KEYS.pop(jid, None)
|
| 146 |
-
try: keyfile.unlink(missing_ok=True)
|
| 147 |
-
except Exception: pass
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
# 1. extract
|
| 161 |
-
setj(status="extracting", current="Unzipping project…")
|
| 162 |
-
w("== Extracting zip ==\n")
|
| 163 |
-
with zipfile.ZipFile(d / "in.zip") as z:
|
| 164 |
-
z.extractall(d / "src")
|
| 165 |
-
root = find_root(d / "src")
|
| 166 |
-
if not root:
|
| 167 |
-
return finish("failed", "settings.gradle(.kts) not found in the zip.")
|
| 168 |
-
j["root"] = str(root)
|
| 169 |
-
(root / "local.properties").write_text(f"sdk.dir={SDK_DIR}\n")
|
| 170 |
-
w(f"Project root: {root.relative_to(d / 'src')}\n")
|
| 171 |
-
|
| 172 |
-
# 2. wait for slot
|
| 173 |
-
if not build_lock.acquire(blocking=False):
|
| 174 |
-
setj(status="queued", current="Waiting for another build to finish…")
|
| 175 |
-
w("== Queued (another build is running) ==\n")
|
| 176 |
-
while not build_lock.acquire(timeout=1):
|
| 177 |
-
if j.get("cancel"):
|
| 178 |
-
return finish("cancelled")
|
| 179 |
-
try:
|
| 180 |
-
if j.get("cancel"):
|
| 181 |
-
return finish("cancelled")
|
| 182 |
-
setj(started=time.time())
|
| 183 |
-
|
| 184 |
-
# 3a. count the REAL number of tasks with a dry-run (no tasks are executed),
|
| 185 |
-
# so the progress bar is accurate from the very first build.
|
| 186 |
-
setj(status="preparing", current="Counting build tasks…", total=0, done=0)
|
| 187 |
-
w(f"\n[{el()}] == Counting tasks (dry-run) ==\n")
|
| 188 |
-
counted = [0]
|
| 189 |
-
def on_dry(line):
|
| 190 |
-
if re.match(r"^:\S+ SKIPPED", line):
|
| 191 |
-
counted[0] += 1
|
| 192 |
-
rc0 = gradle([task, "--dry-run", "--console=plain",
|
| 193 |
-
"--no-configuration-cache", "-Dorg.gradle.jvmargs=-Xmx4g"], on_dry)
|
| 194 |
-
if j.get("cancel"):
|
| 195 |
-
return finish("cancelled")
|
| 196 |
-
if rc0 == 0 and counted[0] > 0:
|
| 197 |
-
total, exact = counted[0], True
|
| 198 |
-
w(f"[{el()}] Tasks in graph: {total}\n")
|
| 199 |
-
else:
|
| 200 |
-
total, exact = load_totals().get(j["name"], DEFAULT_TASKS), False
|
| 201 |
-
w(f"[{el()}] Dry-run failed, estimating {total} tasks\n")
|
| 202 |
-
|
| 203 |
-
# 3b. build. Progress = tasks done / tasks counted above
|
| 204 |
-
setj(total=total, done=0, status="building", current="Starting Gradle…")
|
| 205 |
-
w(f"\n[{el()}] == Building ({task}) ==\n")
|
| 206 |
-
def on_line(line):
|
| 207 |
-
m = re.match(r"^> Task (\S+)", line)
|
| 208 |
-
if m:
|
| 209 |
-
j["done"] += 1; j["current"] = m.group(1)
|
| 210 |
-
if exact:
|
| 211 |
-
if j["done"] >= j["total"]: # more tasks than counted: never exceed 100%
|
| 212 |
-
j["total"] = j["done"] + 1
|
| 213 |
-
elif j["done"] >= j["total"] - 3: # guessed total was too small: grow it
|
| 214 |
-
j["total"] = j["done"] + 10
|
| 215 |
-
if j["done"] % 10 == 0: save(j)
|
| 216 |
-
elif line.startswith("Download "):
|
| 217 |
-
j["current"] = line[:90]
|
| 218 |
-
rc = gradle([task, "--console=plain", "--warning-mode=summary",
|
| 219 |
-
"--build-cache", "--no-configuration-cache",
|
| 220 |
-
"-Dorg.gradle.jvmargs=-Xmx4g"], on_line)
|
| 221 |
-
if j.get("cancel"):
|
| 222 |
-
return finish("cancelled")
|
| 223 |
-
apks = list(Path(j["root"]).rglob(out_glob))
|
| 224 |
-
if rc == 0 and apks:
|
| 225 |
-
shutil.copy(apks[0], d / out_name)
|
| 226 |
-
j["apk_mb"] = round((d / out_name).stat().st_size / 1048576, 1)
|
| 227 |
-
j["total"] = j["done"]
|
| 228 |
-
t = load_totals(); t[j["name"]] = j["done"]; save_totals(t)
|
| 229 |
-
w(f"\n[{el()}] BUILD OK\n")
|
| 230 |
-
return finish("done")
|
| 231 |
-
return finish("failed", summarize_error(logp.read_text(errors="replace")))
|
| 232 |
-
finally:
|
| 233 |
-
build_lock.release()
|
| 234 |
-
shutil.rmtree(d / "src", ignore_errors=True) # save disk
|
| 235 |
-
drop_key() # key never stays on disk
|
| 236 |
-
except Exception as e:
|
| 237 |
-
drop_key()
|
| 238 |
-
try: finish("failed", f"Internal error: {e}")
|
| 239 |
-
except Exception: pass
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
# ---------------------------------------------------------------- pages
|
| 243 |
-
CSS = r"""
|
| 244 |
-
:root{--bg:#0b1020;--card:#151b2e;--bd:#262e48;--tx:#e8ecf8;--mu:#8b95b5;--ac:#5b8cff;--ok:#22c55e;--er:#ef4444;--wa:#f59e0b}
|
| 245 |
-
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font:15px/1.45 system-ui,sans-serif}
|
| 246 |
-
.w{max-width:760px;margin:0 auto;padding:14px}
|
| 247 |
-
h1{font-size:20px;margin:6px 0 14px}.card{background:var(--card);border:1px solid var(--bd);border-radius:14px;padding:14px;margin-bottom:12px}
|
| 248 |
-
button,.btn{font:inherit;border:0;border-radius:10px;padding:11px 14px;background:#27304d;color:var(--tx);cursor:pointer;text-decoration:none;display:inline-block;text-align:center}
|
| 249 |
-
button.p,.btn.p{background:var(--ac);color:#fff}.btn.g,button.g{background:var(--ok);color:#04210f;font-weight:600}
|
| 250 |
-
button.r{background:#4a1d24;color:#ffb4b4}button:disabled{opacity:.5}
|
| 251 |
-
.row{display:flex;gap:8px;flex-wrap:wrap}.row>*{flex:1 1 auto}
|
| 252 |
-
input[type=file],input[type=password]{width:100%;padding:12px;border-radius:10px;border:1px dashed var(--bd);background:#0f1426;color:var(--tx);margin-bottom:10px}
|
| 253 |
-
.bar{height:16px;background:#0f1426;border-radius:99px;overflow:hidden;border:1px solid var(--bd)}
|
| 254 |
-
.bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,#5b8cff,#7c5bff);transition:width .4s}
|
| 255 |
-
.bar.ind>i{width:35%;animation:ind 1.2s infinite ease-in-out}.bar.ok>i{background:var(--ok)}.bar.er>i{background:var(--er)}
|
| 256 |
-
@keyframes ind{0%{margin-left:-35%}100%{margin-left:100%}}
|
| 257 |
-
.mu{color:var(--mu);font-size:13px}.big{font-size:28px;font-weight:700}
|
| 258 |
-
.steps{display:flex;gap:6px;margin:12px 0}.steps span{flex:1;text-align:center;font-size:12px;padding:6px 2px;border-radius:8px;background:#0f1426;color:var(--mu);border:1px solid var(--bd)}
|
| 259 |
-
.steps span.on{color:#fff;border-color:var(--ac);background:#1b2750}.steps span.dn{color:var(--ok);border-color:#1d5b36}
|
| 260 |
-
pre{margin:0;background:#050810;color:#b6f5c4;border:1px solid var(--bd);border-radius:10px;padding:10px;font:12px/1.4 ui-monospace,monospace;max-height:50vh;overflow:auto;white-space:pre}
|
| 261 |
-
pre.wrap{white-space:pre-wrap;word-break:break-all}
|
| 262 |
-
.err{border-color:#5b2430;background:#1d0f14}.err pre{color:#ffb4b4;max-height:30vh}
|
| 263 |
-
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);background:#fff;color:#000;padding:9px 16px;border-radius:99px;opacity:0;transition:.25s;pointer-events:none}
|
| 264 |
-
#toast.s{opacity:1}a{color:var(--ac)}.li{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-top:1px solid var(--bd)}
|
| 265 |
-
.li:first-child{border-top:0}label.c{display:inline-flex;gap:6px;align-items:center;font-size:13px;color:var(--mu)}
|
| 266 |
-
"""
|
| 267 |
-
TOAST_JS = r"""
|
| 268 |
-
function toast(m){const t=document.getElementById('toast');t.textContent=m;t.classList.add('s');setTimeout(()=>t.classList.remove('s'),1500)}
|
| 269 |
-
async function copyText(t){try{await navigator.clipboard.writeText(t)}catch(e){const a=document.createElement('textarea');a.value=t;a.style.cssText='position:fixed;opacity:0';document.body.appendChild(a);a.select();document.execCommand('copy');a.remove()}toast('Copied ✓')}
|
| 270 |
-
"""
|
| 271 |
|
| 272 |
-
def shell(title, body, js=""):
|
| 273 |
-
return ("<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
|
| 274 |
-
f"<title>{title}</title><style>{CSS}</style></head><body><div class='w'>{body}</div><div id='toast'></div>"
|
| 275 |
-
f"<script>{TOAST_JS}{js}</script></body></html>")
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
|
| 280 |
-
HOME_JS = r"""
|
| 281 |
-
const f=document.getElementById('f'),bar=document.getElementById('ub'),st=document.getElementById('us'),btn=document.getElementById('go');
|
| 282 |
-
f.addEventListener('submit',e=>{e.preventDefault();
|
| 283 |
-
const fd=new FormData(f);btn.disabled=true;bar.parentElement.style.display='block';
|
| 284 |
-
const x=new XMLHttpRequest();x.open('POST','/build');
|
| 285 |
-
x.upload.onprogress=ev=>{if(ev.lengthComputable){const p=Math.round(ev.loaded/ev.total*100);bar.style.width=p+'%';st.textContent='Uploading '+p+'% ('+(ev.loaded/1048576).toFixed(1)+' / '+(ev.total/1048576).toFixed(1)+' MB)'}};
|
| 286 |
-
x.onload=()=>{if(x.status==200){location.href='/job/'+JSON.parse(x.responseText).id}else{let m='';try{m=JSON.parse(x.responseText).error||''}catch(e){}st.textContent='Error '+x.status+(x.status==403?' – wrong access key':'')+(m?' – '+m:'');btn.disabled=false}};
|
| 287 |
-
x.onerror=()=>{st.textContent='Upload failed';btn.disabled=false};x.send(fd)});
|
| 288 |
-
"""
|
| 289 |
|
| 290 |
@app.get("/")
|
| 291 |
-
def
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
abort(400)
|
| 319 |
-
jid = uuid.uuid4().hex[:10]
|
| 320 |
-
d = JOBS / jid; d.mkdir(parents=True)
|
| 321 |
-
f.save(d / "in.zip")
|
| 322 |
-
if not zipfile.is_zipfile(d / "in.zip"):
|
| 323 |
-
shutil.rmtree(d); abort(400)
|
| 324 |
-
mode = request.form.get("mode", "debug")
|
| 325 |
-
if mode not in MODES:
|
| 326 |
-
mode = "debug"
|
| 327 |
-
kf = request.files.get("keystore")
|
| 328 |
-
password = request.form.get("storepass", "")
|
| 329 |
-
alias = (request.form.get("alias") or "upload").strip() or "upload"
|
| 330 |
-
fp = None
|
| 331 |
-
if kf and kf.filename:
|
| 332 |
-
kpath = d / "upload.jks"
|
| 333 |
-
kf.save(kpath)
|
| 334 |
-
os.chmod(kpath, 0o600)
|
| 335 |
-
fp = key_fingerprints(kpath, alias, password)
|
| 336 |
-
if not fp:
|
| 337 |
-
shutil.rmtree(d, ignore_errors=True)
|
| 338 |
-
return jsonify(error="Wrong keystore password or alias."), 400
|
| 339 |
-
KEYS[jid] = {"path": kpath, "password": password, "alias": alias}
|
| 340 |
-
elif mode != "debug":
|
| 341 |
-
shutil.rmtree(d, ignore_errors=True)
|
| 342 |
-
return jsonify(error="Release builds need your .jks key file and password."), 400
|
| 343 |
-
JOB[jid] = {"id": jid, "name": (f.filename or "project.zip")[:60], "status": "extracting",
|
| 344 |
-
"mode": mode, "out": MODES[mode][2], "fp": fp,
|
| 345 |
-
"created": time.time(), "started": None, "finished": None,
|
| 346 |
-
"total": 0, "done": 0, "current": "", "error": "", "apk_mb": None}
|
| 347 |
-
save(JOB[jid])
|
| 348 |
-
threading.Thread(target=run_build, args=(jid,), daemon=True).start()
|
| 349 |
-
return jsonify(id=jid)
|
| 350 |
-
|
| 351 |
-
JOB_HTML = r"""
|
| 352 |
-
<a href='/' class='mu'>← New build</a>
|
| 353 |
-
<h1 id='title'>__NAME__</h1>
|
| 354 |
-
<div class='card'>
|
| 355 |
-
<div class='row' style='align-items:baseline'><div class='big' id='pct'>0%</div><div class='mu' id='meta' style='text-align:right'></div></div>
|
| 356 |
-
<div class='bar ind' id='bar' style='margin-top:6px'><i></i></div>
|
| 357 |
-
<div class='steps' id='steps'><span>Unzip</span><span>Queue</span><span>Build</span><span>APK</span></div>
|
| 358 |
-
<div class='mu' id='cur' style='word-break:break-all'></div>
|
| 359 |
-
<div class='row' style='margin-top:12px' id='acts'></div>
|
| 360 |
-
</div>
|
| 361 |
-
<div class='card' id='fpbox' style='display:none'>
|
| 362 |
-
<b>🔑 Signed with</b> <span class='mu'>(add these in Firebase → Android app → Add fingerprint)</span>
|
| 363 |
-
<div class='li'><span>SHA-1</span><button id='cpsha1'>Copy</button></div><pre class='wrap' id='sha1'></pre>
|
| 364 |
-
<div class='li'><span>SHA-256</span><button id='cpsha256'>Copy</button></div><pre class='wrap' id='sha256'></pre>
|
| 365 |
-
</div>
|
| 366 |
-
<div class='card err' id='errbox' style='display:none'>
|
| 367 |
-
<div class='row' style='align-items:center'><b>❌ What went wrong</b><button id='cperr' style='flex:0 0 auto'>Copy error</button></div>
|
| 368 |
-
<pre class='wrap' id='errtxt' style='margin-top:8px'></pre>
|
| 369 |
-
</div>
|
| 370 |
-
<div class='card'>
|
| 371 |
-
<div class='row' style='align-items:center;margin-bottom:8px'>
|
| 372 |
-
<b style='flex:0 0 auto'>Log</b>
|
| 373 |
-
<label class='c'><input type='checkbox' id='auto' checked> auto-scroll</label>
|
| 374 |
-
<label class='c'><input type='checkbox' id='wrap'> wrap</label>
|
| 375 |
-
</div>
|
| 376 |
-
<pre id='log'>waiting…</pre>
|
| 377 |
-
<div class='row' style='margin-top:10px'>
|
| 378 |
-
<button id='cplog'>📋 Copy full log</button>
|
| 379 |
-
<a class='btn' href='/job/__ID__/log.txt' download='build-__ID__.log'>⬇ Download log</a>
|
| 380 |
-
</div>
|
| 381 |
-
</div>
|
| 382 |
-
"""
|
| 383 |
-
|
| 384 |
-
JOB_JS = r"""
|
| 385 |
-
const ID='__ID__', END=['done','failed','cancelled'];
|
| 386 |
-
const $=id=>document.getElementById(id);
|
| 387 |
-
let offset=0, full='', S=null, fetchedAt=0, timer=null, lastEmitted='';
|
| 388 |
-
const fmt=s=>{s=Math.floor(s);const m=Math.floor(s/60);return m+'m '+String(s%60).padStart(2,'0')+'s'};
|
| 389 |
-
function stepIdx(s){return {extracting:0,queued:1,preparing:2,building:2,done:4,failed:-1,cancelled:-1}[s]}
|
| 390 |
-
function render(){
|
| 391 |
-
if(!S)return;
|
| 392 |
-
const st=S.status, active=!END.includes(st);
|
| 393 |
-
const total=S.total||0, done=S.done||0;
|
| 394 |
-
let pct=null;
|
| 395 |
-
if(st==='done')pct=100; else if(st==='building'&&total>0)pct=Math.min(99,Math.floor(done/total*100));
|
| 396 |
-
else if(st==='failed'||st==='cancelled')pct=total>0?Math.min(99,Math.floor(done/total*100)):0;
|
| 397 |
-
const bar=$('bar'); bar.className='bar'+(pct===null?' ind':'')+(st==='done'?' ok':'')+(st==='failed'?' er':'');
|
| 398 |
-
if(pct!==null)bar.firstElementChild.style.width=pct+'%';
|
| 399 |
-
$('pct').textContent=pct===null?(st==='queued'?'Queued':'…'):pct+'%';
|
| 400 |
-
const el=S.elapsed+(active?(Date.now()-fetchedAt)/1000:0);
|
| 401 |
-
let meta=fmt(el);
|
| 402 |
-
if(st==='building'&&total>0){meta=done+'/'+total+' tasks · '+meta; if(pct>=8&&pct<100)meta+=' · ~'+fmt(el*(100-pct)/pct)+' left'}
|
| 403 |
-
if(st==='done'&&S.apk_mb)meta+=' · APK '+S.apk_mb+' MB';
|
| 404 |
-
$('meta').textContent=meta;
|
| 405 |
-
$('cur').textContent=active?(S.current||''):({done:'Build finished 🎉',failed:'Build failed',cancelled:'Cancelled'}[st]);
|
| 406 |
-
const idx=stepIdx(st);
|
| 407 |
-
[...$('steps').children].forEach((e,i)=>e.className=(idx===4||i<idx)?'dn':(i===idx?'on':''));
|
| 408 |
-
// actions
|
| 409 |
-
const a=$('acts'), key=st+(S.has_apk?'1':'0'); if(key!==lastEmitted){lastEmitted=key; a.innerHTML='';
|
| 410 |
-
if(st==='done'&&S.has_apk){
|
| 411 |
-
a.insertAdjacentHTML('beforeend',"<a class='btn g' style='flex:2 1 100%;font-size:17px' href='/job/"+ID+"/apk' download>⬇ Download "+(S.out.endsWith('.aab')?'AAB':'APK')+"</a>");
|
| 412 |
-
a.insertAdjacentHTML('beforeend',"<button id='cplink'>🔗 Copy APK link</button>");
|
| 413 |
-
$('cplink').onclick=()=>copyText(location.origin+'/job/'+ID+'/apk');
|
| 414 |
-
}
|
| 415 |
-
if(active){a.insertAdjacentHTML('beforeend',"<button class='r' id='cancel'>✖ Cancel build</button>");
|
| 416 |
-
$('cancel').onclick=async()=>{if(confirm('Cancel this build?')){await fetch('/api/job/'+ID+'/cancel',{method:'POST'});toast('Cancelling…')}}}
|
| 417 |
-
if(!active){a.insertAdjacentHTML('beforeend',"<a class='btn p' href='/'>🔄 New build</a>")}
|
| 418 |
-
}
|
| 419 |
-
if(st==='failed'&&S.error){$('errbox').style.display='block';$('errtxt').textContent=S.error}
|
| 420 |
-
if(S.fp&&S.fp.sha1){$('fpbox').style.display='block';$('sha1').textContent=S.fp.sha1;$('sha256').textContent=S.fp.sha256||''}
|
| 421 |
-
document.title=(pct!==null?pct+'% · ':'')+S.name;
|
| 422 |
-
}
|
| 423 |
-
function showLog(){
|
| 424 |
-
const lines=full.split('\n'); const p=$('log');
|
| 425 |
-
p.textContent=lines.slice(-400).join('\n');
|
| 426 |
-
if($('auto').checked)p.scrollTop=p.scrollHeight;
|
| 427 |
-
}
|
| 428 |
-
async function pullLog(){
|
| 429 |
-
for(let i=0;i<20;i++){
|
| 430 |
-
const r=await (await fetch('/api/job/'+ID+'/log?offset='+offset)).json();
|
| 431 |
-
if(!r.text)break; full+=r.text; offset=r.offset;
|
| 432 |
-
if(!r.more)break;
|
| 433 |
-
} showLog();
|
| 434 |
-
}
|
| 435 |
-
async function tick(){
|
| 436 |
-
try{
|
| 437 |
-
S=await (await fetch('/api/job/'+ID)).json(); fetchedAt=Date.now(); render(); await pullLog();
|
| 438 |
-
if(END.includes(S.status)){await pullLog();render();return}
|
| 439 |
-
}catch(e){}
|
| 440 |
-
setTimeout(tick,1500);
|
| 441 |
-
}
|
| 442 |
-
setInterval(()=>{if(S&&!END.includes(S.status))render()},1000);
|
| 443 |
-
$('cplog').onclick=()=>copyText(full);
|
| 444 |
-
$('cpsha1').onclick=()=>copyText(S&&S.fp&&S.fp.sha1||'');
|
| 445 |
-
$('cpsha256').onclick=()=>copyText(S&&S.fp&&S.fp.sha256||'');
|
| 446 |
-
$('cperr').onclick=()=>copyText(S&&S.error||'');
|
| 447 |
-
$('wrap').onchange=e=>$('log').classList.toggle('wrap',e.target.checked);
|
| 448 |
-
tick();
|
| 449 |
-
"""
|
| 450 |
-
|
| 451 |
-
@app.get("/job/<jid>")
|
| 452 |
-
def job_page(jid):
|
| 453 |
-
j = get_job(jid)
|
| 454 |
-
body = JOB_HTML.replace("__ID__", jid).replace("__NAME__", html.escape(j["name"]))
|
| 455 |
-
return shell("Build " + jid, body, JOB_JS.replace("__ID__", jid))
|
| 456 |
-
|
| 457 |
-
@app.get("/api/job/<jid>")
|
| 458 |
-
def api_job(jid):
|
| 459 |
-
return jsonify(public(get_job(jid)))
|
| 460 |
-
|
| 461 |
-
@app.get("/api/job/<jid>/log")
|
| 462 |
-
def api_log(jid):
|
| 463 |
-
get_job(jid)
|
| 464 |
-
p = JOBS / jid / "build.log"
|
| 465 |
-
off = max(0, request.args.get("offset", 0, type=int))
|
| 466 |
-
if not p.exists():
|
| 467 |
-
return jsonify(text="", offset=off, more=False)
|
| 468 |
-
with open(p, "rb") as f:
|
| 469 |
-
f.seek(off); data = f.read(200_000)
|
| 470 |
-
# don't cut a UTF-8 char in half
|
| 471 |
-
while data and (data[-1] & 0xC0) == 0x80: data = data[:-1]
|
| 472 |
-
if data and data[-1] >= 0xC0: data = data[:-1]
|
| 473 |
-
more = (off + len(data)) < p.stat().st_size
|
| 474 |
-
return jsonify(text=data.decode("utf-8", "replace"), offset=off + len(data), more=more)
|
| 475 |
-
|
| 476 |
-
@app.get("/job/<jid>/log.txt")
|
| 477 |
-
def log_txt(jid):
|
| 478 |
-
get_job(jid)
|
| 479 |
-
p = JOBS / jid / "build.log"
|
| 480 |
-
if not p.exists(): abort(404)
|
| 481 |
-
return send_file(p, mimetype="text/plain", as_attachment=True, download_name=f"build-{jid}.log")
|
| 482 |
-
|
| 483 |
-
@app.get("/job/<jid>/apk")
|
| 484 |
-
def apk(jid):
|
| 485 |
-
j = get_job(jid)
|
| 486 |
-
name = j.get("out", "app-debug.apk")
|
| 487 |
-
p = JOBS / jid / name
|
| 488 |
-
if not p.exists(): abort(404)
|
| 489 |
-
mime = "application/octet-stream" if name.endswith(".aab") else "application/vnd.android.package-archive"
|
| 490 |
-
return send_file(p, as_attachment=True, download_name=name, mimetype=mime)
|
| 491 |
-
|
| 492 |
-
@app.post("/api/job/<jid>/cancel")
|
| 493 |
-
def cancel(jid):
|
| 494 |
-
j = get_job(jid)
|
| 495 |
-
if j["status"] not in ACTIVE:
|
| 496 |
-
return jsonify(ok=False)
|
| 497 |
-
j["cancel"] = True
|
| 498 |
-
p = PROCS.get(jid)
|
| 499 |
-
if p:
|
| 500 |
-
try: os.killpg(p.pid, signal.SIGTERM)
|
| 501 |
-
except Exception: pass
|
| 502 |
-
subprocess.Popen([GRADLE, "--stop"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
| 503 |
-
return jsonify(ok=True)
|
| 504 |
-
|
| 505 |
-
if __name__ == "__main__":
|
| 506 |
-
app.run(host="0.0.0.0", port=PORT, threaded=True)
|
|
|
|
| 1 |
+
import os, re, hmac, threading, traceback
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch
|
| 4 |
+
from fastapi import FastAPI, Request
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
torch.set_num_threads(int(os.environ.get("TORCH_THREADS", "2")))
|
| 10 |
+
|
| 11 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 12 |
+
SECRET = os.environ.get("TTS_SECRET_KEY", "") # Space Settings -> Secrets me daalo
|
| 13 |
+
CONFIG = os.environ.get("TTS_CONFIG", "hf://saryps-labs/pocket-tts-hindi/config.yaml")
|
| 14 |
+
VOICE_FILE = os.path.join(HERE, "voice.wav")
|
| 15 |
+
FALLBACK_VOICE = "hf://kyutai/tts-voices/alba-mackenna/casual.wav"
|
| 16 |
+
QUANTIZE = os.environ.get("TTS_QUANTIZE", "0") == "1" # int8 (thoda tez), default band
|
| 17 |
+
MAX_CHARS = int(os.environ.get("MAX_CHARS", "6000"))
|
| 18 |
+
GAP_SECONDS = 0.22 # vakyon ke beech ka pause
|
| 19 |
+
|
| 20 |
+
model = None
|
| 21 |
+
voice_state = None
|
| 22 |
+
SR = 24000
|
| 23 |
+
state_msg = "loading"
|
| 24 |
+
gen_lock = threading.Lock()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_everything():
|
| 28 |
+
global model, voice_state, SR, state_msg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
try:
|
| 30 |
+
from pocket_tts import TTSModel
|
| 31 |
+
try:
|
| 32 |
+
m = TTSModel.load_model(CONFIG, temp=0.3, lsd_decode_steps=1, eos_threshold=-1.0)
|
| 33 |
+
except TypeError:
|
| 34 |
+
m = TTSModel.load_model(CONFIG)
|
| 35 |
+
if QUANTIZE:
|
|
|
|
|
|
|
|
|
|
| 36 |
try:
|
| 37 |
+
m = torch.quantization.quantize_dynamic(m, {torch.nn.Linear}, dtype=torch.qint8)
|
| 38 |
+
print("int8 quantization ON")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
print("quantize fail, fp32 chal raha he:", e)
|
| 41 |
+
voice_path = VOICE_FILE if os.path.exists(VOICE_FILE) else FALLBACK_VOICE
|
| 42 |
+
print("Voice prompt:", voice_path)
|
| 43 |
+
voice_state = m.get_state_for_audio_prompt(voice_path)
|
| 44 |
+
SR = int(getattr(m, "sample_rate", 24000))
|
| 45 |
+
model = m
|
| 46 |
+
state_msg = "ready"
|
| 47 |
+
print("Model ready, sample rate", SR)
|
| 48 |
+
except Exception:
|
| 49 |
+
state_msg = "error"
|
| 50 |
+
traceback.print_exc()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
threading.Thread(target=load_everything, daemon=True).start()
|
| 54 |
+
|
| 55 |
+
app = FastAPI()
|
| 56 |
+
app.add_middleware(
|
| 57 |
+
CORSMiddleware,
|
| 58 |
+
allow_origins=["*"],
|
| 59 |
+
allow_methods=["*"],
|
| 60 |
+
allow_headers=["*"],
|
| 61 |
+
expose_headers=["X-Sample-Rate"],
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def authorized(request: Request) -> bool:
|
| 66 |
+
if not SECRET:
|
| 67 |
+
return False
|
| 68 |
+
cands = [
|
| 69 |
+
request.headers.get("x-secret-key", ""),
|
| 70 |
+
request.query_params.get("key", ""),
|
| 71 |
+
]
|
| 72 |
+
auth = request.headers.get("authorization", "")
|
| 73 |
+
if auth.startswith("Bearer "):
|
| 74 |
+
cands.append(auth[7:])
|
| 75 |
+
return any(hmac.compare_digest(c.encode(), SECRET.encode()) for c in cands if c)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def split_clean(text: str):
|
| 79 |
+
"""Text ko vakyon me todo aur punctuation/English hata do (model punctuation nahi samajhta)."""
|
| 80 |
+
text = text.replace("\r", "\n")
|
| 81 |
+
parts = re.split(r"[\u0964\u0965\.\?\!\n;:]+", text)
|
| 82 |
+
out = []
|
| 83 |
+
for p in parts:
|
| 84 |
+
# sirf Devanagari (danda chhodkar), digits aur space rakho
|
| 85 |
+
p = re.sub(r"[^\u0900-\u0963\u0966-\u097F0-9\s]", " ", p)
|
| 86 |
+
p = re.sub(r"\s+", " ", p).strip()
|
| 87 |
+
if len(p) < 3:
|
| 88 |
+
continue
|
| 89 |
+
# bahut lambe vakya ko ~200 chars ke tukdon me todo
|
| 90 |
+
while len(p) > 220:
|
| 91 |
+
cut = p.rfind(" ", 0, 200)
|
| 92 |
+
cut = cut if cut > 50 else 200
|
| 93 |
+
out.append(p[:cut].strip())
|
| 94 |
+
p = p[cut:].strip()
|
| 95 |
+
if p:
|
| 96 |
+
out.append(p)
|
| 97 |
return out
|
| 98 |
|
| 99 |
|
| 100 |
+
def iter_audio(text: str):
|
| 101 |
+
if hasattr(model, "generate_audio_stream"):
|
| 102 |
+
for a in model.generate_audio_stream(voice_state, text):
|
| 103 |
+
yield a
|
| 104 |
+
else:
|
| 105 |
+
yield model.generate_audio(voice_state, text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
def to_pcm16(a) -> bytes:
|
| 109 |
+
arr = a.detach().cpu().numpy() if hasattr(a, "detach") else np.asarray(a)
|
| 110 |
+
arr = np.clip(arr.astype(np.float32).reshape(-1), -1.0, 1.0)
|
| 111 |
+
return (arr * 32767.0).astype("<i2").tobytes()
|
|
|
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
+
def pcm_stream(sentences):
|
| 115 |
+
silence = np.zeros(int(GAP_SECONDS * SR), dtype="<i2").tobytes()
|
| 116 |
+
with gen_lock: # ek samay me ek hi generation (CPU bachane ke liye)
|
| 117 |
+
for s in sentences:
|
| 118 |
+
try:
|
| 119 |
+
for a in iter_audio(s):
|
| 120 |
+
yield to_pcm16(a)
|
| 121 |
+
yield silence
|
| 122 |
+
except Exception:
|
| 123 |
+
print("Sentence fail:", s[:60])
|
| 124 |
+
traceback.print_exc()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
class TTSReq(BaseModel):
|
| 128 |
+
text: str
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
@app.get("/")
|
| 132 |
+
def root():
|
| 133 |
+
return {"status": "ok"}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@app.get("/health")
|
| 137 |
+
def health():
|
| 138 |
+
return {"state": state_msg}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@app.post("/tts")
|
| 142 |
+
def tts(req: TTSReq, request: Request):
|
| 143 |
+
if not authorized(request):
|
| 144 |
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
| 145 |
+
if state_msg == "loading":
|
| 146 |
+
return JSONResponse({"error": "model abhi load ho raha he, 1 minute baad try karo"}, status_code=503)
|
| 147 |
+
if state_msg == "error" or model is None:
|
| 148 |
+
return JSONResponse({"error": "model load fail hua, Space ke logs dekho"}, status_code=500)
|
| 149 |
+
if len(req.text) > MAX_CHARS:
|
| 150 |
+
return JSONResponse({"error": f"text bahut lamba he (max {MAX_CHARS} characters)"}, status_code=413)
|
| 151 |
+
sentences = split_clean(req.text)
|
| 152 |
+
if not sentences:
|
| 153 |
+
return JSONResponse({"error": "text me Hindi (देवनागरी) shabd nahi mile"}, status_code=400)
|
| 154 |
+
return StreamingResponse(
|
| 155 |
+
pcm_stream(sentences),
|
| 156 |
+
media_type="application/octet-stream",
|
| 157 |
+
headers={"X-Sample-Rate": str(SR), "Cache-Control": "no-store"},
|
| 158 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|