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)