Ahmed Mostafa commited on
Commit
aa1867f
·
1 Parent(s): 8285ecf

feat: implement text-to-speech service, add API routers, and define database models

Browse files
src/api/main.py CHANGED
@@ -12,6 +12,7 @@ from src.api.auth_routes import router as auth_router
12
  from src.api.notes_routes import router as notes_router
13
  from src.api.recommendation_routes import router as recommendation_router
14
  from src.api.chat_routes import router as chat_router
 
15
  from src.utils.logger import setup_logger
16
 
17
  logger = setup_logger(__name__)
@@ -41,6 +42,7 @@ app.include_router(notes_router)
41
  app.include_router(recommendation_router)
42
  app.include_router(auth_router)
43
  app.include_router(chat_router)
 
44
 
45
  @app.get("/")
46
  def read_root():
 
12
  from src.api.notes_routes import router as notes_router
13
  from src.api.recommendation_routes import router as recommendation_router
14
  from src.api.chat_routes import router as chat_router
15
+ from src.api.tts_routes import router as tts_router
16
  from src.utils.logger import setup_logger
17
 
18
  logger = setup_logger(__name__)
 
42
  app.include_router(recommendation_router)
43
  app.include_router(auth_router)
44
  app.include_router(chat_router)
45
+ app.include_router(tts_router)
46
 
47
  @app.get("/")
48
  def read_root():
