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