first commit: TTS Book Service with MiMo TTS integration
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# 小米 MiMo TTS API Key(必填)
|
||||
MIMO_API_KEY=your_api_key_here
|
||||
|
||||
# 以下为可选配置(有默认值)
|
||||
# MIMO_API_ENDPOINT=https://api.xiaomimimo.com/v1/chat/completions
|
||||
# MIMO_TTS_MODEL=mimo-v2-audio-tts
|
||||
# MIMO_VOICE=mimo_default
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM python:3.11-alpine
|
||||
|
||||
# 安装 ffmpeg(alpine 版很小)
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ .
|
||||
|
||||
RUN mkdir -p /app/data /app/audio
|
||||
|
||||
EXPOSE 17200
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "17200"]
|
||||
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 📚 TTS Book Service
|
||||
|
||||
基于**小米 MiMo TTS**的听书音频转换服务,提供 Web 管理界面和听书 App 音频接入接口。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
听书 App ──HTTP──▶ 本服务 ──API──▶ 小米 MiMo TTS
|
||||
│
|
||||
├── SQLite (书籍/章节管理)
|
||||
└── MP3 文件缓存
|
||||
```
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 1. 配置 API Key
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填入你的 MIMO_API_KEY
|
||||
|
||||
# 2. 启动
|
||||
docker compose up -d
|
||||
|
||||
# 3. 访问管理界面
|
||||
# http://your-server:17200
|
||||
```
|
||||
|
||||
## 功能
|
||||
|
||||
### Web 管理界面 (`/`)
|
||||
- 📖 书籍管理(添加/删除)
|
||||
- 📑 章节管理(添加/编辑/删除)
|
||||
- 🎙️ TTS 试听(支持风格设置)
|
||||
- ⚡ 单章/批量音频生成
|
||||
- ⚙️ 配置查看
|
||||
|
||||
### 听书 App 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/book/{book_id}` | GET | 获取书籍信息和章节列表 |
|
||||
| `/api/book/{book_id}/chapter/{chapter_id}/audio` | GET | 下载章节 MP3 音频 |
|
||||
|
||||
### 管理 API
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/admin/api/books` | GET/POST | 书籍列表/创建 |
|
||||
| `/admin/api/books/{book_id}` | DELETE | 删除书籍 |
|
||||
| `/admin/api/books/{book_id}/chapters` | GET/POST | 章节列表/创建 |
|
||||
| `/admin/api/books/{book_id}/chapters/{id}` | PUT/DELETE | 更新/删除章节 |
|
||||
| `/admin/api/books/{book_id}/chapters/{id}/generate` | POST | 生成单章音频 |
|
||||
| `/admin/api/books/{book_id}/generate-all` | POST | 批量生成 |
|
||||
| `/admin/api/tts/preview` | POST | TTS 试听 |
|
||||
| `/admin/api/config` | GET | 查看配置 |
|
||||
|
||||
## 接入听书 App
|
||||
|
||||
1. 在管理界面添加书籍和章节
|
||||
2. 为章节生成音频
|
||||
3. 在听书 App 配置:
|
||||
- **接入方式**: 音频方式
|
||||
- **接入地址**: `http://your-server:17200`
|
||||
- **音频类型**: mp3
|
||||
- **书籍地址**: `http://your-server:17200/api/book/{book_id}`
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `MIMO_API_KEY` | ✅ | - | 小米 MiMo TTS API Key |
|
||||
| `MIMO_API_ENDPOINT` | ❌ | `https://api.xiaomimimo.com/v1/chat/completions` | API 地址 |
|
||||
| `MIMO_TTS_MODEL` | ❌ | `mimo-v2-audio-tts` | 模型名称 |
|
||||
| `MIMO_VOICE` | ❌ | `mimo_default` | 默认音色 |
|
||||
| `SERVER_PORT` | ❌ | `17200` | 服务端口 |
|
||||
|
||||
## MiMo TTS 风格参考
|
||||
|
||||
在「TTS 试听」中可填写风格,例如:
|
||||
|
||||
| 类别 | 示例 |
|
||||
|------|------|
|
||||
| 情感 | 开心 / 悲伤 / 生气 / 平静 |
|
||||
| 语速 | 语速慢 / 语速快 / 悄悄话 |
|
||||
| 角色 | 像个大将军 / 像个小孩 / 孙悟空 |
|
||||
| 方言 | 东北话 / 四川话 / 台湾腔 / 粤语 |
|
||||
|
||||
## 不使用 Docker 运行
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
# 需要系统安装 ffmpeg
|
||||
export MIMO_API_KEY=your_key
|
||||
cd app
|
||||
python main.py
|
||||
```
|
||||
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
21
app/config.py
Normal file
21
app/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 小米 MiMo TTS API 配置
|
||||
MIMO_API_ENDPOINT = os.environ.get(
|
||||
"MIMO_API_ENDPOINT", "https://api.xiaomimimo.com/v1/chat/completions"
|
||||
)
|
||||
MIMO_API_KEY = os.environ.get("MIMO_API_KEY", "")
|
||||
MIMO_TTS_MODEL = os.environ.get("MIMO_TTS_MODEL", "mimo-v2-audio-tts")
|
||||
MIMO_VOICE = os.environ.get("MIMO_VOICE", "mimo_default")
|
||||
|
||||
# 服务器配置
|
||||
SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
|
||||
SERVER_PORT = int(os.environ.get("SERVER_PORT", "17200"))
|
||||
|
||||
# 音频存储目录
|
||||
AUDIO_DIR = os.environ.get("AUDIO_DIR", os.path.join(BASE_DIR, "audio"))
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./data/tts.db")
|
||||
436
app/main.py
Normal file
436
app/main.py
Normal file
@@ -0,0 +1,436 @@
|
||||
"""
|
||||
TTS Book Service - 小米 MiMo TTS 转换服务
|
||||
为听书 App 提供音频接入接口
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import subprocess
|
||||
import uuid
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, func, select, delete
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
import config
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────
|
||||
|
||||
engine = create_async_engine(config.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Book(Base):
|
||||
__tablename__ = "books"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
book_id = Column(String(100), unique=True, nullable=False, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
author = Column(String(200), default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class Chapter(Base):
|
||||
__tablename__ = "chapters"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
book_id = Column(String(100), nullable=False, index=True)
|
||||
chapter_id = Column(String(100), nullable=False, index=True)
|
||||
app_chapter_id = Column(String(100), default="")
|
||||
title = Column(String(500), default="")
|
||||
text_content = Column(Text, default="")
|
||||
audio_file = Column(String(500), default="")
|
||||
status = Column(String(20), default="pending")
|
||||
error_msg = Column(Text, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# ── TTS Service ───────────────────────────────────────────────────────────
|
||||
|
||||
async def call_mimo_tts(text: str, style: str = "") -> bytes:
|
||||
"""调用小米 MiMo TTS API,返回 WAV 音频字节"""
|
||||
if not config.MIMO_API_KEY:
|
||||
raise HTTPException(500, "MIMO_API_KEY 未配置,请设置环境变量")
|
||||
|
||||
content = f"<style>{style}</style>{text}" if style else text
|
||||
|
||||
payload = {
|
||||
"model": config.MIMO_TTS_MODEL,
|
||||
"audio": {"format": "wav", "voice": config.MIMO_VOICE},
|
||||
"messages": [{"role": "assistant", "content": content}],
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"api-key": config.MIMO_API_KEY,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(config.MIMO_API_ENDPOINT, json=payload, headers=headers)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, f"MiMo TTS API 错误: HTTP {resp.status_code} - {resp.text[:300]}")
|
||||
|
||||
data = resp.json()
|
||||
if data.get("error"):
|
||||
raise HTTPException(502, f"MiMo TTS 错误: {data['error']}")
|
||||
|
||||
try:
|
||||
audio_b64 = data["choices"][0]["message"]["audio"]["data"]
|
||||
return base64.b64decode(audio_b64)
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise HTTPException(502, f"MiMo TTS 响应解析失败: {e}")
|
||||
|
||||
|
||||
def wav_to_mp3(wav_path: str, mp3_path: str):
|
||||
"""用 ffmpeg 将 WAV 转为 MP3"""
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", wav_path, "-codec:a", "libmp3lame", "-qscale:a", "2", mp3_path],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg 转换失败: {result.stderr[:300]}")
|
||||
|
||||
|
||||
async def generate_chapter_audio(chapter_id_str: str):
|
||||
"""为指定章节生成音频(WAV → MP3)"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(Chapter).where(Chapter.chapter_id == chapter_id_str))
|
||||
chapter = result.scalar_one_or_none()
|
||||
if not chapter:
|
||||
return
|
||||
|
||||
if not chapter.text_content.strip():
|
||||
chapter.status = "error"
|
||||
chapter.error_msg = "文本内容为空"
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
chapter.status = "generating"
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
audio_dir = Path(config.AUDIO_DIR) / chapter.book_id
|
||||
audio_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
wav_path = str(audio_dir / f"{chapter.chapter_id}.wav")
|
||||
mp3_path = str(audio_dir / f"{chapter.chapter_id}.mp3")
|
||||
|
||||
# MiMo TTS 生成 WAV
|
||||
wav_bytes = await call_mimo_tts(chapter.text_content)
|
||||
with open(wav_path, "wb") as f:
|
||||
f.write(wav_bytes)
|
||||
|
||||
# WAV → MP3
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path)
|
||||
|
||||
# 删除 WAV 源文件,只保留 MP3
|
||||
os.remove(wav_path)
|
||||
|
||||
chapter.audio_file = mp3_path
|
||||
chapter.status = "ready"
|
||||
chapter.error_msg = ""
|
||||
except Exception as e:
|
||||
chapter.status = "error"
|
||||
chapter.error_msg = str(e)[:500]
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── App Lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
os.makedirs(config.AUDIO_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.join(config.BASE_DIR, "data"), exist_ok=True)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="TTS Book Service", lifespan=lifespan)
|
||||
|
||||
|
||||
# ── 听书 App 音频接入接口 ─────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/book/{book_id}")
|
||||
async def get_book_info(book_id: str):
|
||||
"""获取书籍信息及章节列表(听书 App 调用)"""
|
||||
async with async_session() as db:
|
||||
book_result = await db.execute(select(Book).where(Book.book_id == book_id))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if not book:
|
||||
raise HTTPException(404, f"书籍 {book_id} 不存在")
|
||||
|
||||
ch_result = await db.execute(
|
||||
select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.id)
|
||||
)
|
||||
chapters = ch_result.scalars().all()
|
||||
|
||||
return {
|
||||
"book_id": book.book_id,
|
||||
"title": book.title,
|
||||
"author": book.author,
|
||||
"chapters": [
|
||||
{
|
||||
"chapter_id": ch.chapter_id,
|
||||
"app_chapter_id": ch.app_chapter_id,
|
||||
"title": ch.title,
|
||||
"status": ch.status,
|
||||
"audio_url": f"/api/book/{book_id}/chapter/{ch.chapter_id}/audio"
|
||||
if ch.status == "ready" else None,
|
||||
}
|
||||
for ch in chapters
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/book/{book_id}/chapter/{chapter_id}/audio")
|
||||
async def get_chapter_audio(book_id: str, chapter_id: str):
|
||||
"""获取章节音频文件(听书 App 调用)"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Chapter).where(
|
||||
Chapter.book_id == book_id, Chapter.chapter_id == chapter_id
|
||||
)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(404, "章节不存在")
|
||||
if chapter.status != "ready" or not chapter.audio_file:
|
||||
raise HTTPException(404, f"音频尚未生成,当前状态: {chapter.status}")
|
||||
|
||||
if not os.path.exists(chapter.audio_file):
|
||||
raise HTTPException(404, "音频文件丢失")
|
||||
|
||||
return FileResponse(chapter.audio_file, media_type="audio/mpeg", filename=f"{chapter_id}.mp3")
|
||||
|
||||
|
||||
# ── 管理 API ──────────────────────────────────────────────────────────────
|
||||
|
||||
# --- Books ---
|
||||
|
||||
@app.get("/admin/api/books")
|
||||
async def list_books():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(Book).order_by(Book.id.desc()))
|
||||
books = result.scalars().all()
|
||||
return [{"book_id": b.book_id, "title": b.title, "author": b.author} for b in books]
|
||||
|
||||
|
||||
@app.post("/admin/api/books")
|
||||
async def create_book(request: Request):
|
||||
data = await request.json()
|
||||
book_id = data.get("book_id", "").strip()
|
||||
title = data.get("title", "").strip()
|
||||
author = data.get("author", "").strip()
|
||||
|
||||
if not book_id or not title:
|
||||
raise HTTPException(400, "book_id 和 title 不能为空")
|
||||
|
||||
async with async_session() as db:
|
||||
existing = await db.execute(select(Book).where(Book.book_id == book_id))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, f"书籍 {book_id} 已存在")
|
||||
|
||||
book = Book(book_id=book_id, title=title, author=author)
|
||||
db.add(book)
|
||||
await db.commit()
|
||||
return {"ok": True, "book_id": book_id}
|
||||
|
||||
|
||||
@app.delete("/admin/api/books/{book_id}")
|
||||
async def delete_book(book_id: str):
|
||||
async with async_session() as db:
|
||||
await db.execute(delete(Chapter).where(Chapter.book_id == book_id))
|
||||
await db.execute(delete(Book).where(Book.book_id == book_id))
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- Chapters ---
|
||||
|
||||
@app.get("/admin/api/books/{book_id}/chapters")
|
||||
async def list_chapters(book_id: str):
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.id)
|
||||
)
|
||||
chapters = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"chapter_id": ch.chapter_id,
|
||||
"app_chapter_id": ch.app_chapter_id,
|
||||
"title": ch.title,
|
||||
"text_content": ch.text_content[:200] + "..." if len(ch.text_content) > 200 else ch.text_content,
|
||||
"text_length": len(ch.text_content),
|
||||
"status": ch.status,
|
||||
"error_msg": ch.error_msg,
|
||||
"has_audio": ch.status == "ready",
|
||||
}
|
||||
for ch in chapters
|
||||
]
|
||||
|
||||
|
||||
@app.post("/admin/api/books/{book_id}/chapters")
|
||||
async def create_chapter(book_id: str, request: Request):
|
||||
data = await request.json()
|
||||
chapter_id = data.get("chapter_id", "").strip()
|
||||
title = data.get("title", "").strip()
|
||||
app_chapter_id = data.get("app_chapter_id", "").strip()
|
||||
text_content = data.get("text_content", "").strip()
|
||||
|
||||
if not chapter_id:
|
||||
raise HTTPException(400, "chapter_id 不能为空")
|
||||
|
||||
async with async_session() as db:
|
||||
existing = await db.execute(
|
||||
select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, f"章节 {chapter_id} 已存在")
|
||||
|
||||
chapter = Chapter(
|
||||
book_id=book_id,
|
||||
chapter_id=chapter_id,
|
||||
app_chapter_id=app_chapter_id or chapter_id,
|
||||
title=title,
|
||||
text_content=text_content,
|
||||
)
|
||||
db.add(chapter)
|
||||
await db.commit()
|
||||
return {"ok": True, "chapter_id": chapter_id}
|
||||
|
||||
|
||||
@app.put("/admin/api/books/{book_id}/chapters/{chapter_id}")
|
||||
async def update_chapter(book_id: str, chapter_id: str, request: Request):
|
||||
data = await request.json()
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id)
|
||||
)
|
||||
chapter = result.scalar_one_or_none()
|
||||
if not chapter:
|
||||
raise HTTPException(404, "章节不存在")
|
||||
|
||||
if "text_content" in data:
|
||||
chapter.text_content = data["text_content"]
|
||||
if "title" in data:
|
||||
chapter.title = data["title"]
|
||||
if "app_chapter_id" in data:
|
||||
chapter.app_chapter_id = data["app_chapter_id"]
|
||||
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/admin/api/books/{book_id}/chapters/{chapter_id}")
|
||||
async def delete_chapter(book_id: str, chapter_id: str):
|
||||
async with async_session() as db:
|
||||
await db.execute(
|
||||
delete(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id)
|
||||
)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- TTS ---
|
||||
|
||||
@app.post("/admin/api/books/{book_id}/chapters/{chapter_id}/generate")
|
||||
async def generate_audio(book_id: str, chapter_id: str):
|
||||
"""手动生成单章音频"""
|
||||
await generate_chapter_audio(chapter_id)
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id)
|
||||
)
|
||||
ch = result.scalar_one_or_none()
|
||||
return {"ok": True, "status": ch.status, "error_msg": ch.error_msg}
|
||||
|
||||
|
||||
@app.post("/admin/api/books/{book_id}/generate-all")
|
||||
async def generate_all_chapters(book_id: str):
|
||||
"""批量生成书籍所有章节音频"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Chapter).where(Chapter.book_id == book_id, Chapter.status != "ready")
|
||||
)
|
||||
chapters = result.scalars().all()
|
||||
|
||||
chapter_ids = [ch.chapter_id for ch in chapters]
|
||||
for cid in chapter_ids:
|
||||
await generate_chapter_audio(cid)
|
||||
|
||||
return {"ok": True, "total": len(chapter_ids), "chapter_ids": chapter_ids}
|
||||
|
||||
|
||||
# --- TTS 试听 ---
|
||||
|
||||
@app.post("/admin/api/tts/preview")
|
||||
async def tts_preview(request: Request):
|
||||
"""试听 TTS 效果"""
|
||||
data = await request.json()
|
||||
text = data.get("text", "").strip()
|
||||
style = data.get("style", "").strip()
|
||||
|
||||
if not text:
|
||||
raise HTTPException(400, "文本不能为空")
|
||||
|
||||
wav_bytes = await call_mimo_tts(text, style)
|
||||
audio_dir = Path(config.AUDIO_DIR) / "_preview"
|
||||
audio_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = f"{uuid.uuid4().hex}.mp3"
|
||||
wav_path = str(audio_dir / f"{uuid.uuid4().hex}.wav")
|
||||
mp3_path = str(audio_dir / filename)
|
||||
|
||||
with open(wav_path, "wb") as f:
|
||||
f.write(wav_bytes)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path)
|
||||
os.remove(wav_path)
|
||||
|
||||
return {"ok": True, "url": f"/audio/_preview/{filename}"}
|
||||
|
||||
|
||||
@app.get("/admin/api/config")
|
||||
async def get_config():
|
||||
return {
|
||||
"endpoint": config.MIMO_API_ENDPOINT,
|
||||
"model": config.MIMO_TTS_MODEL,
|
||||
"voice": config.MIMO_VOICE,
|
||||
"api_key_masked": config.MIMO_API_KEY[:6] + "****" if config.MIMO_API_KEY else "未配置",
|
||||
}
|
||||
|
||||
|
||||
# ── 静态文件 & 前端 ──────────────────────────────────────────────────────
|
||||
|
||||
app.mount("/audio", StaticFiles(directory=config.AUDIO_DIR), name="audio")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def frontend():
|
||||
html_path = os.path.join(config.BASE_DIR, "static", "index.html")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
return HTMLResponse(f.read())
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host=config.SERVER_HOST, port=config.SERVER_PORT, reload=True)
|
||||
42
app/models.py
Normal file
42
app/models.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, func
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
import config
|
||||
|
||||
engine = create_async_engine(config.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Book(Base):
|
||||
__tablename__ = "books"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
book_id = Column(String(100), unique=True, nullable=False, index=True) # 平台书籍ID
|
||||
title = Column(String(500), nullable=False) # 书名
|
||||
author = Column(String(200), default="") # 作者
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class Chapter(Base):
|
||||
__tablename__ = "chapters"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
book_id = Column(String(100), nullable=False, index=True) # 关联的书籍ID
|
||||
chapter_id = Column(String(100), nullable=False, index=True) # 平台音频ID
|
||||
app_chapter_id = Column(String(100), default="") # App章节ID
|
||||
title = Column(String(500), default="") # 章节标题
|
||||
text_content = Column(Text, default="") # TTS 文本内容
|
||||
audio_file = Column(String(500), default="") # 生成的音频文件路径
|
||||
status = Column(String(20), default="pending") # pending / generating / ready / error
|
||||
error_msg = Column(Text, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
480
app/static/index.html
Normal file
480
app/static/index.html
Normal file
@@ -0,0 +1,480 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TTS Book Service</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--primary:#6c5ce7;--primary-hover:#7c6df7;--success:#00b894;--error:#ff6b6b;--warn:#fdcb6e;--text:#e8e8e8;--text-dim:#8b8fa3;--text-bright:#fff}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.container{max-width:1200px;margin:0 auto;padding:20px}
|
||||
h1{font-size:1.6rem;font-weight:700;margin-bottom:8px;background:linear-gradient(135deg,var(--primary),#a29bfe);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.subtitle{color:var(--text-dim);font-size:.85rem;margin-bottom:24px}
|
||||
|
||||
/* Tabs */
|
||||
.tabs{display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:0}
|
||||
.tab{padding:10px 20px;cursor:pointer;color:var(--text-dim);font-size:.9rem;border:none;background:none;transition:all .2s;position:relative;border-bottom:2px solid transparent}
|
||||
.tab:hover{color:var(--text)}
|
||||
.tab.active{color:var(--primary);border-bottom-color:var(--primary)}
|
||||
.tab-panel{display:none}
|
||||
.tab-panel.active{display:block}
|
||||
|
||||
/* Cards */
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
|
||||
.card-title{font-size:1.1rem;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px}
|
||||
|
||||
/* Forms */
|
||||
.form-group{margin-bottom:14px}
|
||||
.form-group label{display:block;font-size:.8rem;color:var(--text-dim);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
|
||||
input,textarea,select{width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.9rem;outline:none;transition:border .2s}
|
||||
input:focus,textarea:focus,select:focus{border-color:var(--primary)}
|
||||
textarea{resize:vertical;min-height:100px;font-family:inherit}
|
||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
|
||||
/* Buttons */
|
||||
.btn{padding:8px 18px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem;font-weight:500;transition:all .2s;display:inline-flex;align-items:center;gap:6px}
|
||||
.btn-primary{background:var(--primary);color:#fff}
|
||||
.btn-primary:hover{background:var(--primary-hover);transform:translateY(-1px)}
|
||||
.btn-success{background:var(--success);color:#fff}
|
||||
.btn-success:hover{opacity:.9}
|
||||
.btn-danger{background:var(--error);color:#fff}
|
||||
.btn-danger:hover{opacity:.9}
|
||||
.btn-sm{padding:5px 12px;font-size:.78rem}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
|
||||
/* Table */
|
||||
.table-wrap{overflow-x:auto}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{text-align:left;padding:10px 12px;font-size:.75rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border)}
|
||||
td{padding:10px 12px;border-bottom:1px solid var(--border);font-size:.85rem;vertical-align:middle}
|
||||
tr:hover{background:rgba(108,92,231,.05)}
|
||||
|
||||
/* Status badges */
|
||||
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:.72rem;font-weight:600;text-transform:uppercase}
|
||||
.badge-ready{background:rgba(0,184,148,.15);color:var(--success)}
|
||||
.badge-pending{background:rgba(253,203,110,.15);color:var(--warn)}
|
||||
.badge-generating{background:rgba(108,92,231,.15);color:var(--primary);animation:pulse 1.5s infinite}
|
||||
.badge-error{background:rgba(255,107,107,.15);color:var(--error)}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);z-index:100;justify-content:center;align-items:center}
|
||||
.modal-overlay.show{display:flex}
|
||||
.modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;width:90%;max-width:700px;max-height:80vh;overflow-y:auto}
|
||||
.modal-title{font-size:1.1rem;font-weight:600;margin-bottom:16px}
|
||||
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||||
|
||||
/* Toast */
|
||||
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:10px;font-size:.85rem;z-index:200;animation:slideIn .3s ease;max-width:400px}
|
||||
.toast-success{background:var(--success);color:#fff}
|
||||
.toast-error{background:var(--error);color:#fff}
|
||||
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
|
||||
/* Preview */
|
||||
.preview-box{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:16px;margin-top:12px}
|
||||
audio{width:100%;margin-top:8px}
|
||||
.text-dim{color:var(--text-dim)}
|
||||
.flex{display:flex;gap:8px;align-items:center}
|
||||
.flex-between{display:flex;justify-content:space-between;align-items:center}
|
||||
.mt-2{margin-top:8px}
|
||||
.mt-4{margin-top:16px}
|
||||
.mb-2{margin-bottom:8px}
|
||||
|
||||
/* Scrollable text preview */
|
||||
.text-preview{max-height:120px;overflow-y:auto;font-size:.82rem;color:var(--text-dim);line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📚 TTS Book Service</h1>
|
||||
<p class="subtitle">小米 MiMo TTS 听书音频转换服务</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('books')">📖 书籍管理</button>
|
||||
<button class="tab" onclick="switchTab('preview')">🎙️ TTS 试听</button>
|
||||
<button class="tab" onclick="switchTab('settings')">⚙️ 配置</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Books -->
|
||||
<div id="tab-books" class="tab-panel active">
|
||||
<div class="card">
|
||||
<div class="flex-between">
|
||||
<div class="card-title" style="margin-bottom:0">📖 书籍列表</div>
|
||||
<button class="btn btn-primary" onclick="showAddBook()">+ 添加书籍</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="book-list" class="card">
|
||||
<p class="text-dim">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Preview -->
|
||||
<div id="tab-preview" class="tab-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">🎙️ TTS 试听</div>
|
||||
<div class="form-group">
|
||||
<label>说话风格(可选)</label>
|
||||
<input id="preview-style" placeholder="如:开心、语速慢、东北话、像个大将军...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>文本内容</label>
|
||||
<textarea id="preview-text" rows="4" placeholder="输入要合成的文本..."></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="doPreview()" id="preview-btn">🔊 生成试听</button>
|
||||
<div id="preview-result" class="preview-box" style="display:none">
|
||||
<audio id="preview-audio" controls></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Settings -->
|
||||
<div id="tab-settings" class="tab-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">⚙️ 当前配置</div>
|
||||
<table>
|
||||
<tr><th style="width:180px">配置项</th><th>值</th></tr>
|
||||
<tr><td>TTS API</td><td id="cfg-endpoint">-</td></tr>
|
||||
<tr><td>模型</td><td id="cfg-model">-</td></tr>
|
||||
<tr><td>默认音色</td><td id="cfg-voice">-</td></tr>
|
||||
<tr><td>API Key</td><td id="cfg-apikey">-</td></tr>
|
||||
</table>
|
||||
<p class="text-dim mt-4" style="font-size:.8rem">通过环境变量配置:MIMO_API_KEY、MIMO_API_ENDPOINT、MIMO_TTS_MODEL、MIMO_VOICE</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Book Modal -->
|
||||
<div id="modal-add-book" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-title">📖 添加书籍</div>
|
||||
<div class="form-group">
|
||||
<label>书籍 ID(听书 App 中的 book_id)</label>
|
||||
<input id="new-book-id" placeholder="如:book_9">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>书名</label>
|
||||
<input id="new-book-title" placeholder="书籍名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>作者</label>
|
||||
<input id="new-book-author" placeholder="作者(可选)">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-add-book')">取消</button>
|
||||
<button class="btn btn-primary" onclick="addBook()">确认添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter Modal -->
|
||||
<div id="modal-chapters" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="flex-between">
|
||||
<div class="modal-title" id="chapter-modal-title" style="margin-bottom:0">章节管理</div>
|
||||
<button class="btn btn-sm btn-primary" onclick="showAddChapter()">+ 添加章节</button>
|
||||
</div>
|
||||
<div id="chapter-list" class="mt-4">
|
||||
<p class="text-dim">加载中...</p>
|
||||
</div>
|
||||
<div class="mt-4" id="bulk-actions" style="display:none">
|
||||
<button class="btn btn-success" onclick="generateAll()">⚡ 批量生成所有未就绪章节</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-chapters')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Chapter Modal -->
|
||||
<div id="modal-add-chapter" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-title" id="add-chapter-title">添加章节</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>章节 ID</label>
|
||||
<input id="new-chapter-id" placeholder="如:chapter_1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>App 章节 ID</label>
|
||||
<input id="new-chapter-app-id" placeholder="如:chapter1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>章节标题</label>
|
||||
<input id="new-chapter-title" placeholder="章节名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>文本内容(TTS 输入)</label>
|
||||
<textarea id="new-chapter-text" rows="8" placeholder="粘贴本章节的文本内容..."></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-add-chapter')">取消</button>
|
||||
<button class="btn btn-primary" onclick="addChapter()">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Chapter Modal -->
|
||||
<div id="modal-edit-chapter" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-title">编辑章节文本</div>
|
||||
<input type="hidden" id="edit-chapter-id">
|
||||
<div class="form-group">
|
||||
<label>文本内容</label>
|
||||
<textarea id="edit-chapter-text" rows="12" placeholder="修改章节文本..."></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" style="background:var(--border)" onclick="closeModal('modal-edit-chapter')">取消</button>
|
||||
<button class="btn btn-primary" onclick="saveChapterText()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentBookId = null;
|
||||
|
||||
// ── Tab ──
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
if (name === 'settings') loadSettings();
|
||||
}
|
||||
|
||||
// ── Toast ──
|
||||
function toast(msg, type = 'success') {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast toast-' + type;
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
|
||||
// ── Modal ──
|
||||
function showModal(id) { document.getElementById(id).classList.add('show'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
||||
|
||||
// ── Books ──
|
||||
async function loadBooks() {
|
||||
try {
|
||||
const res = await fetch('/admin/api/books');
|
||||
const books = await res.json();
|
||||
const el = document.getElementById('book-list');
|
||||
if (!books.length) {
|
||||
el.innerHTML = '<p class="text-dim">暂无书籍,点击右上角「添加书籍」开始</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table>
|
||||
<tr><th>书籍ID</th><th>书名</th><th>作者</th><th>操作</th></tr>
|
||||
${books.map(b => `<tr>
|
||||
<td><code>${b.book_id}</code></td>
|
||||
<td>${b.title}</td>
|
||||
<td>${b.author || '-'}</td>
|
||||
<td class="flex">
|
||||
<button class="btn btn-sm btn-primary" onclick="openChapters('${b.book_id}','${b.title}')">管理章节</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteBook('${b.book_id}')">删除</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
} catch(e) { toast('加载失败: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
function showAddBook() { showModal('modal-add-book'); }
|
||||
|
||||
async function addBook() {
|
||||
const book_id = document.getElementById('new-book-id').value.trim();
|
||||
const title = document.getElementById('new-book-title').value.trim();
|
||||
const author = document.getElementById('new-book-author').value.trim();
|
||||
if (!book_id || !title) { toast('书籍ID和书名不能为空', 'error'); return; }
|
||||
try {
|
||||
await fetch('/admin/api/books', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({book_id, title, author})
|
||||
});
|
||||
closeModal('modal-add-book');
|
||||
document.getElementById('new-book-id').value = '';
|
||||
document.getElementById('new-book-title').value = '';
|
||||
document.getElementById('new-book-author').value = '';
|
||||
toast('书籍添加成功');
|
||||
loadBooks();
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteBook(book_id) {
|
||||
if (!confirm(`确定删除书籍 ${book_id} 及其所有章节?`)) return;
|
||||
await fetch(`/admin/api/books/${book_id}`, {method: 'DELETE'});
|
||||
toast('已删除');
|
||||
loadBooks();
|
||||
}
|
||||
|
||||
// ── Chapters ──
|
||||
async function openChapters(book_id, title) {
|
||||
currentBookId = book_id;
|
||||
document.getElementById('chapter-modal-title').textContent = `📖 ${title} (${book_id})`;
|
||||
showModal('modal-chapters');
|
||||
await loadChapters(book_id);
|
||||
}
|
||||
|
||||
async function loadChapters(book_id) {
|
||||
try {
|
||||
const res = await fetch(`/admin/api/books/${book_id}/chapters`);
|
||||
const chapters = await res.json();
|
||||
const el = document.getElementById('chapter-list');
|
||||
const bulkEl = document.getElementById('bulk-actions');
|
||||
if (!chapters.length) {
|
||||
el.innerHTML = '<p class="text-dim">暂无章节</p>';
|
||||
bulkEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bulkEl.style.display = 'block';
|
||||
el.innerHTML = `<table>
|
||||
<tr><th>章节ID</th><th>App ID</th><th>标题</th><th>字数</th><th>状态</th><th>操作</th></tr>
|
||||
${chapters.map(ch => `<tr>
|
||||
<td><code>${ch.chapter_id}</code></td>
|
||||
<td>${ch.app_chapter_id}</td>
|
||||
<td>${ch.title || '-'}</td>
|
||||
<td>${ch.text_length}</td>
|
||||
<td><span class="badge badge-${ch.status}">${ch.status}</span></td>
|
||||
<td class="flex" style="flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-primary" onclick="editChapter('${book_id}','${ch.chapter_id}')">编辑</button>
|
||||
<button class="btn btn-sm btn-success" onclick="generateOne('${book_id}','${ch.chapter_id}')">生成</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteChapter('${book_id}','${ch.chapter_id}')">删除</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
} catch(e) { toast('加载失败: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
function showAddChapter() {
|
||||
document.getElementById('add-chapter-title').textContent = '添加章节';
|
||||
showModal('modal-add-chapter');
|
||||
}
|
||||
|
||||
async function addChapter() {
|
||||
const chapter_id = document.getElementById('new-chapter-id').value.trim();
|
||||
const app_chapter_id = document.getElementById('new-chapter-app-id').value.trim();
|
||||
const title = document.getElementById('new-chapter-title').value.trim();
|
||||
const text_content = document.getElementById('new-chapter-text').value.trim();
|
||||
if (!chapter_id) { toast('章节ID不能为空', 'error'); return; }
|
||||
try {
|
||||
await fetch(`/admin/api/books/${currentBookId}/chapters`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({chapter_id, app_chapter_id, title, text_content})
|
||||
});
|
||||
closeModal('modal-add-chapter');
|
||||
['new-chapter-id','new-chapter-app-id','new-chapter-title','new-chapter-text'].forEach(id => document.getElementById(id).value = '');
|
||||
toast('章节添加成功');
|
||||
loadChapters(currentBookId);
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function editChapter(book_id, chapter_id) {
|
||||
document.getElementById('edit-chapter-id').value = chapter_id;
|
||||
// Fetch full chapter text
|
||||
const res = await fetch(`/admin/api/books/${book_id}/chapters`);
|
||||
const chapters = await res.json();
|
||||
const ch = chapters.find(c => c.chapter_id === chapter_id);
|
||||
document.getElementById('edit-chapter-text').value = ch ? ch.text_content : '';
|
||||
showModal('modal-edit-chapter');
|
||||
}
|
||||
|
||||
async function saveChapterText() {
|
||||
const chapter_id = document.getElementById('edit-chapter-id').value;
|
||||
const text_content = document.getElementById('edit-chapter-text').value;
|
||||
try {
|
||||
await fetch(`/admin/api/books/${currentBookId}/chapters/${chapter_id}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({text_content})
|
||||
});
|
||||
closeModal('modal-edit-chapter');
|
||||
toast('文本已保存');
|
||||
loadChapters(currentBookId);
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteChapter(book_id, chapter_id) {
|
||||
if (!confirm(`确定删除章节 ${chapter_id}?`)) return;
|
||||
await fetch(`/admin/api/books/${book_id}/chapters/${chapter_id}`, {method: 'DELETE'});
|
||||
toast('已删除');
|
||||
loadChapters(book_id);
|
||||
}
|
||||
|
||||
// ── TTS Generation ──
|
||||
async function generateOne(book_id, chapter_id) {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '生成中...';
|
||||
try {
|
||||
const res = await fetch(`/admin/api/books/${book_id}/chapters/${chapter_id}/generate`, {method: 'POST'});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ready') toast('音频生成成功!');
|
||||
else toast('生成失败: ' + (data.error_msg || '未知错误'), 'error');
|
||||
loadChapters(book_id);
|
||||
} catch(e) { toast('生成失败: ' + e.message, 'error'); }
|
||||
btn.disabled = false;
|
||||
btn.textContent = '生成';
|
||||
}
|
||||
|
||||
async function generateAll() {
|
||||
if (!confirm('确定批量生成所有未就绪章节的音频?这可能需要较长时间。')) return;
|
||||
try {
|
||||
toast('开始批量生成...');
|
||||
const res = await fetch(`/admin/api/books/${currentBookId}/generate-all`, {method: 'POST'});
|
||||
const data = await res.json();
|
||||
toast(`批量生成完成,共 ${data.total} 章`);
|
||||
loadChapters(currentBookId);
|
||||
} catch(e) { toast('批量生成失败: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Preview ──
|
||||
async function doPreview() {
|
||||
const text = document.getElementById('preview-text').value.trim();
|
||||
const style = document.getElementById('preview-style').value.trim();
|
||||
if (!text) { toast('请输入文本', 'error'); return; }
|
||||
const btn = document.getElementById('preview-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 生成中...';
|
||||
try {
|
||||
const res = await fetch('/admin/api/tts/preview', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({text, style})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
document.getElementById('preview-result').style.display = 'block';
|
||||
document.getElementById('preview-audio').src = data.url;
|
||||
document.getElementById('preview-audio').play();
|
||||
toast('试听生成成功');
|
||||
} else {
|
||||
toast('生成失败', 'error');
|
||||
}
|
||||
} catch(e) { toast('生成失败: ' + e.message, 'error'); }
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔊 生成试听';
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/admin/api/config');
|
||||
const cfg = await res.json();
|
||||
document.getElementById('cfg-endpoint').textContent = cfg.endpoint || '-';
|
||||
document.getElementById('cfg-model').textContent = cfg.model || '-';
|
||||
document.getElementById('cfg-voice').textContent = cfg.voice || '-';
|
||||
document.getElementById('cfg-apikey').textContent = cfg.api_key_masked || '未配置';
|
||||
} catch(e) {
|
||||
document.getElementById('cfg-apikey').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
loadBooks();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
tts-book-service:
|
||||
build: .
|
||||
container_name: tts-book-service
|
||||
ports:
|
||||
- "17200:17200"
|
||||
environment:
|
||||
- MIMO_API_KEY=${MIMO_API_KEY}
|
||||
- MIMO_API_ENDPOINT=${MIMO_API_ENDPOINT:-https://api.xiaomimimo.com/v1/chat/completions}
|
||||
- MIMO_TTS_MODEL=${MIMO_TTS_MODEL:-mimo-v2-audio-tts}
|
||||
- MIMO_VOICE=${MIMO_VOICE:-mimo_default}
|
||||
- SERVER_PORT=17200
|
||||
volumes:
|
||||
- tts-data:/app/data
|
||||
- tts-audio:/app/audio
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "1.0"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
tts-data:
|
||||
tts-audio:
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
sqlalchemy==2.0.35
|
||||
aiosqlite==0.20.0
|
||||
httpx==0.27.0
|
||||
python-multipart==0.0.12
|
||||
Reference in New Issue
Block a user