src/api/notes_routes.py CHANGED
@@ -305,6 +305,7 @@ async def process_video_task(
305
  tasks[task_id]["category"] = categories
306
  tasks[task_id]["keyPoints"] = key_points_list
307
  tasks[task_id]["videoTitle"] = resolved_video_title
 
308
  tasks[task_id]["thumbnail"] = (
309
  f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
310
  )
 
305
  tasks[task_id]["category"] = categories
306
  tasks[task_id]["keyPoints"] = key_points_list
307
  tasks[task_id]["videoTitle"] = resolved_video_title
308
+ tasks[task_id]["videoDuration"] = video_duration
309
  tasks[task_id]["thumbnail"] = (
310
  f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
311
  )
src/api/recommendation_routes.py CHANGED
@@ -90,8 +90,12 @@ async def get_recommendations(
90
  logger.info(f"🎯 Getting recommendations for user: {user_id}")
91
 
92
  try:
93
- # Use notesCount directly from user
94
- notes_count = getattr(current_user, "notesCount", 0) or 0
 
 
 
 
95
 
96
  is_personalized = notes_count >= MIN_INTERACTIONS_FOR_PERSONALIZATION
97
 
 
90
  logger.info(f"🎯 Getting recommendations for user: {user_id}")
91
 
92
  try:
93
+ # Get notes count from analytics collection in Firestore
94
+ notes_count = 0
95
+ if db is not None and user_id:
96
+ analytics_doc = db.collection("analytics").document(user_id).get()
97
+ if analytics_doc.exists:
98
+ notes_count = analytics_doc.to_dict().get("notesCount", 0) or 0
99
 
100
  is_personalized = notes_count >= MIN_INTERACTIONS_FOR_PERSONALIZATION
101
 
src/api/tts_routes.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import time
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException
7
+ from fastapi.responses import FileResponse
8
+ from pydantic import BaseModel, Field
9
+
10
+ from src.auth.dependencies import get_current_user
11
+ from src.db.models import User
12
+ from src.speech.speech import TextToSpeechService
13
+ from src.utils.config import settings
14
+ from src.utils.logger import setup_logger
15
+
16
+ logger = setup_logger(__name__)
17
+ router = APIRouter(prefix="/tts", tags=["Text to Speech"])
18
+
19
+ MAX_TTS_CHARS = 20000
20
+ TTS_TTL_SECONDS = 24 * 60 * 60
21
+
22
+
23
+ class TextToSpeechRequest(BaseModel):
24
+ text: str = Field(..., min_length=1)
25
+ title: str | None = None
26
+ rate: str = "-10%"
27
+
28
+
29
+ class TextToSpeechResponse(BaseModel):
30
+ audioId: str
31
+ audioUrl: str
32
+
33
+
34
+ def _safe_user_id(user_id: str | None) -> str:
35
+ value = user_id or "unknown"
36
+ return re.sub(r"[^A-Za-z0-9_-]", "_", value)
37
+
38
+
39
+ def _user_tts_dir(user: User) -> Path:
40
+ user_dir = settings.output_dir / "tts" / _safe_user_id(user.id)
41
+ user_dir.mkdir(parents=True, exist_ok=True)
42
+ return user_dir
43
+
44
+
45
+ def _cleanup_old_tts_files(root: Path) -> None:
46
+ cutoff = time.time() - TTS_TTL_SECONDS
47
+ if not root.exists():
48
+ return
49
+
50
+ for file_path in root.rglob("*.mp3"):
51
+ try:
52
+ if file_path.stat().st_mtime < cutoff:
53
+ file_path.unlink(missing_ok=True)
54
+ except OSError as exc:
55
+ logger.warning("Failed to clean old TTS file %s: %s", file_path, exc)
56
+
57
+
58
+ @router.post("/generate", response_model=TextToSpeechResponse)
59
+ async def generate_tts_audio(
60
+ request: TextToSpeechRequest,
61
+ current_user: User = Depends(get_current_user),
62
+ ):
63
+ text = request.text.strip()
64
+ if not text:
65
+ raise HTTPException(status_code=400, detail="Text is required.")
66
+
67
+ _cleanup_old_tts_files(settings.output_dir / "tts")
68
+
69
+ if len(text) > MAX_TTS_CHARS:
70
+ text = text[:MAX_TTS_CHARS]
71
+
72
+ audio_id = str(uuid.uuid4())
73
+ output_file = _user_tts_dir(current_user) / f"{audio_id}.mp3"
74
+
75
+ try:
76
+ service = TextToSpeechService()
77
+ await service.generate_audio(
78
+ text=text,
79
+ output_file=str(output_file),
80
+ rate=request.rate,
81
+ )
82
+ except ValueError as exc:
83
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
84
+ except Exception as exc:
85
+ logger.error("TTS generation failed: %s", exc)
86
+ raise HTTPException(
87
+ status_code=500,
88
+ detail="Failed to generate speech audio.",
89
+ ) from exc
90
+
91
+ return TextToSpeechResponse(
92
+ audioId=audio_id,
93
+ audioUrl=f"/tts/audio/{audio_id}",
94
+ )
95
+
96
+
97
+ @router.get("/audio/{audio_id}")
98
+ async def get_tts_audio(
99
+ audio_id: str,
100
+ current_user: User = Depends(get_current_user),
101
+ ):
102
+ if not re.fullmatch(r"[A-Fa-f0-9-]{36}", audio_id):
103
+ raise HTTPException(status_code=400, detail="Invalid audio id.")
104
+
105
+ audio_file = _user_tts_dir(current_user) / f"{audio_id}.mp3"
106
+ if not audio_file.exists():
107
+ raise HTTPException(status_code=404, detail="Audio file not found.")
108
+
109
+ return FileResponse(
110
+ path=audio_file,
111
+ media_type="audio/mpeg",
112
+ filename=f"{audio_id}.mp3",
113
+ )
src/db/models.py CHANGED
@@ -19,7 +19,6 @@ class User(BaseModel):
19
  age: Optional[int] = Field(default=None)
20
  gender: Optional[str] = Field(default=None)
21
  created_at: datetime = Field(default_factory=datetime.utcnow)
22
- notesCount: Optional[int] = Field(default=0)
23
 
24
  class Config:
25
  from_attributes = True
 
19
  age: Optional[int] = Field(default=None)
20
  gender: Optional[str] = Field(default=None)
21
  created_at: datetime = Field(default_factory=datetime.utcnow)
 
22
 
23
  class Config:
24
  from_attributes = True