imageeditingpro1 / server.py
Transfer Bot
Moved to Hugging Face automatically
3928cbb
Raw History Blame Contribute Delete
11 kB
import asyncio
import hmac
import os
import re
import threading
import time
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel
import builder
import store
app = FastAPI(title="Lesson content server")
state = {"status": "idle", "log": [], "errors": {}, "finished": None, "voices": {}, "fallback": {}}
lock = threading.Lock()
def _log(msg):
state["log"].append(msg)
del state["log"][:-200]
def run_build():
if not lock.acquire(blocking=False):
return
try:
state.update(status="building", log=[], errors={})
# صداهای قبلی را از ذخیره‌ی پایدار برمی‌گردانیم. اگر نشد، ساخت را متوقف می‌کنیم تا چیزی از نو ساخته و بازنویسی نشود.
if not store.pull(_log):
_log("ساخت متوقف شد تا صداهای قبلی بازنویسی نشوند. کمی بعد «ساخت دوباره» را بزنید.")
state["status"] = "failed"
return
def sync():
if builder.STATE["dirty"] and store.push(_log):
builder.STATE["dirty"] = False
_, errors = asyncio.run(builder.build_all(_log, sync))
sync()
fb = {k: sum(1 for i in v["items"] if i["fb"]) for k, v in builder.LESSON_AUDIO.items()}
state.update(status="ready", errors=errors, voices=dict(builder.INFO), fallback={k: n for k, n in fb.items() if n},
finished=time.strftime("%Y-%m-%d %H:%M:%S"))
if state["fallback"]:
_log("⚠️ جمله‌هایی با صدای جایگزین ساخته شده‌اند: " + ", ".join(f"{k}({n})" for k, n in state["fallback"].items())
+ " . در صفحه‌ی «بررسی صداها» می‌توانید دوباره با Kokoro بسازید.")
except Exception as e: # noqa: BLE001
_log(f"خطای کلی: {e}")
state["status"] = "failed"
finally:
lock.release()
@app.on_event("startup")
def startup():
threading.Thread(target=run_build, daemon=True).start()
def check_password(p):
expected = os.getenv("REBUILD_PASSWORD", "")
if expected and not hmac.compare_digest(p or "", expected):
raise HTTPException(401, "رمز اشتباه است")
@app.get("/manifest.json")
def manifest():
f = builder.OUT / "manifest.json"
if not f.exists():
return JSONResponse({"status": state["status"]}, status_code=503)
return FileResponse(f, media_type="application/json", headers={"Cache-Control": "no-cache"})
STRINGS_FILE = Path(__file__).parent / "strings.json"
@app.get("/strings.json")
def strings():
"""متن‌های برنامه‌ی اندروید: { "متن فعلی": "متن جدید" }. اپ هنگام باز شدن آن را می‌گیرد."""
if not STRINGS_FILE.exists():
return JSONResponse({}, headers={"Cache-Control": "no-cache"})
return FileResponse(STRINGS_FILE, media_type="application/json", headers={"Cache-Control": "no-cache"})
@app.get("/packs/{name}")
def pack(name: str):
if not re.fullmatch(r"[A-Za-z0-9_.-]+\.zip", name):
raise HTTPException(400, "bad name")
f = builder.OUT / "packs" / name
if not f.exists():
raise HTTPException(404, "not found")
return FileResponse(f, media_type="application/zip")
@app.get("/status")
def status():
return state
@app.post("/rebuild")
def rebuild(x_password: str = Header(default="")):
check_password(x_password)
if state["status"] == "building":
return {"ok": False, "msg": "ساخت در حال انجام است"}
threading.Thread(target=run_build, daemon=True).start()
return {"ok": True}
class RegenBody(BaseModel):
lesson: str
texts: Optional[List[str]] = None # خالی = همه‌ی متن‌های درس
@app.post("/api/regen")
def api_regen(body: RegenBody, x_password: str = Header(default="")):
"""صدای کل یک درس یا فقط چند جمله/واژه را دوباره می‌سازد. بقیه‌ی درس‌ها و صداها دست نمی‌خورند."""
check_password(x_password)
if state["status"] == "building":
raise HTTPException(409, "ساخت در حال انجام است؛ بعد از پایانش دوباره تلاش کنید")
try:
n = builder.regen(body.lesson, body.texts)
except Exception as e: # noqa: BLE001
raise HTTPException(400, str(e))
threading.Thread(target=run_build, daemon=True).start()
return {"ok": True, "count": n}
@app.get("/api/lessons")
def api_lessons():
return builder.LESSON_AUDIO
@app.get("/audio/{name}")
def audio(name: str):
if not re.fullmatch(r"[A-Za-z0-9_-]+\.(mp3|wav|ogg|m4a|flac)", name):
raise HTTPException(400, "bad name")
f = builder.CACHE / name
if not f.exists():
raise HTTPException(404, "not found")
return FileResponse(f)
REVIEW = r"""<!doctype html><html lang="fa" dir="rtl"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>بررسی صداها</title>
<style>body{font-family:system-ui,Tahoma;max-width:820px;margin:20px auto;padding:0 12px;background:#f4f9f9;color:#10282b}
details{background:#fff;border-radius:12px;padding:10px 14px;margin:10px 0;box-shadow:0 1px 4px #0001}
summary{cursor:pointer;font-weight:bold}
.row{display:flex;gap:8px;align-items:center;padding:7px 0;border-top:1px solid #e3eeee;flex-wrap:wrap}
.row .t{flex:1 1 260px;direction:ltr;text-align:left;font-size:14px}
.k{font-size:11px;background:#dff1f3;border-radius:6px;padding:1px 6px}.fb{background:#ffe3a3}
button{background:#0b7f8b;color:#fff;border:0;border-radius:8px;padding:6px 12px;cursor:pointer}
button.alt{background:#fff;color:#0b7f8b;border:1px solid #0b7f8b}
input{padding:8px;border-radius:8px;border:1px solid #9bb}</style>
<h3>بررسی صداها</h3>
<p><a href="./">← صفحه‌ی اصلی</a> · <input id="pw" type="password" placeholder="رمز (اگر تعریف کرده‌اید)"></p>
<p style="font-size:13px">هر متن را پخش کنید. اگر تلفظ بد بود «بازسازی» را بزنید. فقط همان صدا ساخته می‌شود و بقیه‌ی درس‌ها دست نمی‌خورند.
اگر بعد از بازسازی هم همان‌طور خوانده شد، در فایل درس کنار آن متن فیلد <code>"tts"</code> بگذارید (مثلاً املای دیگر یا علامت‌گذاری دیگر).
بعد از تغییر صدا، <code>version</code> همان درس را بالا ببرید تا کاربران قدیمی هم نسخه‌ی تازه را بگیرند.</p>
<div id="box">در حال خواندن…</div>
<script>
async function regen(lesson,texts){
const r=await fetch('api/regen',{method:'POST',headers:{'Content-Type':'application/json','X-Password':pw.value},
body:JSON.stringify({lesson:lesson,texts:texts})});
if(!r.ok){let m='خطا';try{m=(await r.json()).detail||m}catch(e){}alert(m);return}
alert('ساخت شروع شد. چند دقیقه بعد این صفحه را تازه کنید.');}
(async()=>{
const d=await (await fetch('api/lessons')).json(); box.textContent='';
const ids=Object.keys(d); if(!ids.length){box.textContent='هنوز ساخته نشده؛ چند دقیقه بعد دوباره باز کنید.';return}
ids.forEach(id=>{
const L=d[id], det=document.createElement('details'), sm=document.createElement('summary');
const nfb=L.items.filter(i=>i.fb).length;
sm.textContent=id+' — '+L.title+' ('+L.items.length+' صدا'+(nfb?('، '+nfb+' با صدای جایگزین'):'')+')';
det.appendChild(sm);
const all=document.createElement('button');all.className='alt';all.textContent='بازسازی همه‌ی صداهای این درس';
all.onclick=()=>{if(confirm('همه‌ی صداهای درس '+id+' دوباره ساخته شود؟'))regen(id,null)};det.appendChild(all);
L.items.forEach(it=>{
const row=document.createElement('div');row.className='row';
const t=document.createElement('span');t.className='t';t.textContent=it.t;
const k=document.createElement('span');k.className='k'+(it.fb?' fb':'');k.textContent=it.fb?'جایگزین':(it.k==='w'?'واژه':'جمله');
const a=document.createElement('audio');a.controls=true;a.preload='none';a.src='audio/'+it.f;
const b=document.createElement('button');b.textContent='بازسازی';b.onclick=()=>regen(id,[it.t]);
row.append(t,k,a,b);det.appendChild(row);});
box.appendChild(det);});
})();
</script></html>"""
@app.get("/review", response_class=HTMLResponse)
def review():
return REVIEW
PAGE = """<!doctype html><html lang="fa" dir="rtl"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>سرور درس‌ها</title>
<style>body{font-family:system-ui,Tahoma;max-width:720px;margin:24px auto;padding:0 16px;background:#f4f9f9;color:#10282b}
.card{background:#fff;border-radius:14px;padding:16px;margin:12px 0;box-shadow:0 1px 4px #0001}
button{background:#0b7f8b;color:#fff;border:0;border-radius:10px;padding:10px 18px;font-size:15px;cursor:pointer}
input{padding:9px;border-radius:8px;border:1px solid #9bb;font-size:15px}
pre{background:#10282b;color:#cfe;padding:12px;border-radius:10px;max-height:300px;overflow:auto;direction:ltr;text-align:left;white-space:pre-wrap}
.ok{color:#1e9e6a}.bad{color:#d9482b}</style>
<h2>سرور درس‌ها</h2>
<div class="card"><div>وضعیت: <b id="st">…</b></div><div id="fin"></div>
<p><a href="review">🎧 بررسی صداها و بازسازی</a></p>
<p>آدرس فهرست: <code id="url" dir="ltr"></code></p>
<p><input id="pw" type="password" placeholder="رمز (اگر تعریف کرده‌اید)"> <button onclick="rb()">ساخت دوباره‌ی درس‌ها و صداها</button></p></div>
<div class="card"><b>گزارش</b><pre id="log"></pre><div id="err" class="bad"></div></div>
<script>
document.getElementById('url').textContent=location.origin+'/manifest.json';
async function load(){const s=await (await fetch('status')).json();
const m={idle:'آماده',building:'در حال ساخت…',ready:'آماده ✅',failed:'ناموفق ❌'};
st.textContent=m[s.status]||s.status;fin.textContent=(s.finished?('آخرین ساخت: '+s.finished):'')+(s.voices&&s.voices.words?(' | واژه‌ها: '+s.voices.words+' | جمله‌ها: '+s.voices.sentences):'');
log.textContent=s.log.join('\\n');err.textContent=Object.entries(s.errors).map(([k,v])=>k+': '+v).join('\\n');}
async function rb(){const r=await fetch('rebuild',{method:'POST',headers:{'X-Password':pw.value}});
if(!r.ok){alert('رمز اشتباه است');return}load()}
load();setInterval(load,3000);
</script></html>"""
@app.get("/", response_class=HTMLResponse)
def home():
return PAGE