"""Carli Daily — FastAPI backend for the Hugging Face Space. Serves the mobile web app, exercise data pulled from a private HF Storage Bucket, and Spanish text-to-speech generated with facebook/mms-tts-spa. """ import hashlib import io import json import logging import os import re import threading import wave from collections import OrderedDict import requests from fastapi import FastAPI, HTTPException, Query, Request from fastapi.responses import JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel logging.basicConfig(level=logging.INFO) log = logging.getLogger("carli-daily") APP_PASSWORD = os.environ.get("APP_PASSWORD", "") HF_TOKEN = os.environ.get("HF_TOKEN", "") DATA_BUCKET = os.environ.get("DATA_BUCKET", "jeffboudier/carli-daily-data") TOTAL_DAYS = 42 TTS_MODEL_ID = "facebook/mms-tts-spa" app = FastAPI(title="Carli Daily") # ---------------------------------------------------------------- auth def session_token() -> str: return hashlib.sha256(f"carli-v1|{APP_PASSWORD}".encode()).hexdigest() def check_auth(request: Request) -> None: if not APP_PASSWORD: return # local dev without secrets if request.headers.get("x-carli-token") != session_token(): raise HTTPException(status_code=401, detail="No autorizado") class LoginBody(BaseModel): password: str @app.post("/api/login") def login(body: LoginBody): if not APP_PASSWORD: return {"token": "dev"} if body.password.strip().lower() != APP_PASSWORD.strip().lower(): raise HTTPException(status_code=403, detail="wrong password") return {"token": session_token()} # ---------------------------------------------------------------- day data _days: dict[int, dict] = {} _days_lock = threading.Lock() def _fetch_day(n: int) -> dict | None: url = f"https://huggingface.co/buckets/{DATA_BUCKET}/resolve/data/day{n:02d}.json" headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} try: r = requests.get(url, headers=headers, timeout=20) if r.status_code == 200: return r.json() log.warning("day %s fetch failed: HTTP %s", n, r.status_code) except Exception as e: # noqa: BLE001 log.warning("day %s fetch failed: %s", n, e) return None def get_day(n: int) -> dict | None: with _days_lock: if n in _days: return _days[n] data = _fetch_day(n) if data is not None: with _days_lock: _days[n] = data return data def _warm_days() -> None: for n in range(1, TOTAL_DAYS + 1): get_day(n) log.info("day cache warmed: %d/%d", len(_days), TOTAL_DAYS) _ensure_progress_loaded() threading.Thread(target=_warm_days, daemon=True).start() @app.get("/api/days") def list_days(request: Request): check_auth(request) out = [] for n in range(1, TOTAL_DAYS + 1): d = get_day(n) if d is None: out.append({"day": n, "missing": True}) else: out.append({ "day": d["day"], "date": d["date"], "title": d["title"], "emoji": d.get("emoji", "🇪🇸"), "theme_en": d.get("theme_en", ""), "tense_name": d.get("tense", {}).get("name", ""), }) return out @app.get("/api/day/{n}") def day_detail(n: int, request: Request): check_auth(request) if not 1 <= n <= TOTAL_DAYS: raise HTTPException(status_code=404, detail="no such day") d = get_day(n) if d is None: raise HTTPException(status_code=503, detail="day data unavailable") return d # ---------------------------------------------------------------- progress # Completed days are persisted server-side in the data bucket so progress # survives across devices/browsers (localStorage on the phone is only a cache). PROGRESS_PATH = "progress.json" _progress: set[int] = set() _progress_loaded = False _progress_lock = threading.Lock() def _ensure_progress_loaded() -> None: global _progress, _progress_loaded with _progress_lock: if _progress_loaded: return url = f"https://huggingface.co/buckets/{DATA_BUCKET}/resolve/{PROGRESS_PATH}" headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} try: r = requests.get(url, headers=headers, timeout=20) if r.status_code == 200: _progress = {int(x) for x in r.json().get("done", [])} _progress_loaded = True elif r.status_code == 404: _progress_loaded = True else: log.warning("progress fetch failed: HTTP %s", r.status_code) except Exception as e: # noqa: BLE001 log.warning("progress fetch failed: %s", e) def _save_progress() -> None: from huggingface_hub import HfApi payload = json.dumps({"done": sorted(_progress)}).encode() HfApi(token=HF_TOKEN or None).batch_bucket_files( DATA_BUCKET, add=[(payload, PROGRESS_PATH)] ) class ProgressBody(BaseModel): days: list[int] @app.get("/api/progress") def get_progress(request: Request): check_auth(request) _ensure_progress_loaded() return {"done": sorted(_progress)} @app.post("/api/progress") def post_progress(body: ProgressBody, request: Request): check_auth(request) _ensure_progress_loaded() valid = {d for d in body.days if 1 <= d <= TOTAL_DAYS} with _progress_lock: new = valid - _progress if new: _progress.update(new) try: _save_progress() except Exception as e: # noqa: BLE001 log.warning("progress save failed: %s", e) return {"done": sorted(_progress)} # ---------------------------------------------------------------- TTS _tts_lock = threading.Lock() _tts_model = None _tts_tokenizer = None _tts_cache: OrderedDict[str, bytes] = OrderedDict() TTS_CACHE_MAX = 600 def _load_tts(): global _tts_model, _tts_tokenizer if _tts_model is None: log.info("loading TTS model %s ...", TTS_MODEL_ID) import torch # noqa: F401 from transformers import AutoTokenizer, VitsModel _tts_model = VitsModel.from_pretrained(TTS_MODEL_ID) _tts_model.eval() _tts_tokenizer = AutoTokenizer.from_pretrained(TTS_MODEL_ID) log.info("TTS model ready") def synthesize(text: str) -> bytes: import torch _load_tts() inputs = _tts_tokenizer(text, return_tensors="pt") with torch.no_grad(): output = _tts_model(**inputs) waveform = output.waveform[0].cpu().numpy() pcm = (waveform * 32767).clip(-32768, 32767).astype(" TTS_CACHE_MAX: _tts_cache.popitem(last=False) return Response(content=audio, media_type="audio/wav", headers={"Cache-Control": "private, max-age=86400"}) @app.get("/api/health") def health(): return JSONResponse({"ok": True, "days_cached": len(_days)}) # static frontend (must be mounted last) app.mount("/", StaticFiles(directory="static", html=True), name="static")