siftlode/backend/app/config.py

113 lines
4.8 KiB
Python
Raw Normal View History

from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Shipped placeholder; production must override it. Used by the startup guard below.
_DEFAULT_SECRET_KEY = "change-me-session-key"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Siftlode"
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
# Session cookie signing key.
secret_key: str = _DEFAULT_SECRET_KEY
# Fernet key (urlsafe base64, 32 bytes) for encrypting stored refresh tokens.
token_encryption_key: str = ""
google_client_id: str = ""
google_client_secret: str = ""
oauth_redirect_url: str = "http://localhost:8080/auth/callback"
# Comma-separated invite list / admin list of Google account emails.
allowed_emails: str = ""
admin_emails: str = ""
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
frontend_origin: str = ""
# --- Outbound email (onboarding: access-request + approval notices) ---
# Gmail SMTP + App Password by default. All optional: if unset, email is skipped
# (fail-soft) and onboarding still works via in-app notifications.
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "" # e.g. "Siftlode <addr@gmail.com>"; falls back to smtp_user
# --- Sync / YouTube Data API ---
# Optional API key for public reads (channels/videos/playlistItems). When set it is
# preferred for shared backfill/enrichment so it doesn't depend on a specific user.
youtube_api_key: str = ""
# Daily quota budget (units). Default leaves headroom under the 10,000/day free limit.
quota_daily_budget: int = 9000
# Recent-first backfill: how far back to fetch on the first pass per channel.
backfill_recent_max_videos: int = 100
backfill_recent_max_days: int = 365
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
# this duration (and not livestreams) are probed; longer videos are never Shorts.
shorts_probe_max_seconds: int = 180
shorts_probe_batch: int = 150
shorts_probe_interval_minutes: int = 2
# videos.list accepts up to 50 ids per call.
enrich_batch_size: int = 50
# --- Background scheduler ---
scheduler_enabled: bool = True
rss_poll_minutes: int = 20
enrich_interval_minutes: int = 3
backfill_interval_minutes: int = 10
# Keep this many quota units in reserve so scheduled backfill never starves
# interactive syncs / enrichment of fresh videos.
backfill_quota_reserve: int = 2000
# --- Auto-tagging / feed defaults ---
# Number of recent video titles sampled per channel for language detection.
autotag_title_sample: int = 40
autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360
# live_status values hidden from the feed by default. Completed-stream VODs
# ("was_live") are real watchable content and stay visible.
feed_default_hidden_live: str = "live,upcoming"
@property
def allowed_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
@property
def admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
@property
def session_https_only(self) -> bool:
"""Mark the session cookie Secure when we're served over HTTPS. We treat an
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
return self.oauth_redirect_url.lower().startswith("https://")
@model_validator(mode="after")
def _enforce_production_secrets(self) -> "Settings":
"""Fail fast rather than boot a public instance with the shipped placeholder
signing key (which would let anyone forge an admin session) or without token
encryption. Local dev (http redirect URL) keeps the convenient defaults."""
if not self.session_https_only:
return self # local/dev: allow the placeholder defaults
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < 32:
raise ValueError(
"SECRET_KEY must be a unique random value of at least 32 chars in "
"production (an https OAUTH_REDIRECT_URL was detected). Generate one "
'with: python -c "import secrets; print(secrets.token_urlsafe(48))"'
)
if not self.token_encryption_key:
raise ValueError(
"TOKEN_ENCRYPTION_KEY must be set in production so refresh tokens are "
"encrypted at rest. Generate one with: "
'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
return self
settings = Settings()