yash184 commited on
Commit
fb657a8
·
verified ·
1 Parent(s): eb21fb1

Update App.py

Browse files
Files changed (1) hide show
  1. App.py +144 -492
App.py CHANGED
@@ -1,506 +1,158 @@
1
- import os, re, json, shutil, signal, subprocess, threading, time, uuid, zipfile, html
2
- from pathlib import Path
3
- from flask import Flask, request, abort, send_file, jsonify
4
-
5
- app = Flask(__name__)
6
- app.config["MAX_CONTENT_LENGTH"] = 500 * 1024 * 1024
7
-
8
- JOBS = Path(os.environ.get("JOBS_DIR", "/tmp/jobs")); JOBS.mkdir(parents=True, exist_ok=True)
9
- GRADLE = os.environ.get("GRADLE_BIN", "gradle")
10
- SDK_DIR = os.environ.get("ANDROID_HOME", "/opt/android-sdk")
11
- ACCESS_KEY = os.environ.get("ACCESS_KEY", "") # optional; empty = no password
12
- PORT = int(os.environ.get("PORT", "7860"))
13
-
14
- # ---- signing key: asked on EVERY build, never stored ----
15
- # The .jks + password come with the upload form. They live only in memory
16
- # (KEYS dict) and as a temp file inside the job folder while Gradle runs, and
17
- # are deleted right after. Nothing about the key is written to state.json/logs.
18
- KEYS = {} # job id -> {"path": Path, "password": str, "alias": str}
19
-
20
- MODES = { # form value -> (gradle task, output glob, file name, label)
21
- "debug": ("assembleDebug", "build/outputs/apk/debug/*.apk", "app-debug.apk", "Debug APK"),
22
- "release": ("assembleRelease", "build/outputs/apk/release/*.apk", "app-release.apk", "Release APK (signed, for installing)"),
23
- "bundle": ("bundleRelease", "build/outputs/bundle/release/*.aab", "app-release.aab", "Release AAB (for Play Store)"),
24
- }
25
-
26
- def key_fingerprints(path, alias, password):
27
- """SHA-1 / SHA-256 of the uploaded key (public values, safe to show)."""
28
- try:
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
- (JOBS / j["id"] / "state.json").write_text(json.dumps(j))
49
- except Exception:
50
- pass
51
-
52
- def load_old():
53
- for d in JOBS.iterdir():
54
- if not d.is_dir(): continue
55
- f = d / "state.json"
56
- if f.exists():
57
  try:
58
- j = json.loads(f.read_text())
59
- if j["status"] in ACTIVE:
60
- j["status"], j["error"] = "failed", "Server restarted while building."
61
- j["finished"] = j.get("finished") or time.time()
62
- JOB[j["id"]] = j
63
- except Exception:
64
- pass
65
- load_old()
66
-
67
- DEFAULT_TASKS = 60 # only a last-resort guess; real count comes from a gradle --dry-run
68
- def load_totals():
69
- try: return json.loads((JOBS / "_totals.json").read_text())
70
- except Exception: return {}
71
- def save_totals(t):
72
- try: (JOBS / "_totals.json").write_text(json.dumps(t))
73
- except Exception: pass
74
-
75
- def get_job(jid):
76
- if not ID_RE.match(jid) or jid not in JOB:
77
- abort(404)
78
- return JOB[jid]
79
-
80
- def public(j):
81
- now = time.time()
82
- t0 = j.get("started") or j["created"]
83
- end = j.get("finished") or now
84
- out = {k: j.get(k) for k in ("id", "name", "status", "total", "done", "current", "error", "apk_mb")}
85
- out["elapsed"] = max(0, end - t0)
86
- out["has_apk"] = (JOBS / j["id"] / j.get("out", "app-debug.apk")).exists()
87
- out["out"] = j.get("out", "app-debug.apk")
88
- out["mode"] = j.get("mode", "debug")
89
- out["fp"] = j.get("fp")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  return out
91
 
92
 
93
- # ---------------------------------------------------------------- build
94
- def find_root(src: Path):
95
- cands = [src] + sorted([p for p in src.rglob("*") if p.is_dir()], key=lambda p: len(p.parts))
96
- for p in cands:
97
- if (p / "settings.gradle.kts").exists() or (p / "settings.gradle").exists():
98
- return p
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
- task, out_glob, out_name, _label = MODES.get(j.get("mode", "debug"), MODES["debug"])
139
- genv = os.environ.copy()
140
- genv.pop("ACCESS_KEY", None)
141
- key = KEYS.get(jid)
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
- try:
150
- # 0. signing key (from the upload form)
151
- if key:
152
- genv.update(KEYSTORE_PATH=str(keyfile), STORE_PASSWORD=key["password"],
153
- KEY_PASSWORD=key["password"], KEY_ALIAS=key["alias"])
154
- w("Signing with the uploaded key (deleted after the build).\n")
155
- elif task != "assembleDebug":
156
- return finish("failed", "Release builds need your signing key: choose the .jks file and enter its password.")
157
- else:
158
- w("No key given: signed with a temporary debug key (SHA-1 will differ from your real key).\n")
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
- ICON = {"extracting": "📂", "queued": "⏳", "preparing": "🧮", "building": "🔨",
278
- "done": "✅", "failed": "❌", "cancelled": "🚫"}
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 index():
292
- key = "<input name='key' type='password' placeholder='Access key' required>" if ACCESS_KEY else ""
293
- jobs = sorted(JOB.values(), key=lambda j: -j["created"])[:10]
294
- rows = "".join(
295
- f"<div class='li'><a href='/job/{j['id']}'>{ICON[j['status']]} {html.escape(j['name'])}</a>"
296
- f"<span class='mu'>{time.strftime('%d %b %H:%M', time.localtime(j['created']))}</span></div>" for j in jobs
297
- ) or "<div class='mu'>No builds yet.</div>"
298
- opts = "".join(f"<option value='{k}'>{html.escape(v[3])}</option>" for k, v in MODES.items())
299
- body = f"""<h1>📦 APK Builder</h1>
300
- <div class='card'><form id='f'><input type='file' name='zip' accept='.zip' required>{key}
301
- <select name='mode' style='width:100%;padding:12px;border-radius:10px;margin-bottom:10px;background:#0f1426;color:#e8ecf8;border:1px solid #262e48'>{opts}</select>
302
- <div class='mu' style='margin:4px 0 6px'>🔑 Signing key — needed for Release; for Debug it keeps the same SHA-1 as your real key. Not saved anywhere.</div>
303
- <input type='file' name='keystore' accept='.jks,.keystore,.p12'>
304
- <input type='password' name='storepass' placeholder='Keystore password' autocomplete='off'>
305
- <input type='text' name='alias' value='upload' placeholder='Key alias' style='width:100%;padding:12px;border-radius:10px;border:1px dashed #262e48;background:#0f1426;color:#e8ecf8;margin-bottom:10px'>
306
- <button class='p' id='go' style='width:100%'>Upload &amp; build</button></form>
307
- <div class='bar' style='display:none;margin-top:12px'><i id='ub'></i></div><div class='mu' id='us' style='margin-top:6px'></div></div>
308
- <div class='card'><b>Recent builds</b><div style='margin-top:6px'>{rows}</div></div>
309
- <div class='mu'>JDK 17 · Gradle 9.3.1</div>"""
310
- return shell("APK Builder", body, HOME_JS)
311
-
312
- @app.post("/build")
313
- def build():
314
- if ACCESS_KEY and request.form.get("key") != ACCESS_KEY:
315
- abort(403)
316
- f = request.files.get("zip")
317
- if not f:
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
+ )