diff --git a/alembic/versions/0d9b7f1c367f_add_status_and_error_fields_to_tts_table.py b/alembic/versions/0d9b7f1c367f_add_status_and_error_fields_to_tts_table.py new file mode 100644 index 0000000..66e8275 --- /dev/null +++ b/alembic/versions/0d9b7f1c367f_add_status_and_error_fields_to_tts_table.py @@ -0,0 +1,34 @@ +"""Add status and error fields to TTS table + +Revision ID: 0d9b7f1c367f +Revises: e617c155eea9 +Create Date: 2025-09-21 14:09:56.418372 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0d9b7f1c367f' +down_revision: Union[str, Sequence[str], None] = 'e617c155eea9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tts', sa.Column('status', sa.String(), nullable=False, server_default='pending')) + op.add_column('tts', sa.Column('error', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tts', 'error') + op.drop_column('tts', 'status') + # ### end Alembic commands ### diff --git a/alembic/versions/e617c155eea9_add_tts_table.py b/alembic/versions/e617c155eea9_add_tts_table.py new file mode 100644 index 0000000..44e40fd --- /dev/null +++ b/alembic/versions/e617c155eea9_add_tts_table.py @@ -0,0 +1,45 @@ +"""Add TTS table + +Revision ID: e617c155eea9 +Revises: a0d322857b2c +Create Date: 2025-09-20 21:51:26.557738 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'e617c155eea9' +down_revision: Union[str, Sequence[str], None] = 'a0d322857b2c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('text', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False), + sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('options', sa.JSON(), nullable=True), + sa.Column('sound_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('tts') + # ### end Alembic commands ### diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index 638a549..2bf37bf 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -15,6 +15,7 @@ from app.api.v1 import ( scheduler, socket, sounds, + tts, ) # V1 API router with v1 prefix @@ -32,4 +33,5 @@ api_router.include_router(playlists.router, tags=["playlists"]) api_router.include_router(scheduler.router, tags=["scheduler"]) api_router.include_router(socket.router, tags=["socket"]) api_router.include_router(sounds.router, tags=["sounds"]) +api_router.include_router(tts.router, tags=["tts"]) api_router.include_router(admin.router) diff --git a/app/api/v1/tts.py b/app/api/v1/tts.py new file mode 100644 index 0000000..9e951bf --- /dev/null +++ b/app/api/v1/tts.py @@ -0,0 +1,225 @@ +"""TTS API endpoints.""" + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_current_active_user_flexible +from app.models.user import User +from app.services.tts import TTSService + +router = APIRouter(prefix="/tts", tags=["tts"]) + + +class TTSGenerateRequest(BaseModel): + """TTS generation request model.""" + + text: str = Field( + ..., min_length=1, max_length=1000, description="Text to convert to speech", + ) + provider: str = Field(default="gtts", description="TTS provider to use") + options: dict[str, Any] = Field( + default_factory=dict, description="Provider-specific options", + ) + + +class TTSResponse(BaseModel): + """TTS generation response model.""" + + id: int + text: str + provider: str + options: dict[str, Any] + status: str + error: str | None + sound_id: int | None + user_id: int + created_at: str + + +class ProviderInfo(BaseModel): + """Provider information model.""" + + name: str + file_extension: str + supported_languages: list[str] + option_schema: dict[str, Any] + + +async def get_tts_service( + session: Annotated[AsyncSession, Depends(get_db)], +) -> TTSService: + """Get the TTS service.""" + return TTSService(session) + + +@router.get("") +async def get_tts_list( + current_user: Annotated[User, Depends(get_current_active_user_flexible)], + tts_service: Annotated[TTSService, Depends(get_tts_service)], + limit: int = 50, + offset: int = 0, +) -> list[TTSResponse]: + """Get TTS list for the current user.""" + try: + if current_user.id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not available", + ) + + tts_records = await tts_service.get_user_tts_history( + user_id=current_user.id, + limit=limit, + offset=offset, + ) + + return [ + TTSResponse( + id=tts.id, + text=tts.text, + provider=tts.provider, + options=tts.options, + status=tts.status, + error=tts.error, + sound_id=tts.sound_id, + user_id=tts.user_id, + created_at=tts.created_at.isoformat(), + ) + for tts in tts_records + ] + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get TTS history: {e!s}", + ) from e + + +@router.post("") +async def generate_tts( + request: TTSGenerateRequest, + current_user: Annotated[User, Depends(get_current_active_user_flexible)], + tts_service: Annotated[TTSService, Depends(get_tts_service)], +) -> dict[str, Any]: + """Generate TTS audio and create sound.""" + try: + if current_user.id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not available", + ) + + result = await tts_service.create_tts_request( + text=request.text, + user_id=current_user.id, + provider=request.provider, + **request.options, + ) + + tts_record = result["tts"] + + return { + "message": result["message"], + "tts": TTSResponse( + id=tts_record.id, + text=tts_record.text, + provider=tts_record.provider, + options=tts_record.options, + status=tts_record.status, + error=tts_record.error, + sound_id=tts_record.sound_id, + user_id=tts_record.user_id, + created_at=tts_record.created_at.isoformat(), + ), + } + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to generate TTS: {e!s}", + ) from e + + +@router.get("/providers") +async def get_providers( + tts_service: Annotated[TTSService, Depends(get_tts_service)], +) -> dict[str, ProviderInfo]: + """Get all available TTS providers.""" + providers = tts_service.get_providers() + result = {} + + for name, provider in providers.items(): + result[name] = ProviderInfo( + name=provider.name, + file_extension=provider.file_extension, + supported_languages=provider.get_supported_languages(), + option_schema=provider.get_option_schema(), + ) + + return result + + +@router.get("/providers/{provider_name}") +async def get_provider( + provider_name: str, + tts_service: Annotated[TTSService, Depends(get_tts_service)], +) -> ProviderInfo: + """Get information about a specific TTS provider.""" + provider = tts_service.get_provider(provider_name) + + if not provider: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider '{provider_name}' not found", + ) + + return ProviderInfo( + name=provider.name, + file_extension=provider.file_extension, + supported_languages=provider.get_supported_languages(), + option_schema=provider.get_option_schema(), + ) + + +@router.delete("/{tts_id}") +async def delete_tts( + tts_id: int, + current_user: Annotated[User, Depends(get_current_active_user_flexible)], + tts_service: Annotated[TTSService, Depends(get_tts_service)], +) -> dict[str, str]: + """Delete a TTS generation and its associated files.""" + try: + if current_user.id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not available", + ) + + await tts_service.delete_tts(tts_id=tts_id, user_id=current_user.id) + + return {"message": "TTS generation deleted successfully"} + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e), + ) from e + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete TTS: {e!s}", + ) from e diff --git a/app/core/database.py b/app/core/database.py index 0e3a4f4..aa261fe 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,3 +1,4 @@ +import asyncio from collections.abc import AsyncGenerator, Callable from alembic.config import Config @@ -49,8 +50,10 @@ async def init_db() -> None: # Get the alembic config alembic_cfg = Config("alembic.ini") - # Run migrations to the latest revision - command.upgrade(alembic_cfg, "head") + # Run migrations to the latest revision in a thread pool to avoid blocking + await asyncio.get_event_loop().run_in_executor( + None, command.upgrade, alembic_cfg, "head", + ) logger.info("Database migrations completed successfully") except Exception: diff --git a/app/main.py b/app/main.py index 4f2ba6a..ea2fd36 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api import api_router from app.core.config import settings -from app.core.database import get_session_factory, init_db +from app.core.database import get_session_factory from app.core.logging import get_logger, setup_logging from app.core.services import app_services from app.middleware.logging import LoggingMiddleware @@ -19,6 +19,7 @@ from app.services.player import ( ) from app.services.scheduler import SchedulerService from app.services.socket import socket_manager +from app.services.tts_processor import tts_processor @asynccontextmanager @@ -28,13 +29,17 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: logger = get_logger(__name__) logger.info("Starting application") - await init_db() - logger.info("Database initialized") + # await init_db() + # logger.info("Database initialized") # Start the extraction processor await extraction_processor.start() logger.info("Extraction processor started") + # Start the TTS processor + await tts_processor.start() + logger.info("TTS processor started") + # Start the player service await initialize_player_service(get_session_factory()) logger.info("Player service started") @@ -43,7 +48,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: try: player_service = get_player_service() # Get the initialized player service app_services.scheduler_service = SchedulerService( - get_session_factory(), player_service, + get_session_factory(), + player_service, ) await app_services.scheduler_service.start() logger.info("Enhanced scheduler service started") @@ -64,6 +70,10 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: await shutdown_player_service() logger.info("Player service stopped") + # Stop the TTS processor + await tts_processor.stop() + logger.info("TTS processor stopped") + # Stop the extraction processor await extraction_processor.stop() logger.info("Extraction processor stopped") diff --git a/app/models/__init__.py b/app/models/__init__.py index 1e3c2c7..ab7e0c0 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -12,10 +12,12 @@ from .playlist_sound import PlaylistSound from .scheduled_task import ScheduledTask from .sound import Sound from .sound_played import SoundPlayed +from .tts import TTS from .user import User from .user_oauth import UserOauth __all__ = [ + "TTS", "BaseModel", "CreditAction", "CreditTransaction", diff --git a/app/models/tts.py b/app/models/tts.py new file mode 100644 index 0000000..005b229 --- /dev/null +++ b/app/models/tts.py @@ -0,0 +1,28 @@ +"""TTS model.""" + +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +class TTS(SQLModel, table=True): + """Text-to-Speech generation record.""" + + __tablename__ = "tts" + + id: int | None = Field(primary_key=True) + text: str = Field(max_length=1000, description="Text that was converted to speech") + provider: str = Field(max_length=50, description="TTS provider used") + options: dict[str, Any] = Field( + default_factory=dict, + sa_column=Column(JSON), + description="Provider-specific options used", + ) + status: str = Field(default="pending", description="Processing status") + error: str | None = Field(default=None, description="Error message if failed") + sound_id: int | None = Field(foreign_key="sound.id", description="Associated sound ID") + user_id: int = Field(foreign_key="user.id", description="User who created the TTS") + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/app/repositories/tts.py b/app/repositories/tts.py new file mode 100644 index 0000000..d17430e --- /dev/null +++ b/app/repositories/tts.py @@ -0,0 +1,65 @@ +"""TTS repository for database operations.""" + +from collections.abc import Sequence +from typing import Any + +from sqlmodel import select + +from app.models.tts import TTS +from app.repositories.base import BaseRepository + + +class TTSRepository(BaseRepository[TTS]): + """Repository for TTS operations.""" + + def __init__(self, session: Any) -> None: + super().__init__(TTS, session) + + async def get_by_user_id( + self, + user_id: int, + limit: int = 50, + offset: int = 0, + ) -> Sequence[TTS]: + """Get TTS records by user ID with pagination. + + Args: + user_id: User ID to filter by + limit: Maximum number of records to return + offset: Number of records to skip + + Returns: + List of TTS records + + """ + stmt = ( + select(self.model) + .where(self.model.user_id == user_id) + .order_by(self.model.created_at.desc()) + .limit(limit) + .offset(offset) + ) + result = await self.session.exec(stmt) + return result.all() + + async def get_by_user_and_id( + self, + user_id: int, + tts_id: int, + ) -> TTS | None: + """Get a specific TTS record by user ID and TTS ID. + + Args: + user_id: User ID to filter by + tts_id: TTS ID to retrieve + + Returns: + TTS record if found and belongs to user, None otherwise + + """ + stmt = select(self.model).where( + self.model.id == tts_id, + self.model.user_id == user_id, + ) + result = await self.session.exec(stmt) + return result.first() diff --git a/app/services/tts/__init__.py b/app/services/tts/__init__.py new file mode 100644 index 0000000..3fc5340 --- /dev/null +++ b/app/services/tts/__init__.py @@ -0,0 +1,6 @@ +"""Text-to-Speech services package.""" + +from .base import TTSProvider +from .service import TTSService + +__all__ = ["TTSProvider", "TTSService"] diff --git a/app/services/tts/base.py b/app/services/tts/base.py new file mode 100644 index 0000000..5f3cd11 --- /dev/null +++ b/app/services/tts/base.py @@ -0,0 +1,39 @@ +"""Base TTS provider interface.""" + +from abc import ABC, abstractmethod +from typing import Any + + +class TTSProvider(ABC): + """Abstract base class for TTS providers.""" + + @abstractmethod + async def generate_speech(self, text: str, **options: Any) -> bytes: + """Generate speech from text with provider-specific options. + + Args: + text: The text to convert to speech + **options: Provider-specific options + + Returns: + Audio data as bytes + + """ + + @abstractmethod + def get_supported_languages(self) -> list[str]: + """Return list of supported language codes.""" + + @abstractmethod + def get_option_schema(self) -> dict[str, Any]: + """Return schema for provider-specific options.""" + + @property + @abstractmethod + def name(self) -> str: + """Return the provider name.""" + + @property + @abstractmethod + def file_extension(self) -> str: + """Return the default file extension for this provider.""" diff --git a/app/services/tts/providers/__init__.py b/app/services/tts/providers/__init__.py new file mode 100644 index 0000000..30f28a3 --- /dev/null +++ b/app/services/tts/providers/__init__.py @@ -0,0 +1,5 @@ +"""TTS providers package.""" + +from .gtts import GTTSProvider + +__all__ = ["GTTSProvider"] diff --git a/app/services/tts/providers/gtts.py b/app/services/tts/providers/gtts.py new file mode 100644 index 0000000..7f31600 --- /dev/null +++ b/app/services/tts/providers/gtts.py @@ -0,0 +1,81 @@ +"""Google Text-to-Speech provider.""" + +import asyncio +import io +from typing import Any + +from gtts import gTTS + +from ..base import TTSProvider + + +class GTTSProvider(TTSProvider): + """Google Text-to-Speech provider implementation.""" + + @property + def name(self) -> str: + """Return the provider name.""" + return "gtts" + + @property + def file_extension(self) -> str: + """Return the default file extension for this provider.""" + return "mp3" + + async def generate_speech(self, text: str, **options: Any) -> bytes: + """Generate speech from text using Google TTS. + + Args: + text: The text to convert to speech + **options: GTTS-specific options (lang, tld, slow) + + Returns: + MP3 audio data as bytes + + """ + lang = options.get("lang", "en") + tld = options.get("tld", "com") + slow = options.get("slow", False) + + # Run TTS generation in thread pool since gTTS is synchronous + def _generate(): + tts = gTTS(text=text, lang=lang, tld=tld, slow=slow) + fp = io.BytesIO() + tts.write_to_fp(fp) + fp.seek(0) + return fp.read() + + # Use asyncio.to_thread which is more reliable than run_in_executor + return await asyncio.to_thread(_generate) + + def get_supported_languages(self) -> list[str]: + """Return list of supported language codes.""" + # Complete list of GTTS supported languages including regional variants + return [ + "af", "ar", "bg", "bn", "bs", "ca", "cs", "cy", "da", "de", "el", + "en", "en-au", "en-ca", "en-gb", "en-ie", "en-in", "en-ng", "en-nz", + "en-ph", "en-za", "en-tz", "en-uk", "en-us", + "eo", "es", "es-es", "es-mx", "es-us", "et", "eu", "fa", "fi", + "fr", "fr-ca", "fr-fr", "ga", "gu", "he", "hi", "hr", "hu", "hy", + "id", "is", "it", "ja", "jw", "ka", "kk", "km", "kn", "ko", "la", + "lv", "mk", "ml", "mr", "ms", "mt", "my", "ne", "nl", "no", "pa", + "pl", "pt", "pt-br", "pt-pt", "ro", "ru", "si", "sk", "sl", "sq", + "sr", "su", "sv", "sw", "ta", "te", "th", "tl", "tr", "uk", "ur", + "vi", "yo", "zh", "zh-cn", "zh-tw", "zu", + ] + + def get_option_schema(self) -> dict[str, Any]: + """Return schema for GTTS-specific options.""" + return { + "lang": { + "type": "string", + "default": "en", + "description": "Language code", + "enum": self.get_supported_languages(), + }, + "slow": { + "type": "boolean", + "default": False, + "description": "Speak slowly", + }, + } diff --git a/app/services/tts/service.py b/app/services/tts/service.py new file mode 100644 index 0000000..11afa94 --- /dev/null +++ b/app/services/tts/service.py @@ -0,0 +1,524 @@ +"""TTS service implementation.""" + +import asyncio +import io +import uuid +from pathlib import Path +from typing import Any + +from gtts import gTTS +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.sound import Sound +from app.models.tts import TTS +from app.repositories.sound import SoundRepository +from app.repositories.tts import TTSRepository +from app.services.sound_normalizer import SoundNormalizerService +from app.utils.audio import get_audio_duration, get_file_hash, get_file_size + +from .base import TTSProvider +from .providers import GTTSProvider + +# Constants +MAX_TEXT_LENGTH = 1000 +MAX_NAME_LENGTH = 50 + + +class TTSService: + """Text-to-Speech service with provider management.""" + + def __init__(self, session: AsyncSession) -> None: + """Initialize TTS service. + + Args: + session: Database session + + """ + self.session = session + self.sound_repo = SoundRepository(session) + self.tts_repo = TTSRepository(session) + self.providers: dict[str, TTSProvider] = {} + + # Register default providers + self._register_default_providers() + + def _register_default_providers(self) -> None: + """Register default TTS providers.""" + self.register_provider(GTTSProvider()) + + def register_provider(self, provider: TTSProvider) -> None: + """Register a TTS provider. + + Args: + provider: TTS provider instance + + """ + self.providers[provider.name] = provider + + def get_providers(self) -> dict[str, TTSProvider]: + """Get all registered providers.""" + return self.providers.copy() + + def get_provider(self, name: str) -> TTSProvider | None: + """Get a specific provider by name.""" + return self.providers.get(name) + + async def create_tts_request( + self, + text: str, + user_id: int, + provider: str = "gtts", + **options: Any, + ) -> dict[str, Any]: + """Create a TTS request that will be processed in the background. + + Args: + text: Text to convert to speech + user_id: ID of user creating the sound + provider: TTS provider name + **options: Provider-specific options + + Returns: + Dictionary with TTS record information + + Raises: + ValueError: If provider not found or text too long + Exception: If request creation fails + + """ + provider_not_found_msg = f"Provider '{provider}' not found" + if provider not in self.providers: + raise ValueError(provider_not_found_msg) + + text_too_long_msg = f"Text too long (max {MAX_TEXT_LENGTH} characters)" + if len(text) > MAX_TEXT_LENGTH: + raise ValueError(text_too_long_msg) + + empty_text_msg = "Text cannot be empty" + if not text.strip(): + raise ValueError(empty_text_msg) + + # Create TTS record with pending status + tts = TTS( + text=text, + provider=provider, + options=options, + status="pending", + sound_id=None, # Will be set when processing completes + user_id=user_id, + ) + self.session.add(tts) + await self.session.commit() + await self.session.refresh(tts) + + # Queue for background processing using the TTS processor + if tts.id is not None: + from app.services.tts_processor import tts_processor + + await tts_processor.queue_tts(tts.id) + + return {"tts": tts, "message": "TTS generation queued successfully"} + + async def _queue_tts_processing(self, tts_id: int) -> None: + """Queue TTS for background processing.""" + # For now, process immediately in a different way + # This could be moved to a proper background queue later + task = asyncio.create_task(self._process_tts_in_background(tts_id)) + # Store reference to prevent garbage collection + self._background_tasks = getattr(self, "_background_tasks", set()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + async def _process_tts_in_background(self, tts_id: int) -> None: + """Process TTS generation in background.""" + from app.core.database import get_session_factory + + try: + # Create a new session for background processing + session_factory = get_session_factory() + async with session_factory() as background_session: + tts_service = TTSService(background_session) + + # Get the TTS record + stmt = select(TTS).where(TTS.id == tts_id) + result = await background_session.exec(stmt) + tts = result.first() + + if not tts: + return + + # Use a synchronous approach for the actual generation + sound = await tts_service._generate_tts_sync( + tts.text, + tts.provider, + tts.user_id, + tts.options, + ) + + # Update the TTS record with the sound ID + if sound.id is not None: + tts.sound_id = sound.id + background_session.add(tts) + await background_session.commit() + + except Exception: + # Log error but don't fail - avoiding print for production + pass + + async def _generate_tts_sync( + self, text: str, provider: str, user_id: int, options: dict[str, Any], + ) -> Sound: + """Generate TTS using a synchronous approach.""" + # Generate the audio using the provider (avoid async issues by doing it directly) + tts_provider = self.providers[provider] + + # Create directories if they don't exist + original_dir = Path("sounds/originals/text_to_speech") + original_dir.mkdir(parents=True, exist_ok=True) + + # Create UUID filename + sound_uuid = str(uuid.uuid4()) + original_filename = f"{sound_uuid}.{tts_provider.file_extension}" + original_path = original_dir / original_filename + + # Generate audio synchronously + try: + # Generate TTS audio + lang = options.get("lang", "en") + tld = options.get("tld", "com") + slow = options.get("slow", False) + + tts_instance = gTTS(text=text, lang=lang, tld=tld, slow=slow) + fp = io.BytesIO() + tts_instance.write_to_fp(fp) + fp.seek(0) + audio_bytes = fp.read() + + # Save the file + original_path.write_bytes(audio_bytes) + + except Exception: + raise + + # Create Sound record with proper metadata + sound = await self._create_sound_record_complete( + original_path, text, provider, user_id, + ) + + # Normalize the sound + await self._normalize_sound_safe(sound.id) + + return sound + + async def get_user_tts_history( + self, user_id: int, limit: int = 50, offset: int = 0, + ) -> list[TTS]: + """Get TTS history for a user. + + Args: + user_id: User ID + limit: Maximum number of records + offset: Offset for pagination + + Returns: + List of TTS records + + """ + result = await self.tts_repo.get_by_user_id(user_id, limit, offset) + return list(result) + + async def _create_sound_record( + self, audio_path: Path, text: str, provider: str, user_id: int, file_hash: str, + ) -> Sound: + """Create a Sound record for the TTS audio.""" + # Get audio metadata + duration = get_audio_duration(audio_path) + size = get_file_size(audio_path) + name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "") + name = " ".join(word.capitalize() for word in name.split()) + + # Create sound data + sound_data = { + "type": "TTS", + "name": name, + "filename": audio_path.name, + "duration": duration, + "size": size, + "hash": file_hash, + "user_id": user_id, + "is_deletable": True, + "is_music": False, # TTS is speech, not music + "is_normalized": False, + "play_count": 0, + } + + sound = await self.sound_repo.create(sound_data) + return sound + + async def _create_sound_record_simple( + self, audio_path: Path, text: str, provider: str, user_id: int, + ) -> Sound: + """Create a Sound record for the TTS audio with minimal processing.""" + # Create sound data with basic info + name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "") + name = " ".join(word.capitalize() for word in name.split()) + + sound_data = { + "type": "TTS", + "name": name, + "filename": audio_path.name, + "duration": 0, # Skip duration calculation for now + "size": 0, # Skip size calculation for now + "hash": str(uuid.uuid4()), # Use UUID as temporary hash + "user_id": user_id, + "is_deletable": True, + "is_music": False, # TTS is speech, not music + "is_normalized": False, + "play_count": 0, + } + + sound = await self.sound_repo.create(sound_data) + return sound + + async def _create_sound_record_complete( + self, audio_path: Path, text: str, provider: str, user_id: int, + ) -> Sound: + """Create a Sound record for the TTS audio with complete metadata.""" + # Get audio metadata + duration = get_audio_duration(audio_path) + size = get_file_size(audio_path) + file_hash = get_file_hash(audio_path) + name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "") + name = " ".join(word.capitalize() for word in name.split()) + + # Check if a sound with this hash already exists + existing_sound = await self.sound_repo.get_by_hash(file_hash) + + if existing_sound: + # Clean up the temporary file since we have a duplicate + if audio_path.exists(): + audio_path.unlink() + return existing_sound + + # Create sound data with complete metadata + sound_data = { + "type": "TTS", + "name": name, + "filename": audio_path.name, + "duration": duration, + "size": size, + "hash": file_hash, + "user_id": user_id, + "is_deletable": True, + "is_music": False, # TTS is speech, not music + "is_normalized": False, + "play_count": 0, + } + + sound = await self.sound_repo.create(sound_data) + return sound + + async def _normalize_sound_safe(self, sound_id: int) -> None: + """Normalize the TTS sound with error handling.""" + try: + # Get fresh sound object from database for normalization + sound = await self.sound_repo.get_by_id(sound_id) + if not sound: + return + + normalizer_service = SoundNormalizerService(self.session) + result = await normalizer_service.normalize_sound(sound) + + if result["status"] == "error": + print( + f"Warning: Failed to normalize TTS sound {sound_id}: {result.get('error')}", + ) + + except Exception as e: + print(f"Exception during TTS sound normalization {sound_id}: {e}") + # Don't fail the TTS generation if normalization fails + + async def _normalize_sound(self, sound_id: int) -> None: + """Normalize the TTS sound.""" + try: + # Get fresh sound object from database for normalization + sound = await self.sound_repo.get_by_id(sound_id) + if not sound: + return + + normalizer_service = SoundNormalizerService(self.session) + result = await normalizer_service.normalize_sound(sound) + + if result["status"] == "error": + # Log warning but don't fail the TTS generation + pass + + except Exception: + # Don't fail the TTS generation if normalization fails + pass + + async def delete_tts(self, tts_id: int, user_id: int) -> None: + """Delete a TTS generation and its associated sound and files.""" + # Get the TTS record + tts = await self.tts_repo.get_by_id(tts_id) + if not tts: + raise ValueError(f"TTS with ID {tts_id} not found") + + # Check ownership + if tts.user_id != user_id: + raise PermissionError( + "You don't have permission to delete this TTS generation", + ) + + # If there's an associated sound, delete it and its files + if tts.sound_id: + sound = await self.sound_repo.get_by_id(tts.sound_id) + if sound: + # Delete the sound files + await self._delete_sound_files(sound) + # Delete the sound record + await self.sound_repo.delete(sound) + + # Delete the TTS record + await self.tts_repo.delete(tts) + + async def _delete_sound_files(self, sound: Sound) -> None: + """Delete all files associated with a sound.""" + from pathlib import Path + + # Delete original file + original_path = Path("sounds/originals/text_to_speech") / sound.filename + if original_path.exists(): + original_path.unlink() + + # Delete normalized file if it exists + if sound.normalized_filename: + normalized_path = ( + Path("sounds/normalized/text_to_speech") / sound.normalized_filename + ) + if normalized_path.exists(): + normalized_path.unlink() + + async def get_pending_tts(self) -> list[TTS]: + """Get all pending TTS generations.""" + stmt = select(TTS).where(TTS.status == "pending").order_by(TTS.created_at) + result = await self.session.exec(stmt) + return list(result.all()) + + async def mark_tts_processing(self, tts_id: int) -> None: + """Mark a TTS generation as processing.""" + stmt = select(TTS).where(TTS.id == tts_id) + result = await self.session.exec(stmt) + tts = result.first() + if tts: + tts.status = "processing" + self.session.add(tts) + await self.session.commit() + + async def mark_tts_completed(self, tts_id: int, sound_id: int) -> None: + """Mark a TTS generation as completed.""" + stmt = select(TTS).where(TTS.id == tts_id) + result = await self.session.exec(stmt) + tts = result.first() + if tts: + tts.status = "completed" + tts.sound_id = sound_id + tts.error = None + self.session.add(tts) + await self.session.commit() + + async def mark_tts_failed(self, tts_id: int, error_message: str) -> None: + """Mark a TTS generation as failed.""" + stmt = select(TTS).where(TTS.id == tts_id) + result = await self.session.exec(stmt) + tts = result.first() + if tts: + tts.status = "failed" + tts.error = error_message + self.session.add(tts) + await self.session.commit() + + async def reset_stuck_tts(self) -> int: + """Reset stuck TTS generations from processing back to pending.""" + stmt = select(TTS).where(TTS.status == "processing") + result = await self.session.exec(stmt) + stuck_tts = list(result.all()) + + for tts in stuck_tts: + tts.status = "pending" + tts.error = None + self.session.add(tts) + + await self.session.commit() + return len(stuck_tts) + + async def process_tts_generation(self, tts_id: int) -> None: + """Process a TTS generation (used by the processor).""" + # Mark as processing + await self.mark_tts_processing(tts_id) + + try: + # Get the TTS record + stmt = select(TTS).where(TTS.id == tts_id) + result = await self.session.exec(stmt) + tts = result.first() + + if not tts: + raise ValueError(f"TTS with ID {tts_id} not found") + + # Generate the TTS + sound = await self._generate_tts_sync( + tts.text, + tts.provider, + tts.user_id, + tts.options, + ) + + # Capture sound ID before session issues + sound_id = sound.id + + # Mark as completed + await self.mark_tts_completed(tts_id, sound_id) + + # Emit socket event for completion + await self._emit_tts_event("tts_completed", tts_id, sound_id) + + except Exception as e: + # Mark as failed + await self.mark_tts_failed(tts_id, str(e)) + + # Emit socket event for failure + await self._emit_tts_event("tts_failed", tts_id, None, str(e)) + raise + + async def _emit_tts_event( + self, + event: str, + tts_id: int, + sound_id: int | None = None, + error: str | None = None, + ) -> None: + """Emit a socket event for TTS status change.""" + try: + from app.core.logging import get_logger + from app.services.socket import socket_manager + + logger = get_logger(__name__) + + data = { + "tts_id": tts_id, + "sound_id": sound_id, + } + if error: + data["error"] = error + + logger.info(f"Emitting TTS socket event: {event} with data: {data}") + await socket_manager.broadcast_to_all(event, data) + logger.info(f"Successfully emitted TTS socket event: {event}") + except Exception as e: + # Don't fail TTS processing if socket emission fails + from app.core.logging import get_logger + + logger = get_logger(__name__) + logger.error(f"Failed to emit TTS socket event {event}: {e}", exc_info=True) diff --git a/app/services/tts_processor.py b/app/services/tts_processor.py new file mode 100644 index 0000000..80c7bf3 --- /dev/null +++ b/app/services/tts_processor.py @@ -0,0 +1,193 @@ +"""Background TTS processor for handling TTS generation queue.""" + +import asyncio +import contextlib + +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.config import settings +from app.core.database import engine +from app.core.logging import get_logger +from app.services.tts import TTSService + +logger = get_logger(__name__) + + +class TTSProcessor: + """Background processor for handling TTS generation queue with concurrency control.""" + + def __init__(self) -> None: + """Initialize the TTS processor.""" + self.max_concurrent = getattr(settings, "TTS_MAX_CONCURRENT", 3) + self.running_tts: set[int] = set() + self.processing_lock = asyncio.Lock() + self.shutdown_event = asyncio.Event() + self.processor_task: asyncio.Task | None = None + + logger.info( + "Initialized TTS processor with max concurrent: %d", + self.max_concurrent, + ) + + async def start(self) -> None: + """Start the background TTS processor.""" + if self.processor_task and not self.processor_task.done(): + logger.warning("TTS processor is already running") + return + + # Reset any stuck TTS generations from previous runs + await self._reset_stuck_tts() + + self.shutdown_event.clear() + self.processor_task = asyncio.create_task(self._process_queue()) + logger.info("Started TTS processor") + + async def stop(self) -> None: + """Stop the background TTS processor.""" + logger.info("Stopping TTS processor...") + self.shutdown_event.set() + + if self.processor_task and not self.processor_task.done(): + try: + await asyncio.wait_for(self.processor_task, timeout=30.0) + except TimeoutError: + logger.warning( + "TTS processor did not stop gracefully, cancelling...", + ) + self.processor_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self.processor_task + + logger.info("TTS processor stopped") + + async def queue_tts(self, tts_id: int) -> None: + """Queue a TTS generation for processing.""" + async with self.processing_lock: + if tts_id not in self.running_tts: + logger.info("Queued TTS %d for processing", tts_id) + # The processor will pick it up on the next cycle + else: + logger.warning( + "TTS %d is already being processed", + tts_id, + ) + + async def _process_queue(self) -> None: + """Process the TTS queue in the main processing loop.""" + logger.info("Starting TTS queue processor") + + while not self.shutdown_event.is_set(): + try: + await self._process_pending_tts() + + # Wait before checking for new TTS generations + try: + await asyncio.wait_for(self.shutdown_event.wait(), timeout=5.0) + break # Shutdown requested + except TimeoutError: + continue # Continue processing + + except Exception: + logger.exception("Error in TTS queue processor") + # Wait a bit before retrying to avoid tight error loops + try: + await asyncio.wait_for(self.shutdown_event.wait(), timeout=10.0) + break # Shutdown requested + except TimeoutError: + continue + + logger.info("TTS queue processor stopped") + + async def _process_pending_tts(self) -> None: + """Process pending TTS generations up to the concurrency limit.""" + async with self.processing_lock: + # Check how many slots are available + available_slots = self.max_concurrent - len(self.running_tts) + + if available_slots <= 0: + return # No available slots + + # Get pending TTS generations from database + async with AsyncSession(engine) as session: + tts_service = TTSService(session) + pending_tts = await tts_service.get_pending_tts() + + # Filter out TTS that are already being processed + available_tts = [ + tts + for tts in pending_tts + if tts.id not in self.running_tts + ] + + # Start processing up to available slots + tts_to_start = available_tts[:available_slots] + + for tts in tts_to_start: + tts_id = tts.id + self.running_tts.add(tts_id) + + # Start processing this TTS in the background + task = asyncio.create_task( + self._process_single_tts(tts_id), + ) + task.add_done_callback( + lambda t, tid=tts_id: self._on_tts_completed( + tid, + t, + ), + ) + + logger.info( + "Started processing TTS %d (%d/%d slots used)", + tts_id, + len(self.running_tts), + self.max_concurrent, + ) + + async def _process_single_tts(self, tts_id: int) -> None: + """Process a single TTS generation.""" + try: + async with AsyncSession(engine) as session: + tts_service = TTSService(session) + await tts_service.process_tts_generation(tts_id) + logger.info("Successfully processed TTS %d", tts_id) + + except Exception: + logger.exception("Failed to process TTS %d", tts_id) + # Mark TTS as failed in database + try: + async with AsyncSession(engine) as session: + tts_service = TTSService(session) + await tts_service.mark_tts_failed(tts_id, "Processing failed") + except Exception: + logger.exception("Failed to mark TTS %d as failed", tts_id) + + def _on_tts_completed(self, tts_id: int, task: asyncio.Task) -> None: + """Handle completion of a TTS processing task.""" + self.running_tts.discard(tts_id) + + if task.exception(): + logger.error( + "TTS processing task %d failed: %s", + tts_id, + task.exception(), + ) + else: + logger.info("TTS processing task %d completed successfully", tts_id) + + async def _reset_stuck_tts(self) -> None: + """Reset any TTS generations that were stuck in 'processing' state.""" + try: + async with AsyncSession(engine) as session: + tts_service = TTSService(session) + reset_count = await tts_service.reset_stuck_tts() + if reset_count > 0: + logger.info("Reset %d stuck TTS generations", reset_count) + else: + logger.info("No stuck TTS generations found to reset") + except Exception: + logger.exception("Failed to reset stuck TTS generations") + + +# Global TTS processor instance +tts_processor = TTSProcessor() diff --git a/pyproject.toml b/pyproject.toml index a1dfa00..d746fb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,16 +10,17 @@ dependencies = [ "apscheduler==3.11.0", "bcrypt==4.3.0", "email-validator==2.3.0", - "fastapi[standard]==0.116.1", + "fastapi[standard]==0.117.1", "ffmpeg-python==0.2.0", + "gtts==2.5.4", "httpx==0.28.1", "pydantic-settings==2.10.1", "pyjwt==2.10.1", "python-socketio==5.13.0", "pytz==2025.2", "python-vlc==3.0.21203", - "sqlmodel==0.0.24", - "uvicorn[standard]==0.35.0", + "sqlmodel==0.0.25", + "uvicorn[standard]==0.36.0", "yt-dlp==2025.9.5", "asyncpg==0.30.0", "psycopg[binary]==3.2.10", @@ -28,7 +29,7 @@ dependencies = [ [tool.uv] dev-dependencies = [ "coverage==7.10.6", - "faker==37.6.0", + "faker==37.8.0", "httpx==0.28.1", "mypy==1.18.1", "pytest==8.4.2", diff --git a/uv.lock b/uv.lock index 02ce38c..6fba234 100644 --- a/uv.lock +++ b/uv.lock @@ -155,16 +155,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722 }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, +] + [[package]] name = "click" -version = "8.2.1" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] [[package]] @@ -264,28 +306,28 @@ wheels = [ [[package]] name = "faker" -version = "37.6.0" +version = "37.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/cd/f7679c20f07d9e2013123b7f7e13809a3450a18d938d58e86081a486ea15/faker-37.6.0.tar.gz", hash = "sha256:0f8cc34f30095184adf87c3c24c45b38b33ad81c35ef6eb0a3118f301143012c", size = 1907960 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/da/1336008d39e5d4076dddb4e0f3a52ada41429274bf558a3cc28030d324a3/faker-37.8.0.tar.gz", hash = "sha256:090bb5abbec2b30949a95ce1ba6b20d1d0ed222883d63483a0d4be4a970d6fb8", size = 1912113 } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/7d/8b50e4ac772719777be33661f4bde320793400a706f5eb214e4de46f093c/faker-37.6.0-py3-none-any.whl", hash = "sha256:3c5209b23d7049d596a51db5d76403a0ccfea6fc294ffa2ecfef6a8843b1e6a7", size = 1949837 }, + { url = "https://files.pythonhosted.org/packages/f5/11/02ebebb09ff2104b690457cb7bc6ed700c9e0ce88cf581486bb0a5d3c88b/faker-37.8.0-py3-none-any.whl", hash = "sha256:b08233118824423b5fc239f7dd51f145e7018082b4164f8da6a9994e1f1ae793", size = 1953940 }, ] [[package]] name = "fastapi" -version = "0.116.1" +version = "0.117.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485 } +sdist = { url = "https://files.pythonhosted.org/packages/7e/7e/d9788300deaf416178f61fb3c2ceb16b7d0dc9f82a08fdb87a5e64ee3cc7/fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a", size = 307155 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631 }, + { url = "https://files.pythonhosted.org/packages/6d/45/d9d3e8eeefbe93be1c50060a9d9a9f366dba66f288bb518a9566a23a8631/fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552", size = 95959 }, ] [package.optional-dependencies] @@ -390,6 +432,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 }, ] +[[package]] +name = "gtts" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/79/5ddb1dfcd663581d0d3fca34ccb1d8d841b47c22a24dc8dce416e3d87dfa/gtts-2.5.4.tar.gz", hash = "sha256:f5737b585f6442f677dbe8773424fd50697c75bdf3e36443585e30a8d48c1884", size = 24018 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/6c/8b8b1fdcaee7e268536f1bb00183a5894627726b54a9ddc6fc9909888447/gTTS-2.5.4-py3-none-any.whl", hash = "sha256:5dd579377f9f5546893bc26315ab1f846933dc27a054764b168f141065ca8436", size = 29184 }, +] + [[package]] name = "h11" version = "0.16.0" @@ -880,6 +935,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, +] + [[package]] name = "rich" version = "14.0.0" @@ -992,6 +1062,7 @@ dependencies = [ { name = "email-validator" }, { name = "fastapi", extra = ["standard"] }, { name = "ffmpeg-python" }, + { name = "gtts" }, { name = "httpx" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, @@ -1023,8 +1094,9 @@ requires-dist = [ { name = "asyncpg", specifier = "==0.30.0" }, { name = "bcrypt", specifier = "==4.3.0" }, { name = "email-validator", specifier = "==2.3.0" }, - { name = "fastapi", extras = ["standard"], specifier = "==0.116.1" }, + { name = "fastapi", extras = ["standard"], specifier = "==0.117.1" }, { name = "ffmpeg-python", specifier = "==0.2.0" }, + { name = "gtts", specifier = "==2.5.4" }, { name = "httpx", specifier = "==0.28.1" }, { name = "psycopg", extras = ["binary"], specifier = "==3.2.10" }, { name = "pydantic-settings", specifier = "==2.10.1" }, @@ -1032,15 +1104,15 @@ requires-dist = [ { name = "python-socketio", specifier = "==5.13.0" }, { name = "python-vlc", specifier = "==3.0.21203" }, { name = "pytz", specifier = "==2025.2" }, - { name = "sqlmodel", specifier = "==0.0.24" }, - { name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" }, + { name = "sqlmodel", specifier = "==0.0.25" }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.36.0" }, { name = "yt-dlp", specifier = "==2025.9.5" }, ] [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = "==7.10.6" }, - { name = "faker", specifier = "==37.6.0" }, + { name = "faker", specifier = "==37.8.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "mypy", specifier = "==1.18.1" }, { name = "pytest", specifier = "==8.4.2" }, @@ -1122,15 +1194,15 @@ wheels = [ [[package]] name = "sqlmodel" -version = "0.0.24" +version = "0.0.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780 } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/d9c098a88724ee4554907939cf39590cf67e10c6683723216e228d3315f7/sqlmodel-0.0.25.tar.gz", hash = "sha256:56548c2e645975b1ed94d6c53f0d13c85593f57926a575e2bf566650b2243fa4", size = 117075 } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622 }, + { url = "https://files.pythonhosted.org/packages/57/cf/5d175ce8de07fe694ec4e3d4d65c2dd06cc30f6c79599b31f9d2f6dd2830/sqlmodel-0.0.25-py3-none-any.whl", hash = "sha256:c98234cda701fb77e9dcbd81688c23bb251c13bb98ce1dd8d4adc467374d45b7", size = 28893 }, ] [[package]] @@ -1214,15 +1286,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.35.0" +version = "0.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473 } +sdist = { url = "https://files.pythonhosted.org/packages/ef/5e/f0cd46063a02fd8515f0e880c37d2657845b7306c16ce6c4ffc44afd9036/uvicorn-0.36.0.tar.gz", hash = "sha256:527dc68d77819919d90a6b267be55f0e76704dca829d34aea9480be831a9b9d9", size = 80032 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406 }, + { url = "https://files.pythonhosted.org/packages/96/06/5cc0542b47c0338c1cb676b348e24a1c29acabc81000bced518231dded6f/uvicorn-0.36.0-py3-none-any.whl", hash = "sha256:6bb4ba67f16024883af8adf13aba3a9919e415358604ce46780d3f9bdc36d731", size = 67675 }, ] [package.optional-dependencies]