Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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")