Recently Written · git

subplz-web

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

Log | Files | Refs


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