Recently Written · git

subplz-web

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

Log | Files | Refs


backend/settings.py (9078 bytes)

1 """Configuration. Every scale-out seam is an env var with a localhost-friendly default."""
2 
3 import os
4 import shutil
5 from pathlib import Path
6 from typing import Literal
7 
8 from pydantic_settings import BaseSettings, SettingsConfigDict
9 
10 ROOT = Path(__file__).resolve().parent.parent
11 
12 
13 def _find_subplz() -> str:
14     """Locate the alignment backend on PATH.
15 
16     subplz is an ordinary dependency of this project, installed into whatever
17     environment is running it - not a sibling checkout. Falling back to the bare
18     name keeps the error useful when it is genuinely missing.
19     """
20     return shutil.which("subplz") or "subplz"
21 
22 
23 class Settings(BaseSettings):
24     model_config = SettingsConfigDict(env_prefix="SUBPLZ_WEB_", env_file=".env", extra="ignore")
25 
26     # --- paths -------------------------------------------------------------
27     data_dir: Path = ROOT / "data"
28     # Override with SUBPLZ_WEB_SUBPLZ_BIN to point at a specific build.
29     subplz_bin: Path | str = _find_subplz()
30 
31     # --- alignment ---------------------------------------------------------
32     # Which backend does the aligning. See aligner.py to add another.
33     aligner: str = "subplz"
34     # "tiny" is what upstream recommends for audiobooks: the transcript only has
35     # to be good enough to align against text we already have.
36     model: str = "tiny"
37     device: Literal["cpu", "cuda"] = "cpu"
38     # 0 = let the runner pick from CPU count.
39     threads: int = 0
40     # Wall-clock ceiling per job so a wedged run cannot hold a worker forever.
41     job_timeout_seconds: int = 6 * 60 * 60
42 
43     # --- scale-out seams ---------------------------------------------------
44     # "memory" runs jobs in a thread in this process (localhost).
45     # "redis" hands them to external workers (public deployment).
46     queue_backend: Literal["memory", "redis"] = "memory"
47     redis_url: str = "redis://localhost:6379/0"
48     # How many jobs this process will run at once when queue_backend="memory".
49     max_concurrent_jobs: int = 1
50 
51     # "local" writes under data_dir. "s3" writes to a bucket.
52     storage_backend: Literal["local", "s3"] = "local"
53     s3_bucket: str = ""
54     s3_prefix: str = "jobs/"
55     # Presigned-URL lifetime for s3 downloads.
56     download_url_ttl_seconds: int = 3600
57 
58     # SQLite locally; set to a postgresql+psycopg:// URL in production.
59     database_url: str = ""
60 
61     # --- billing -----------------------------------------------------------
62     # The public offer: free in the visitor's browser; a conversion on this
63     # server's hardware takes a credit. See billing.py.
64     # True when fast conversion on this server's hardware is on offer. While it
65     # is false the site sells nothing, whatever billing_enabled says: credits
66     # that buy nothing must not be for sale.
67     cloud_enabled: bool = False
68     # Where customers write to: shown on the terms page and in the footer. The
69     # payment processor wants one on the site before it lets a shop go live.
70     contact_email: str = ""
71     # Off on localhost so nothing blocks you; flip on for the public release.
72     billing_enabled: bool = False
73     # Free credits for conversions on this server. They exist only while
74     # cloud_enabled is on. See billing.py.
75     # A visitor without a verified email gets free_credits_anonymous. A buyer
76     # whose email came only from Stripe is in this group.
77     free_credits_anonymous: int = 1
78     # An account whose email is verified (by an emailed sign-in link) gets
79     # free_credits_verified in all: a free credit that one of its devices
80     # used before counts toward it.
81     free_credits_verified: int = 2
82     # A verified account also gets one free credit each day. It does not
83     # carry over to the next day.
84     daily_free_credit: bool = True
85     # A verified account gets one more free credit for each this many books
86     # that the server converted for it. 0 turns this off.
87     books_per_bonus_credit: int = 2
88 
89     # --- accounts & payments -----------------------------------------------
90     # What customers see on the Stripe page and in the sign-in email.
91     site_name: str = "SubRead"
92     # The origin users reach this on. Sign-in links and Stripe's return URLs
93     # are built from it, so it must be right in production.
94     public_base_url: str = "http://127.0.0.1:8420"
95     # Send the identity cookie over HTTPS only. On for any public deployment.
96     cookie_secure: bool = False
97 
98     # Stripe. Leave empty and checkout reports itself unavailable rather than
99     # failing half way through a purchase.
100     stripe_secret_key: str = ""
101     stripe_webhook_secret: str = ""
102 
103     # Sign-in links go out over plain SMTP, so any provider works. With no
104     # host set, sign-in is simply unavailable (see dev_login_links below).
105     smtp_host: str = ""
106     smtp_port: int = 587
107     smtp_user: str = ""
108     smtp_password: str = ""
109     smtp_from: str = "SubRead <login@subread.space>"
110     smtp_starttls: bool = True
111     login_link_minutes: int = 30
112     # Hand the sign-in link back in the API response instead of mailing it.
113     # For development ONLY: it signs anyone in as any address. Never inferred
114     # from other settings - a public server with billing off is still public.
115     dev_login_links: bool = False
116 
117     # --- match check -------------------------------------------------------
118     # Transcribe a few short samples on upload and score them against the book,
119     # so a mismatched pair fails in seconds instead of after a full run.
120     match_check: bool = True
121     # Clean the book text before alignment to raise the match score.
122     text_prep: bool = True
123 
124     # --- video -------------------------------------------------------------
125     # Render a YouTube-ready MP4 (cover image + audio + soft subtitle track).
126     render_video: bool = True
127     video_width: int = 1920
128     video_height: int = 1080
129     # 1 fps is the minimum YouTube accepts and all a still image needs.
130     video_fps: int = 1
131     # "auto" picks the best H.264 encoder this ffmpeg build actually has.
132     # Force one with libx264 / h264_nvenc / h264_amf / h264_qsv / libopenh264.
133     video_encoder: str = "auto"
134     video_crf: int = 28
135 
136     # --- cloud captions (Subrep) ------------------------------------------
137     # Without a key, /api/captions/transcribe answers 503. Set it with
138     # tools/set-secret.sh SUBPLZ_WEB_CAPTION_API_KEY on the server.
139     caption_api_key: str = ""
140     caption_api_url: str = "https://api.deepinfra.com/v1/openai/audio/transcriptions"
141     caption_model: str = "openai/whisper-large-v3-turbo"
142     # The longest piece of speech in one request. The app sends 11 s at most.
143     caption_max_seconds: int = 30
144 
145     # --- Google Play (Subrep Play build) ------------------------------------
146     # The path of the JSON key of a service account that can read and consume
147     # the orders of Subrep in Play Console. Keep the file outside the checkout,
148     # owned by the service user, with mode 600, in a folder that the service
149     # user can open. If the service user cannot read the file, the server
150     # tells the Play build that it cannot sell hours (play_available), and
151     # POST /api/captions/play-purchase answers 503. See play.py.
152     play_service_account_file: str = ""
153 
154     # --- Subrep Pro -------------------------------------------------------
155     # The Ed25519 key that signs the licences of the Subrep desktop app
156     # (base64url of the 32-byte private key). Without it, Subrep Pro is not
157     # for sale. Make it on the server with tools/subrep_key.py: every licence
158     # of every customer depends on it, so never replace it.
159     subrep_license_key: str = ""
160 
161     # --- uploads -----------------------------------------------------------
162     max_upload_bytes: int = 2 * 1024 * 1024 * 1024  # 2 GiB
163     # During the beta, a job in the browser sends a copy of its audio and its
164     # book to the server, for debugging, as the privacy and user data policy
165     # says (terms.html, "Input data kept for debugging"). Set false after the
166     # beta, and change that policy and the page at the same time.
167     browser_copy: bool = True
168 
169     @property
170     def payments_configured(self) -> bool:
171         return bool(self.stripe_secret_key)
172 
173     @property
174     def play_configured(self) -> bool:
175         # Do not use Path.is_file here. On Python 3.11, it raises
176         # PermissionError when the service user cannot open the folder of the
177         # key, and then each GET /api/captions answers 500. os.path.isfile
178         # gives False for each error.
179         path = self.play_service_account_file
180         return bool(path) and os.path.isfile(path) and os.access(path, os.R_OK)
181 
182     @property
183     def email_configured(self) -> bool:
184         return bool(self.smtp_host)
185 
186     @property
187     def sign_in_available(self) -> bool:
188         return self.email_configured or self.dev_login_links
189 
190     @property
191     def resolved_database_url(self) -> str:
192         if self.database_url:
193             return self.database_url
194         return f"sqlite:///{(self.data_dir / 'subplz.db').as_posix()}"
195 
196 
197 settings = Settings()
198 settings.data_dir.mkdir(parents=True, exist_ok=True)