# 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, )