Files
UmbraAudio/umbraaudio/web/__init__.py
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
The review UI's language dropdown did nothing: every selection kept
rendering the English text. Two independent causes, both fixed.

1. The translations were never being read. The pipeline's
   `utterance_translations` table holds one row in Deal Hunter, and that
   row stores the *English* text under a `zh-Hans` code. The real
   translations live in the on-disk sample files under
   `samples/translations/`. `umbraaudio/translations.py` now resolves both
   sources and merges them by (chapter_id, utterance_index): the table wins
   where it holds a real translation, the sample files fill the gaps. A
   candidate whose text is identical to the source is a placeholder, not a
   translation, so it is ignored - otherwise the page would show English
   under a Chinese heading and claim it was translated.

2. The query that read them was broken. `language_routes.py` selected
   `u.index` and `u.speaker`; the columns are `utterance_index` and
   `speaker_name`, so every read raised `sqlite3.OperationalError: near
   "index": syntax error` and `/language-mockup` returned 500. It also
   opened `<project>/project.db` - a 0-byte decoy - instead of
   `<project>/.umbra/project.db`, the file that actually holds the data.

Also in this commit:

* The chapter review page honours `?lang=`, remembers the choice in a
  cookie (set by a middleware, so it survives on every page), and shows the
  selected language's *name* in the "has not been translated yet" message.
* The page now shows the translation, not the source, and the edit box is
  bound to the source text so saving cannot overwrite the English with the
  Chinese.
* The stray language selector that sat after `</html>` in base.html is
  gone; the chapters-list page's dropdown had zero options because the
  route never passed the language list.
* The language tag is wired into generation: `utterance.language_code`
  (NULL = project source language) reaches `engine.generate()` and the
  staleness marker, so a retagged line is re-rendered instead of keeping
  English phonology behind an unchanged text. The marker records the
  language; a marker written before the tag existed means English, so the
  ~4,300 existing clips are not invalidated.
* `project.source_language` (spec 31 section 13.1) records the book's own
  language, with a migration for existing project DBs.
* A scene-break marker deliberately records no language: it is silence,
  identical in every language, and tagging it would claim a phonology the
  audio does not have.

Tests: 883 passed / 8 failed - the same 8 pre-existing failures
(2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests
cover the resolver and the generation wiring. ruff introduces no new
findings.
2026-11-06 16:55:49 -08:00

165 lines
6.6 KiB
Python

from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
if TYPE_CHECKING:
from umbraaudio.config import AppConfig
_app: FastAPI | None = None
def create_app(config: AppConfig) -> FastAPI:
global _app
if _app is not None:
return _app
logger = logging.getLogger(__name__)
app = FastAPI(title="UmbraAudio Review", version="0.2.0")
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
from umbraaudio.config import list_projects
from umbraaudio.pipeline_manager import PipelineManager
from umbraaudio.voices.generation_manager import GenerationManager
from umbraaudio.web.language_routes import router as language_router
from umbraaudio.web.name_token_routes import router as name_token_router
from umbraaudio.web.routes import NoActiveProjectError, router
from umbraaudio.web.voice_queue_routes import router as voice_queue_router
app.include_router(router)
# Language selection UI (spec 30/31): reads from project's
# utterance_translations table, renders per-language chapter audio
# under <project>/Lang_test/<CODE>.
app.include_router(language_router)
# Spec 17 Phase 3: orphan name-token review queue lives in its own module so
# the UI never has to be merged into the hot routes.py.
app.include_router(name_token_router)
# Voice-regeneration queue. Separate module for the same reason, and so the
# eventual controller can drive the same endpoints remotely.
app.include_router(voice_queue_router)
# ---------------------------------------------------------------- auth gate
# Installed as middleware rather than per-route dependencies so that a newly
# added route is protected by default: forgetting to decorate a handler is
# not a way to accidentally expose the UI.
#
# Fail-CLOSED: any error while checking the session denies the request. An
# exception here must never fall through to "no session required", because
# that would turn a bug into an open door.
from umbraaudio import auth
from umbraaudio.web.auth_routes import router as auth_router
app.include_router(auth_router)
@app.middleware("http")
async def _auth_gate(request: Request, call_next):
path = request.url.path
if (
path in auth.PUBLIC_PATHS
or path.startswith(auth.PUBLIC_PREFIXES)
or request.method == "OPTIONS"
):
return await call_next(request)
try:
user = auth.verify_session(request.cookies.get(auth.COOKIE_NAME))
except Exception:
logger.exception("auth: session check failed; denying request")
user = None
if not user:
# API and HTMX callers get a status they can act on; browsers get
# sent to the login page.
wants_json = request.headers.get("hx-request") or (
"application/json" in (request.headers.get("accept") or "")
and "text/html" not in (request.headers.get("accept") or "")
)
if wants_json:
return JSONResponse(
{"detail": "Not authenticated.", "redirect": "/login"},
status_code=401,
)
return RedirectResponse(url="/login", status_code=303)
# First login is forced through a password change before any other page.
state = auth.password_state()
if state["must_change"] and path not in ("/change-password", "/api/change-password", "/api/logout"):
if path.startswith("/static/"):
return await call_next(request)
return RedirectResponse(url="/change-password", status_code=303)
return await call_next(request)
# ---------------------------------------------------------------- language
# The content-language selector is in the page header, so it appears on
# every page. Honouring ``?lang=`` here rather than in each route means
# the choice is remembered in a cookie on every page, not just the ones
# that happen to read the query parameter.
from umbraaudio.web.language_routes import SUPPORTED as _SUPPORTED_LANGUAGES
language_codes = {code for code, _ in _SUPPORTED_LANGUAGES}
@app.middleware("http")
async def _language_cookie(request: Request, call_next):
response = await call_next(request)
requested = (request.query_params.get("lang") or "").strip()
if requested in language_codes:
response.set_cookie(
"active_language",
requested,
max_age=31536000,
path="/",
httponly=False,
)
return response
# Spec 17 §3.1/§3.2: speaker identification confidence. Exposed as a template
# global (like active_project) so the speakers page can show a
# clear / needs-review designation for every speaker without touching the
# hot routes.py rendering code.
from umbraaudio.analysis.speaker_confidence import load_speaker_assessments
from umbraaudio.web.routes import templates as _templates
def _speaker_designations_for_template(request) -> dict:
try:
slug = request.cookies.get("active_project")
if not slug:
return {}
return load_speaker_assessments(Path(slug) / ".umbra")
except Exception:
return {}
_templates.env.globals["speaker_designations"] = _speaker_designations_for_template
@app.exception_handler(NoActiveProjectError)
async def _no_active_project_handler(request: Request, exc: NoActiveProjectError):
"""HTML pages go to the project picker; API callers get a 400 error."""
accept = (request.headers.get("accept") or "").lower()
if "text/html" in accept:
return RedirectResponse(url="/projects", status_code=303)
return JSONResponse(
{"detail": str(exc), "redirect": "/projects"}, status_code=400
)
app.state.config = config
app.state.projects = list_projects()
if config.data_dir and config.data_dir.exists() and config.data_dir.name != ".umbra":
app.state.pipeline_manager = PipelineManager(config.data_dir)
app.state.generation_manager = GenerationManager(config.data_dir)
else:
app.state.pipeline_manager = None
app.state.generation_manager = None
_app = app
return app