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.
165 lines
6.6 KiB
Python
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
|