- Add cover image CRUD endpoints: GET/POST/DELETE /api/projects/{id}/cover
- Add output generation endpoints: POST /api/projects/{id}/output/{format}
- Add cover image and output queue service methods
- Add CoverImageResponse and CoverImageUploadResponse schemas
- Add umbra-api.js request handling for FormData (cover uploads)
- Add output generation buttons (EPUB/PDF/Audio) to Format UI in umbra-app.js
- Add backtranslate.py tool for verifying translation quality via back-translation
- Add cover image support to render.py EPUB generation
- Copy cover image to deal-hunter-zh source directory
- Spanish translation pipeline started (running in background)
260 lines
7.5 KiB
Python
260 lines
7.5 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""``/api`` controller-integration endpoints for the UmbraAudio node.
|
|
|
|
These endpoints let the UmbraAudio system (audio node) push data into the
|
|
UmbraTrans controller and retrieve work state back. They are separate from
|
|
``/api/workspace`` (the member's dashboard view) and ``/api/uadmin`` (the
|
|
administrator's view) because they serve a machine-to-machine integration
|
|
contract rather than a human UI.
|
|
|
|
Endpoints:
|
|
|
|
* ``POST /api/orgs`` — create an organization
|
|
* ``POST /api/programs`` — create a program (project)
|
|
* ``POST /api/speakers/sync`` — sync speakers from the audio node's speakers.json
|
|
* ``POST /api/projects/{id}/translations`` — bulk upsert utterance translations
|
|
* ``GET /api/projects/{id}/translations/{language_code}`` — get translations
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
|
|
from .app import get_service, require_principal
|
|
from .models import Principal
|
|
from .schemas import (
|
|
CoverImageResponse,
|
|
OrganizationCreateResponse,
|
|
OrganizationCreateRequest,
|
|
ProgramCreateResponse,
|
|
ProgramCreateRequest,
|
|
SpeakerSyncResponse,
|
|
SpeakerSyncRequest,
|
|
UtteranceTranslationListResponse,
|
|
UtteranceTranslationRequest,
|
|
)
|
|
from .service import AccountService
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.post("/orgs", response_model=OrganizationCreateResponse)
|
|
def create_organization(
|
|
payload: OrganizationCreateRequest,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> OrganizationCreateResponse:
|
|
"""Create an organization. Platform administrator only."""
|
|
|
|
service: AccountService = get_service(request)
|
|
return service.create_organization(
|
|
principal,
|
|
name=payload.name,
|
|
slug=payload.slug,
|
|
)
|
|
|
|
|
|
@router.post("/programs", response_model=ProgramCreateResponse)
|
|
def create_program(
|
|
payload: ProgramCreateRequest,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> ProgramCreateResponse:
|
|
"""Create a program (project) inside an organization."""
|
|
|
|
service: AccountService = get_service(request)
|
|
return service.create_program(
|
|
principal,
|
|
organization_id=payload.organization_id,
|
|
name=payload.name,
|
|
slug=payload.slug,
|
|
source_language_tag=payload.source_language_tag,
|
|
)
|
|
|
|
|
|
@router.post("/speakers/sync", response_model=SpeakerSyncResponse)
|
|
def sync_speakers(
|
|
payload: SpeakerSyncRequest,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> SpeakerSyncResponse:
|
|
"""Sync speaker data from the audio node's speakers.json.
|
|
|
|
The audio node posts its full speakers.json payload. The controller
|
|
stores it in the translations directory's speakers.json (for the
|
|
file-based pipeline) and in the in-memory job store (for cross-system
|
|
consistency).
|
|
"""
|
|
|
|
from uuid import UUID
|
|
|
|
service: AccountService = get_service(request)
|
|
# The speaker sync targets the project that the caller's session is
|
|
# scoped to. The caller must have selected a project context; we derive
|
|
# it from the workspace snapshot.
|
|
snapshot = service.workspace(principal)
|
|
projects = snapshot.projects
|
|
if not projects:
|
|
from .errors import NotFoundError
|
|
raise NotFoundError("project_not_found")
|
|
# Use the first accessible project as the sync target. The caller should
|
|
# target a specific project; this is a convenience for single-project
|
|
# deployments.
|
|
project = projects[0]
|
|
result = service.sync_speakers(
|
|
principal,
|
|
project.id,
|
|
[s.model_dump() for s in payload.speakers],
|
|
)
|
|
return SpeakerSyncResponse(
|
|
project_id=project.id,
|
|
created=result["created"],
|
|
updated=result["updated"],
|
|
aliases_added=result["aliases_added"],
|
|
)
|
|
|
|
|
|
@router.post("/projects/{project_id}/translations", response_model=dict)
|
|
def upsert_utterance_translations(
|
|
project_id: str,
|
|
payload: UtteranceTranslationRequest,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> dict:
|
|
"""Bulk upsert utterance translations for one language.
|
|
|
|
The audio node posts translated text for every utterance in a chapter
|
|
or book. The controller stores it keyed by (utterance_id, language_code)
|
|
so the review UI can display it.
|
|
"""
|
|
|
|
from uuid import UUID
|
|
|
|
service: AccountService = get_service(request)
|
|
upserted = service.upsert_utterance_translations(
|
|
principal,
|
|
UUID(str(project_id)),
|
|
payload.language_code,
|
|
payload.language_name,
|
|
[t.model_dump() for t in payload.translations],
|
|
)
|
|
return {"ok": True, "upserted": upserted}
|
|
|
|
|
|
@router.get(
|
|
"/projects/{project_id}/translations/{language_code}",
|
|
response_model=UtteranceTranslationListResponse,
|
|
)
|
|
def get_utterance_translations(
|
|
project_id: str,
|
|
language_code: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> UtteranceTranslationListResponse:
|
|
"""Get all utterance translations for one project and language."""
|
|
|
|
from uuid import UUID
|
|
|
|
service: AccountService = get_service(request)
|
|
return service.get_utterance_translations(
|
|
principal,
|
|
UUID(str(project_id)),
|
|
language_code,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/projects/{project_id}/cover",
|
|
response_model=CoverImageResponse,
|
|
)
|
|
def get_cover_image(
|
|
project_id: str,
|
|
request: Request,
|
|
language_tag: str | None = None,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> CoverImageResponse:
|
|
"""Get cover image info for a project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.get_cover_image(
|
|
principal,
|
|
UUID(str(project_id)),
|
|
language_tag,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/projects/{project_id}/cover",
|
|
response_model=CoverImageResponse,
|
|
)
|
|
async def set_cover_image(
|
|
project_id: str,
|
|
request: Request,
|
|
language_tag: str | None = None,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> CoverImageResponse:
|
|
"""Save cover image for a project. Uploads image/png data in the request body."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
image_data = await request.body()
|
|
return service.set_cover_image(
|
|
principal,
|
|
UUID(str(project_id)),
|
|
language_tag,
|
|
image_data,
|
|
content_type=request.headers.get("content-type", "image/png"),
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/projects/{project_id}/cover",
|
|
response_model=CoverImageResponse,
|
|
)
|
|
def delete_cover_image(
|
|
project_id: str,
|
|
request: Request,
|
|
language_tag: str | None = None,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> CoverImageResponse:
|
|
"""Delete cover image for a project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.delete_cover_image(
|
|
principal,
|
|
UUID(str(project_id)),
|
|
language_tag,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/projects/{project_id}/output/{format}",
|
|
response_model=dict,
|
|
)
|
|
async def queue_output_generation(
|
|
project_id: str,
|
|
format: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> dict:
|
|
"""Queue output generation (EPUB, PDF, or audio) for a language project."""
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
body = await request.json()
|
|
language_tag = body.get("language_tag")
|
|
|
|
return service.queue_output(
|
|
principal,
|
|
UUID(str(project_id)),
|
|
language_tag,
|
|
format,
|
|
)
|