- Auth system: JWT login/register with Cloudflare Turnstile, admin + user roles - Admin panel: user management (role/active toggle, delete), Nextcloud config - 6 TTS models: OpenAI TTS / TTS-HD, ElevenLabs, Gemini 2.5 Flash/Pro - Voice lock: prevent model/voice changes while audio is playing - Nextcloud browser: PROPFIND WebDAV, folder navigation, PDF loading - Saved documents: save/load with PUT/POST, progress auto-saves every 2s - Resume playback: remembers sentence position per user per document - Better sentence highlighting with glow effect Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
594 lines
24 KiB
Python
594 lines
24 KiB
Python
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Response, Request
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
from typing import Optional
|
|
import httpx, re, uuid, requests as req_lib
|
|
from datetime import datetime
|
|
|
|
from models import get_db, init_db, User, Document, Progress, Setting
|
|
from auth import (
|
|
hash_password, verify_password, create_token, get_current_user, require_admin
|
|
)
|
|
|
|
# ── CONFIG ──────────────────────────────────────────────────────────
|
|
LITELLM_BASE = "http://localhost:4000"
|
|
LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52"
|
|
TURNSTILE_SECRET = "0x4AAAAAAC0VtE0VOo0vpGoTvmHSk8yRN4Y"
|
|
|
|
TTS_MODELS = {
|
|
"openai-tts-1": {
|
|
"name": "OpenAI TTS",
|
|
"quality": "Standard",
|
|
"voices": [
|
|
{"id": "alloy", "name": "Alloy"},
|
|
{"id": "ash", "name": "Ash"},
|
|
{"id": "coral", "name": "Coral"},
|
|
{"id": "echo", "name": "Echo"},
|
|
{"id": "fable", "name": "Fable"},
|
|
{"id": "nova", "name": "Nova"},
|
|
{"id": "onyx", "name": "Onyx"},
|
|
{"id": "sage", "name": "Sage"},
|
|
{"id": "shimmer", "name": "Shimmer"},
|
|
],
|
|
},
|
|
"openai-tts-1-hd": {
|
|
"name": "OpenAI TTS HD",
|
|
"quality": "High Definition",
|
|
"voices": [
|
|
{"id": "alloy", "name": "Alloy"},
|
|
{"id": "ash", "name": "Ash"},
|
|
{"id": "coral", "name": "Coral"},
|
|
{"id": "echo", "name": "Echo"},
|
|
{"id": "fable", "name": "Fable"},
|
|
{"id": "nova", "name": "Nova"},
|
|
{"id": "onyx", "name": "Onyx"},
|
|
{"id": "sage", "name": "Sage"},
|
|
{"id": "shimmer", "name": "Shimmer"},
|
|
],
|
|
},
|
|
"elevenlabs-v3": {
|
|
"name": "ElevenLabs v3",
|
|
"quality": "Premium",
|
|
"voices": [
|
|
{"id": "Rachel", "name": "Rachel"},
|
|
{"id": "Drew", "name": "Drew"},
|
|
{"id": "Clyde", "name": "Clyde"},
|
|
{"id": "Paul", "name": "Paul"},
|
|
{"id": "Domi", "name": "Domi"},
|
|
{"id": "Dave", "name": "Dave"},
|
|
{"id": "Fin", "name": "Fin"},
|
|
{"id": "Bella", "name": "Bella"},
|
|
{"id": "Antoni", "name": "Antoni"},
|
|
{"id": "Thomas", "name": "Thomas"},
|
|
{"id": "Charlie", "name": "Charlie"},
|
|
{"id": "Emily", "name": "Emily"},
|
|
{"id": "Elli", "name": "Elli"},
|
|
{"id": "Callum", "name": "Callum"},
|
|
{"id": "Patrick", "name": "Patrick"},
|
|
{"id": "Harry", "name": "Harry"},
|
|
{"id": "Liam", "name": "Liam"},
|
|
{"id": "Dorothy", "name": "Dorothy"},
|
|
{"id": "Josh", "name": "Josh"},
|
|
{"id": "Arnold", "name": "Arnold"},
|
|
{"id": "Adam", "name": "Adam"},
|
|
{"id": "Sam", "name": "Sam"},
|
|
],
|
|
},
|
|
"elevenlabs-multilingual": {
|
|
"name": "ElevenLabs Multilingual",
|
|
"quality": "Premium Multilingual",
|
|
"voices": [
|
|
{"id": "Rachel", "name": "Rachel"},
|
|
{"id": "Drew", "name": "Drew"},
|
|
{"id": "Clyde", "name": "Clyde"},
|
|
{"id": "Paul", "name": "Paul"},
|
|
{"id": "Domi", "name": "Domi"},
|
|
{"id": "Dave", "name": "Dave"},
|
|
{"id": "Fin", "name": "Fin"},
|
|
{"id": "Bella", "name": "Bella"},
|
|
{"id": "Antoni", "name": "Antoni"},
|
|
{"id": "Thomas", "name": "Thomas"},
|
|
{"id": "Charlie", "name": "Charlie"},
|
|
{"id": "Emily", "name": "Emily"},
|
|
{"id": "Elli", "name": "Elli"},
|
|
{"id": "Callum", "name": "Callum"},
|
|
{"id": "Patrick", "name": "Patrick"},
|
|
{"id": "Harry", "name": "Harry"},
|
|
{"id": "Liam", "name": "Liam"},
|
|
{"id": "Dorothy", "name": "Dorothy"},
|
|
{"id": "Josh", "name": "Josh"},
|
|
{"id": "Arnold", "name": "Arnold"},
|
|
{"id": "Adam", "name": "Adam"},
|
|
{"id": "Sam", "name": "Sam"},
|
|
],
|
|
},
|
|
"vertex-gemini-2.5-flash-tts": {
|
|
"name": "Gemini 2.5 Flash TTS",
|
|
"quality": "Fast",
|
|
"voices": [
|
|
{"id": "Aoede", "name": "Aoede"},
|
|
{"id": "Charon", "name": "Charon"},
|
|
{"id": "Fenrir", "name": "Fenrir"},
|
|
{"id": "Kore", "name": "Kore"},
|
|
{"id": "Puck", "name": "Puck"},
|
|
{"id": "Orbit", "name": "Orbit"},
|
|
{"id": "Zephyr", "name": "Zephyr"},
|
|
{"id": "Autonoe", "name": "Autonoe"},
|
|
{"id": "Enceladus", "name": "Enceladus"},
|
|
{"id": "Laomedeia", "name": "Laomedeia"},
|
|
{"id": "Achernar", "name": "Achernar"},
|
|
{"id": "Rasalgethi", "name": "Rasalgethi"},
|
|
{"id": "Schedar", "name": "Schedar"},
|
|
{"id": "Sulafat", "name": "Sulafat"},
|
|
],
|
|
},
|
|
"vertex-gemini-2.5-pro-tts": {
|
|
"name": "Gemini 2.5 Pro TTS",
|
|
"quality": "High Quality",
|
|
"voices": [
|
|
{"id": "Aoede", "name": "Aoede"},
|
|
{"id": "Charon", "name": "Charon"},
|
|
{"id": "Fenrir", "name": "Fenrir"},
|
|
{"id": "Kore", "name": "Kore"},
|
|
{"id": "Puck", "name": "Puck"},
|
|
{"id": "Orbit", "name": "Orbit"},
|
|
{"id": "Zephyr", "name": "Zephyr"},
|
|
{"id": "Autonoe", "name": "Autonoe"},
|
|
{"id": "Enceladus", "name": "Enceladus"},
|
|
{"id": "Laomedeia", "name": "Laomedeia"},
|
|
{"id": "Achernar", "name": "Achernar"},
|
|
{"id": "Rasalgethi", "name": "Rasalgethi"},
|
|
{"id": "Schedar", "name": "Schedar"},
|
|
{"id": "Sulafat", "name": "Sulafat"},
|
|
],
|
|
},
|
|
}
|
|
|
|
# ── APP ─────────────────────────────────────────────────────────────
|
|
app = FastAPI(title="Speechify Clone")
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
init_db()
|
|
db = next(get_db())
|
|
# Create default admin if no users exist
|
|
if db.query(User).count() == 0:
|
|
admin = User(
|
|
username="admin",
|
|
email="admin@speechify.local",
|
|
password_hash=hash_password("admin123"),
|
|
role="admin",
|
|
)
|
|
db.add(admin)
|
|
db.commit()
|
|
print("\n" + "="*50)
|
|
print("Default admin created: admin / admin123")
|
|
print("="*50 + "\n")
|
|
db.close()
|
|
|
|
|
|
# ── HELPERS ─────────────────────────────────────────────────────────
|
|
def verify_turnstile(token: str, ip: str = "") -> bool:
|
|
try:
|
|
r = req_lib.post(
|
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
data={"secret": TURNSTILE_SECRET, "response": token, "remoteip": ip},
|
|
timeout=10,
|
|
)
|
|
return r.json().get("success", False)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def get_setting(db: Session, key: str, default: str = "") -> str:
|
|
s = db.query(Setting).filter(Setting.key == key).first()
|
|
return s.value if s else default
|
|
|
|
|
|
def set_setting(db: Session, key: str, value: str):
|
|
s = db.query(Setting).filter(Setting.key == key).first()
|
|
if s:
|
|
s.value = value
|
|
else:
|
|
db.add(Setting(key=key, value=value))
|
|
db.commit()
|
|
|
|
|
|
# ── PYDANTIC MODELS ──────────────────────────────────────────────────
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
turnstile_token: str
|
|
|
|
class RegisterRequest(BaseModel):
|
|
username: str
|
|
email: str
|
|
password: str
|
|
turnstile_token: str
|
|
|
|
class TTSRequest(BaseModel):
|
|
text: str
|
|
model: str
|
|
voice: str
|
|
speed: float = 1.0
|
|
|
|
class URLRequest(BaseModel):
|
|
url: str
|
|
|
|
class SaveDocRequest(BaseModel):
|
|
title: str
|
|
content: str
|
|
source_type: str = "text"
|
|
|
|
class ProgressRequest(BaseModel):
|
|
sentence_index: int
|
|
|
|
class NextcloudConfigRequest(BaseModel):
|
|
url: str
|
|
username: str
|
|
password: str
|
|
|
|
class NextcloudDownloadRequest(BaseModel):
|
|
path: str
|
|
|
|
class UserUpdateRequest(BaseModel):
|
|
role: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class SettingsRequest(BaseModel):
|
|
nextcloud_url: Optional[str] = None
|
|
nextcloud_username: Optional[str] = None
|
|
nextcloud_password: Optional[str] = None
|
|
|
|
|
|
# ── AUTH ROUTES ─────────────────────────────────────────────────────
|
|
@app.post("/api/auth/login")
|
|
async def login(req: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
|
|
ip = request.client.host if request.client else ""
|
|
if not verify_turnstile(req.turnstile_token, ip):
|
|
raise HTTPException(status_code=400, detail="Turnstile verification failed")
|
|
|
|
user = db.query(User).filter(User.username == req.username).first()
|
|
if not user or not verify_password(req.password, user.password_hash):
|
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
if not user.is_active:
|
|
raise HTTPException(status_code=403, detail="Account disabled")
|
|
|
|
token = create_token(user.id, user.username, user.role)
|
|
response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800)
|
|
return {"username": user.username, "role": user.role}
|
|
|
|
|
|
@app.post("/api/auth/register")
|
|
async def register(req: RegisterRequest, response: Response, request: Request, db: Session = Depends(get_db)):
|
|
ip = request.client.host if request.client else ""
|
|
if not verify_turnstile(req.turnstile_token, ip):
|
|
raise HTTPException(status_code=400, detail="Turnstile verification failed")
|
|
|
|
if db.query(User).filter(User.username == req.username).first():
|
|
raise HTTPException(status_code=400, detail="Username already taken")
|
|
if db.query(User).filter(User.email == req.email).first():
|
|
raise HTTPException(status_code=400, detail="Email already registered")
|
|
|
|
user = User(
|
|
username=req.username,
|
|
email=req.email,
|
|
password_hash=hash_password(req.password),
|
|
role="user",
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
token = create_token(user.id, user.username, user.role)
|
|
response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800)
|
|
return {"username": user.username, "role": user.role}
|
|
|
|
|
|
@app.post("/api/auth/logout")
|
|
async def logout(response: Response):
|
|
response.delete_cookie("auth_token")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/auth/me")
|
|
async def me(user: User = Depends(get_current_user)):
|
|
return {"id": user.id, "username": user.username, "email": user.email, "role": user.role}
|
|
|
|
|
|
# ── ADMIN ROUTES ─────────────────────────────────────────────────────
|
|
@app.get("/api/admin/users")
|
|
async def list_users(admin: User = Depends(require_admin), db: Session = Depends(get_db)):
|
|
users = db.query(User).all()
|
|
return [{"id": u.id, "username": u.username, "email": u.email, "role": u.role,
|
|
"is_active": u.is_active, "created_at": u.created_at.isoformat()} for u in users]
|
|
|
|
|
|
@app.patch("/api/admin/users/{user_id}")
|
|
async def update_user(user_id: int, req: UserUpdateRequest,
|
|
admin: User = Depends(require_admin), db: Session = Depends(get_db)):
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if req.role is not None:
|
|
user.role = req.role
|
|
if req.is_active is not None:
|
|
user.is_active = req.is_active
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/admin/users/{user_id}")
|
|
async def delete_user(user_id: int, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if user.id == admin.id:
|
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
|
db.delete(user)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/admin/settings")
|
|
async def get_settings(admin: User = Depends(require_admin), db: Session = Depends(get_db)):
|
|
return {
|
|
"nextcloud_url": get_setting(db, "nextcloud_url"),
|
|
"nextcloud_username": get_setting(db, "nextcloud_username"),
|
|
"nextcloud_password": get_setting(db, "nextcloud_password"),
|
|
}
|
|
|
|
|
|
@app.post("/api/admin/settings")
|
|
async def save_settings(req: SettingsRequest, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
|
|
if req.nextcloud_url is not None: set_setting(db, "nextcloud_url", req.nextcloud_url)
|
|
if req.nextcloud_username is not None: set_setting(db, "nextcloud_username", req.nextcloud_username)
|
|
if req.nextcloud_password is not None: set_setting(db, "nextcloud_password", req.nextcloud_password)
|
|
return {"ok": True}
|
|
|
|
|
|
# ── DOCUMENT ROUTES ──────────────────────────────────────────────────
|
|
@app.get("/api/documents")
|
|
async def list_documents(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
docs = db.query(Document).filter(Document.user_id == user.id).order_by(Document.created_at.desc()).all()
|
|
return [{"id": d.id, "title": d.title, "source_type": d.source_type,
|
|
"created_at": d.created_at.isoformat()} for d in docs]
|
|
|
|
|
|
@app.post("/api/documents")
|
|
async def save_document(req: SaveDocRequest, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
doc = Document(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user.id,
|
|
title=req.title,
|
|
content=req.content,
|
|
source_type=req.source_type,
|
|
)
|
|
db.add(doc)
|
|
db.commit()
|
|
return {"id": doc.id}
|
|
|
|
|
|
@app.put("/api/documents/{doc_id}")
|
|
async def update_document(doc_id: str, req: SaveDocRequest,
|
|
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first()
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
doc.title = req.title
|
|
doc.content = req.content
|
|
db.commit()
|
|
return {"id": doc.id}
|
|
|
|
|
|
@app.get("/api/documents/{doc_id}")
|
|
async def get_document(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first()
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
return {"id": doc.id, "title": doc.title, "content": doc.content, "source_type": doc.source_type}
|
|
|
|
|
|
@app.delete("/api/documents/{doc_id}")
|
|
async def delete_document(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first()
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
db.query(Progress).filter(Progress.document_id == doc_id).delete()
|
|
db.delete(doc)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── PROGRESS ROUTES ──────────────────────────────────────────────────
|
|
@app.get("/api/progress/{doc_id}")
|
|
async def get_progress(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
p = db.query(Progress).filter(Progress.user_id == user.id, Progress.document_id == doc_id).first()
|
|
return {"sentence_index": p.sentence_index if p else 0}
|
|
|
|
|
|
@app.post("/api/progress/{doc_id}")
|
|
async def save_progress(doc_id: str, req: ProgressRequest,
|
|
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
p = db.query(Progress).filter(Progress.user_id == user.id, Progress.document_id == doc_id).first()
|
|
if p:
|
|
p.sentence_index = req.sentence_index
|
|
p.updated_at = datetime.utcnow()
|
|
else:
|
|
db.add(Progress(user_id=user.id, document_id=doc_id, sentence_index=req.sentence_index))
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── NEXTCLOUD ROUTES ─────────────────────────────────────────────────
|
|
@app.get("/api/nextcloud/files")
|
|
async def list_nextcloud_files(path: str = "/", user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
nc_url = get_setting(db, "nextcloud_url")
|
|
nc_user = get_setting(db, "nextcloud_username")
|
|
nc_pass = get_setting(db, "nextcloud_password")
|
|
if not nc_url:
|
|
raise HTTPException(status_code=400, detail="Nextcloud not configured")
|
|
|
|
dav_url = f"{nc_url.rstrip('/')}/remote.php/dav/files/{nc_user}/{path.lstrip('/')}"
|
|
try:
|
|
r = req_lib.request(
|
|
"PROPFIND", dav_url,
|
|
auth=(nc_user, nc_pass),
|
|
headers={"Depth": "1"},
|
|
timeout=15,
|
|
)
|
|
if r.status_code not in (207, 200):
|
|
raise HTTPException(status_code=r.status_code, detail="Nextcloud error")
|
|
|
|
# Parse WebDAV XML response
|
|
from xml.etree import ElementTree as ET
|
|
ns = {"d": "DAV:"}
|
|
tree = ET.fromstring(r.text)
|
|
items = []
|
|
for resp in tree.findall("d:response", ns):
|
|
href = resp.find("d:href", ns).text
|
|
prop = resp.find(".//d:prop", ns)
|
|
display_name = prop.find("d:displayname", ns)
|
|
content_type = prop.find("d:getcontenttype", ns)
|
|
is_collection = prop.find("d:resourcetype/d:collection", ns) is not None
|
|
|
|
name = display_name.text if display_name is not None else href.split("/")[-1]
|
|
mime = content_type.text if content_type is not None else ""
|
|
if not name:
|
|
continue
|
|
|
|
# Extract path relative to user's root
|
|
dav_root = f"/remote.php/dav/files/{nc_user}/"
|
|
rel_path = href.split(dav_root)[-1] if dav_root in href else href
|
|
|
|
if is_collection or mime == "application/pdf" or rel_path.lower().endswith(".pdf"):
|
|
items.append({
|
|
"name": name,
|
|
"path": rel_path,
|
|
"is_dir": is_collection,
|
|
"mime": mime,
|
|
})
|
|
return items
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/nextcloud/download")
|
|
async def download_nextcloud_file(req: NextcloudDownloadRequest,
|
|
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
nc_url = get_setting(db, "nextcloud_url")
|
|
nc_user = get_setting(db, "nextcloud_username")
|
|
nc_pass = get_setting(db, "nextcloud_password")
|
|
if not nc_url:
|
|
raise HTTPException(status_code=400, detail="Nextcloud not configured")
|
|
|
|
try:
|
|
import fitz
|
|
dav_url = f"{nc_url.rstrip('/')}/remote.php/dav/files/{nc_user}/{req.path.lstrip('/')}"
|
|
r = req_lib.get(dav_url, auth=(nc_user, nc_pass), timeout=30)
|
|
if r.status_code != 200:
|
|
raise HTTPException(status_code=r.status_code, detail="File download failed")
|
|
|
|
doc = fitz.open(stream=r.content, filetype="pdf")
|
|
pages = [page.get_text() for page in doc]
|
|
text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(pages)).strip()
|
|
title = req.path.split("/")[-1]
|
|
return {"text": text, "title": title}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# ── VOICES ───────────────────────────────────────────────────────────
|
|
@app.get("/api/voices")
|
|
async def get_voices(_: User = Depends(get_current_user)):
|
|
return TTS_MODELS
|
|
|
|
|
|
# ── TTS ──────────────────────────────────────────────────────────────
|
|
@app.post("/api/tts")
|
|
async def text_to_speech(req: TTSRequest, _: User = Depends(get_current_user)):
|
|
payload = {"model": req.model, "input": req.text, "voice": req.voice}
|
|
if req.speed != 1.0:
|
|
payload["speed"] = req.speed
|
|
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
resp = await client.post(
|
|
f"{LITELLM_BASE}/v1/audio/speech",
|
|
json=payload,
|
|
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
|
|
)
|
|
if resp.status_code != 200:
|
|
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
|
|
|
from fastapi.responses import Response
|
|
return Response(content=resp.content, media_type="audio/mpeg")
|
|
|
|
|
|
# ── URL EXTRACTION ────────────────────────────────────────────────────
|
|
@app.post("/api/extract-url")
|
|
async def extract_from_url(req: URLRequest, _: User = Depends(get_current_user)):
|
|
try:
|
|
from bs4 import BeautifulSoup
|
|
import html2text
|
|
|
|
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
|
resp = await client.get(req.url, headers={"User-Agent": "Mozilla/5.0"})
|
|
|
|
soup = BeautifulSoup(resp.text, "html.parser")
|
|
for tag in soup(["script", "style", "nav", "footer", "header", "aside", "iframe"]):
|
|
tag.decompose()
|
|
|
|
main = soup.find("article") or soup.find("main") or soup.find(id="content") or soup.body
|
|
h = html2text.HTML2Text()
|
|
h.ignore_links = True
|
|
h.ignore_images = True
|
|
h.body_width = 0
|
|
text = re.sub(r'\n{3,}', '\n\n', h.handle(str(main))).strip()
|
|
title = soup.title.string.strip() if soup.title else req.url
|
|
return {"text": text, "title": title}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
# ── PDF EXTRACTION ────────────────────────────────────────────────────
|
|
@app.post("/api/extract-pdf")
|
|
async def extract_from_pdf(file: UploadFile = File(...), _: User = Depends(get_current_user)):
|
|
"""
|
|
Reads the binary PDF file, extracts all text content from each page,
|
|
and returns it as plain text that can be fed to TTS.
|
|
"""
|
|
try:
|
|
import fitz # PyMuPDF
|
|
content = await file.read()
|
|
doc = fitz.open(stream=content, filetype="pdf")
|
|
pages = [page.get_text() for page in doc]
|
|
text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(pages)).strip()
|
|
return {"text": text, "title": file.filename}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
# ── STATIC / PAGES ────────────────────────────────────────────────────
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
|
|
|
|
@app.get("/login")
|
|
async def login_page():
|
|
return FileResponse("static/login.html")
|
|
|
|
|
|
@app.get("/")
|
|
async def root(request: Request):
|
|
# Let the frontend handle auth redirect via /api/auth/me
|
|
return FileResponse("static/index.html")
|
|
|
|
|
|
@app.get("/admin")
|
|
async def admin_page():
|
|
return FileResponse("static/admin.html")
|