backend/main.py (6128 bytes)
1 """Application entrypoint. 2 3 python -m uvicorn backend.main:app --host 127.0.0.1 --port 8420 4 5 Serves the API and the static frontend from one origin, so localhost needs no 6 CORS config and no second server. 7 """ 8 9 from __future__ import annotations 10 11 import asyncio 12 13 import logging 14 import mimetypes 15 import subprocess 16 from contextlib import asynccontextmanager 17 18 from fastapi import FastAPI 19 from fastapi.responses import FileResponse 20 from fastapi.staticfiles import StaticFiles 21 22 from . import billing, captions, play, subrep 23 from .api import router 24 from .db import Job, JobStatus, SessionLocal, init_db 25 from .languages import all_languages 26 from .queue import queue 27 from .settings import ROOT, settings 28 29 logging.basicConfig( 30 level=logging.INFO, 31 format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", 32 ) 33 log = logging.getLogger("subplz.web") 34 35 FRONTEND = ROOT / "frontend" 36 37 38 def _version() -> str: 39 """The release this checkout is: the nearest git tag, e.g. v2.0.0 or 40 v2.0.0-3-gabc1234 when it is ahead of one. Answers "what is deployed?".""" 41 try: 42 return subprocess.run( 43 ["git", "-c", f"safe.directory={ROOT.as_posix()}", "describe", "--tags", "--always", "--dirty"], 44 cwd=ROOT, capture_output=True, text=True, timeout=10, check=True, 45 ).stdout.strip() or "unknown" 46 except (subprocess.SubprocessError, OSError): 47 return "unknown" 48 49 50 VERSION = _version() 51 52 # Python takes these from the OS, and Windows gets both wrong. A module served 53 # as text/plain is refused outright, and WebAssembly will not stream-compile. 54 mimetypes.add_type("text/javascript", ".js") 55 mimetypes.add_type("text/javascript", ".mjs") 56 mimetypes.add_type("application/wasm", ".wasm") 57 58 59 def _requeue_interrupted() -> None: 60 """Recover jobs that were mid-flight when the server last stopped. 61 62 An in-process queue dies with the process, so anything left `running` is 63 orphaned. Put it back in the queue rather than leaving a stuck progress bar. 64 65 A Redis queue keeps its jobs, and a worker on another machine may still run 66 one. Putting that job on the queue again would convert the book twice. 67 """ 68 if settings.queue_backend != "memory": 69 return 70 with SessionLocal() as s: 71 stale = ( 72 s.query(Job) 73 .filter(Job.status.in_([JobStatus.running, JobStatus.queued]), Job.local == 0) 74 .all() 75 ) 76 for job in stale: 77 job.status = JobStatus.queued 78 job.stage = "Queued (resumed after restart)" 79 job.progress = 0.0 80 s.commit() 81 ids = [(j.id, billing.is_paid(j)) for j in stale] 82 83 for job_id, paid in ids: 84 queue.enqueue(job_id, paid=paid) 85 if ids: 86 log.info("re-queued %d interrupted job(s)", len(ids)) 87 88 89 @asynccontextmanager 90 async def lifespan(_: FastAPI): 91 init_db() 92 log.info( 93 "subplz-web ready | %d languages | model=%s device=%s | " 94 "queue=%s storage=%s | billing=%s", 95 len(all_languages()), settings.model, settings.device, 96 settings.queue_backend, settings.storage_backend, 97 "on" if settings.billing_enabled else "off", 98 ) 99 _requeue_interrupted() 100 sweeper = asyncio.create_task(_housekeeping()) 101 yield 102 sweeper.cancel() 103 queue.shutdown() 104 105 106 def housekeeping() -> None: 107 """One pass of the hourly housekeeping: let go of abandoned browser jobs, 108 and take back the hours of Google Play purchases that Google refunded. 109 110 It deletes no file. The server keeps each upload and each result for 111 debugging, and the operator deletes them by hand. 112 """ 113 from .api import expire_stale_local_jobs 114 115 with SessionLocal() as s: 116 expire_stale_local_jobs(s) 117 if settings.play_configured: 118 with SessionLocal() as s: 119 play.take_back_voided(s) 120 121 122 async def _housekeeping() -> None: 123 """Run `housekeeping` once an hour.""" 124 while True: 125 try: 126 await asyncio.to_thread(housekeeping) 127 except Exception: # noqa: BLE001 - housekeeping must not take the site down 128 log.exception("housekeeping failed") 129 await asyncio.sleep(3600) 130 131 132 app = FastAPI( 133 title="SubPlz Web", 134 summary="Drag an audiobook and an epub in; get split-timed SRT subtitles out.", 135 version="1.0.0", 136 lifespan=lifespan, 137 ) 138 app.include_router(router) 139 app.include_router(captions.router) 140 app.include_router(play.router) 141 app.include_router(subrep.router) 142 143 144 @app.middleware("http") 145 async def cross_origin_isolation(request, call_next): 146 """Let the page use threads. 147 148 The speech model runs in the browser on WebAssembly threads, which need 149 SharedArrayBuffer, which browsers only hand to a cross-origin-isolated 150 page. Every file the page loads, the model weights included, comes from 151 this origin. "credentialless" rather than "require-corp" so that a 152 cross-origin image or link a visitor adds later does not break the page. 153 """ 154 response = await call_next(request) 155 response.headers["Cross-Origin-Opener-Policy"] = "same-origin" 156 response.headers["Cross-Origin-Embedder-Policy"] = "credentialless" 157 # The engine's modules import each other by bare path, so a browser that 158 # kept an old engine/asr.js would run it with a new app.js. Make each of 159 # them ask the server first (an ETag answers in one round trip); the 160 # pinned libraries and weights under /vendor/ may stay a day. 161 if request.url.path.startswith("/vendor/"): 162 response.headers["Cache-Control"] = "public, max-age=86400" 163 elif response.headers.get("content-type", "").startswith(("text/javascript", "text/html")): 164 response.headers["Cache-Control"] = "no-cache" 165 return response 166 167 168 @app.get("/healthz") 169 def healthz(): 170 return { 171 "ok": True, 172 "version": VERSION, 173 "languages": len(all_languages()), 174 "queue_backend": settings.queue_backend, 175 "queue_depth": queue.depth(), 176 "storage_backend": settings.storage_backend, 177 } 178 179 180 @app.get("/") 181 def index(): 182 return FileResponse(FRONTEND / "index.html") 183 184 185 app.mount("/", StaticFiles(directory=FRONTEND), name="static")