Files
InkFlow/backend/inkflow/api/app.py

296 lines
9.1 KiB
Python

"""Application FastAPI : pilote le pipeline et sert l'UI.
Toutes les routes lourdes (analyse, casting, rendu) sont *enfilees* dans
l'orchestrateur et rendent la main immediatement ; l'avancement arrive par
WebSocket. Les operations rapides (preview de voix) tournent dans un threadpool.
"""
from __future__ import annotations
import asyncio
import io
from pathlib import Path
from typing import Optional
import soundfile as sf
from fastapi import FastAPI, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from ..config import DATA_DIR, book_data_dir, book_output_dir, ensure_dirs
from ..epub.parser import load_book, load_chapter_text, parse_epub
from ..models import Cast, ChapterAnalysis, Pronunciation
from ..pipeline.orchestrator import load_state, orchestrator
from ..settings import Settings, get_settings, save_settings
from ..store import artifacts
from ..util import slugify
from .ws import manager
app = FastAPI(title="InkFlow API")
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)
@app.on_event("startup")
async def _startup() -> None:
ensure_dirs()
manager.bind_loop(asyncio.get_running_loop())
orchestrator.set_broadcaster(manager.broadcast_threadsafe)
# --- Helpers -----------------------------------------------------------------
def _list_book_slugs() -> list[str]:
if not DATA_DIR.exists():
return []
return sorted(p.parent.name for p in DATA_DIR.glob("*/book.json"))
def _book_summary(slug: str) -> dict:
book = load_book(slug)
state = load_state(slug)
rendered = sum(1 for r in state.render.values() if r.mp3)
return {
"slug": slug,
"title": book.title,
"author": book.author,
"chapters": len(book.render_chapters),
"rendered": rendered,
"cover": f"/api/books/{slug}/cover" if book.cover_file else None,
}
# --- Bibliotheque / upload ---------------------------------------------------
@app.get("/api/books")
def list_books() -> list[dict]:
return [_book_summary(s) for s in _list_book_slugs()]
@app.post("/api/books")
async def upload_book(file: UploadFile) -> dict:
ensure_dirs()
uploads = DATA_DIR / "_uploads"
uploads.mkdir(parents=True, exist_ok=True)
dest = uploads / (file.filename or "livre.epub")
dest.write_bytes(await file.read())
book = await asyncio.to_thread(parse_epub, dest)
# Initialise l'etat.
load_state(book.slug)
return {"slug": book.slug, "title": book.title}
@app.get("/api/books/{slug}")
def get_book(slug: str) -> dict:
_require(slug)
book = load_book(slug)
return {"book": book.model_dump(mode="json"),
"state": load_state(slug).model_dump(mode="json")}
@app.get("/api/books/{slug}/cover")
def get_cover(slug: str):
book = load_book(slug)
if not book.cover_file:
raise HTTPException(404, "pas de couverture")
return FileResponse(str(book_data_dir(slug) / book.cover_file))
@app.get("/api/books/{slug}/chapters/{index}")
def get_chapter(slug: str, index: int) -> dict:
_require(slug)
book = load_book(slug)
ch = next((c for c in book.chapters if c.index == index), None)
if ch is None:
raise HTTPException(404, "chapitre inconnu")
out: dict = {"chapter": ch.model_dump(mode="json")}
apath = artifacts.analysis_path(slug, index)
if apath.exists():
out["analysis"] = artifacts.load_analysis(slug, index).model_dump(mode="json")
elif ch.text_file:
out["text"] = load_chapter_text(slug, ch).model_dump(mode="json")
return out
@app.put("/api/books/{slug}/chapters/{index}/analysis")
def put_analysis(slug: str, index: int, analysis: ChapterAnalysis) -> dict:
_require(slug)
if analysis.index != index:
raise HTTPException(400, "index incoherent")
artifacts.save_analysis(slug, analysis)
return {"saved": True}
# --- Etapes (enfilees) -------------------------------------------------------
class ChaptersBody(BaseModel):
chapters: Optional[list[int]] = None
@app.post("/api/books/{slug}/analyze")
def analyze(slug: str, body: ChaptersBody) -> dict:
_require(slug)
orchestrator.run_analyze(slug, body.chapters)
return {"queued": True}
@app.post("/api/books/{slug}/pronounce")
def pronounce(slug: str) -> dict:
_require(slug)
orchestrator.run_pronounce(slug)
return {"queued": True}
@app.post("/api/books/{slug}/cast/auto")
def cast_auto(slug: str) -> dict:
_require(slug)
orchestrator.run_cast(slug)
return {"queued": True}
@app.post("/api/books/{slug}/cast/analyze")
def cast_analyze(slug: str, body: ChaptersBody) -> dict:
"""(Re)analyse le casting d'un/des chapitre(s) avec reconciliation."""
_require(slug)
orchestrator.run_cast_analyze(slug, body.chapters)
return {"queued": True}
@app.post("/api/books/{slug}/cast/dedup")
def cast_dedup(slug: str) -> dict:
"""Deduplique le casting existant (variantes de noms -> aliases)."""
_require(slug)
orchestrator.run_dedup_cast(slug)
return {"queued": True}
class RenderBody(BaseModel):
chapters: list[int]
backend: Optional[str] = None
mono: bool = False
@app.post("/api/books/{slug}/render")
def render(slug: str, body: RenderBody) -> dict:
_require(slug)
orchestrator.run_render(slug, body.chapters, backend=body.backend, mono=body.mono)
return {"queued": True}
# --- Casting / prononciation (lecture-ecriture directe) ----------------------
@app.get("/api/books/{slug}/cast")
def get_cast(slug: str) -> dict:
from ..casting.voicebank import load_voicebank
_require(slug)
return {"cast": artifacts.load_cast(slug).model_dump(mode="json"),
"voicebank": load_voicebank().model_dump(mode="json")}
@app.put("/api/books/{slug}/cast")
def put_cast(slug: str, cast: Cast) -> dict:
_require(slug)
artifacts.save_cast(slug, cast)
return {"saved": True}
@app.get("/api/books/{slug}/pronunciation")
def get_pron(slug: str) -> dict:
_require(slug)
return artifacts.load_pronunciation(slug).model_dump(mode="json")
@app.put("/api/books/{slug}/pronunciation")
def put_pron(slug: str, pron: Pronunciation) -> dict:
_require(slug)
artifacts.save_pronunciation(slug, pron)
return {"saved": True}
# --- Reglages techniques globaux ---------------------------------------------
@app.get("/api/settings")
def read_settings() -> dict:
return get_settings().model_dump(mode="json")
@app.put("/api/settings")
def write_settings(settings: Settings) -> dict:
save_settings(settings)
return {"saved": True}
# --- Voicebank + preview -----------------------------------------------------
@app.get("/api/voicebank")
def get_voicebank() -> dict:
from ..casting.voicebank import load_voicebank
return load_voicebank().model_dump(mode="json")
class PreviewBody(BaseModel):
voice_id: str
text: str = "Bonjour, voici un aperçu de cette voix pour votre livre audio."
@app.post("/api/voicebank/preview")
async def preview_voice(body: PreviewBody):
from ..casting.voicebank import load_voicebank
from ..tts.base import VoiceSpec
entry = load_voicebank().by_id(body.voice_id)
if entry is None:
raise HTTPException(404, "voix inconnue")
def _synth() -> bytes:
from ..tts.factory import get_backend
backend = get_backend("kokoro")
audio, sr = backend.synthesize(body.text, VoiceSpec(preset=entry.kokoro_voice))
buf = io.BytesIO()
sf.write(buf, audio, sr, format="WAV")
return buf.getvalue()
data = await asyncio.to_thread(_synth)
return Response(content=data, media_type="audio/wav")
@app.get("/api/books/{slug}/audio/{index}")
def get_audio(slug: str, index: int):
state = load_state(slug)
rs = state.render.get(index)
if not rs or not rs.mp3:
raise HTTPException(404, "audio non genere")
path = book_output_dir(load_book(slug).title) / rs.mp3
if not path.exists():
raise HTTPException(404, "fichier introuvable")
return FileResponse(str(path), media_type="audio/mpeg", filename=rs.mp3)
# --- WebSocket ---------------------------------------------------------------
@app.websocket("/ws/{slug}")
async def ws_endpoint(ws: WebSocket, slug: str) -> None:
await manager.connect(slug, ws)
try:
# Envoi de l'etat courant a la connexion.
await ws.send_json({"type": "state", "state": load_state(slug).model_dump(mode="json")})
while True:
await ws.receive_text() # garde la connexion ouverte
except WebSocketDisconnect:
manager.disconnect(slug, ws)
except Exception: # noqa: BLE001
manager.disconnect(slug, ws)
def _require(slug: str) -> None:
if not (book_data_dir(slug) / "book.json").exists():
raise HTTPException(404, "livre inconnu")
# --- Service du frontend build (si present) ----------------------------------
_FRONTEND_DIST = Path(__file__).resolve().parents[2].parent / "frontend" / "dist"
if _FRONTEND_DIST.exists():
app.mount("/", StaticFiles(directory=str(_FRONTEND_DIST), html=True), name="ui")