Initial commit: InkFlow — EPUB vers livre audio local (MLX/Kokoro)
This commit is contained in:
0
backend/inkflow/api/__init__.py
Normal file
0
backend/inkflow/api/__init__.py
Normal file
295
backend/inkflow/api/app.py
Normal file
295
backend/inkflow/api/app.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""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")
|
||||
47
backend/inkflow/api/ws.py
Normal file
47
backend/inkflow/api/ws.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Gestionnaire de connexions WebSocket avec diffusion thread-safe.
|
||||
|
||||
L'orchestrateur tourne dans un thread worker ; il appelle `broadcast_threadsafe`
|
||||
qui replanifie l'envoi sur la boucle asyncio de l'API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self.active: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop = loop
|
||||
|
||||
async def connect(self, slug: str, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
self.active[slug].add(ws)
|
||||
|
||||
def disconnect(self, slug: str, ws: WebSocket) -> None:
|
||||
self.active[slug].discard(ws)
|
||||
|
||||
def broadcast_threadsafe(self, slug: str, data: dict) -> None:
|
||||
"""Appelable depuis n'importe quel thread (worker orchestrateur)."""
|
||||
if self._loop is None:
|
||||
return
|
||||
self._loop.call_soon_threadsafe(self._dispatch, slug, data)
|
||||
|
||||
def _dispatch(self, slug: str, data: dict) -> None:
|
||||
for ws in list(self.active.get(slug, ())):
|
||||
asyncio.create_task(self._safe_send(slug, ws, data))
|
||||
|
||||
async def _safe_send(self, slug: str, ws: WebSocket, data: dict) -> None:
|
||||
try:
|
||||
await ws.send_json({"type": "state", "state": data})
|
||||
except Exception: # noqa: BLE001 — connexion fermee
|
||||
self.disconnect(slug, ws)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
Reference in New Issue
Block a user