Compare commits

..

10 Commits

17 changed files with 1364 additions and 29 deletions

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -15,6 +15,7 @@ from app.api.v1 import (
scheduler, scheduler,
socket, socket,
sounds, sounds,
tts,
) )
# V1 API router with v1 prefix # 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(scheduler.router, tags=["scheduler"])
api_router.include_router(socket.router, tags=["socket"]) api_router.include_router(socket.router, tags=["socket"])
api_router.include_router(sounds.router, tags=["sounds"]) api_router.include_router(sounds.router, tags=["sounds"])
api_router.include_router(tts.router, tags=["tts"])
api_router.include_router(admin.router) api_router.include_router(admin.router)

225
app/api/v1/tts.py Normal file
View File

@@ -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

View File

@@ -1,3 +1,4 @@
import asyncio
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from alembic.config import Config from alembic.config import Config
@@ -49,8 +50,10 @@ async def init_db() -> None:
# Get the alembic config # Get the alembic config
alembic_cfg = Config("alembic.ini") alembic_cfg = Config("alembic.ini")
# Run migrations to the latest revision # Run migrations to the latest revision in a thread pool to avoid blocking
command.upgrade(alembic_cfg, "head") await asyncio.get_event_loop().run_in_executor(
None, command.upgrade, alembic_cfg, "head",
)
logger.info("Database migrations completed successfully") logger.info("Database migrations completed successfully")
except Exception: except Exception:

View File

@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api import api_router from app.api import api_router
from app.core.config import settings 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.logging import get_logger, setup_logging
from app.core.services import app_services from app.core.services import app_services
from app.middleware.logging import LoggingMiddleware from app.middleware.logging import LoggingMiddleware
@@ -19,6 +19,7 @@ from app.services.player import (
) )
from app.services.scheduler import SchedulerService from app.services.scheduler import SchedulerService
from app.services.socket import socket_manager from app.services.socket import socket_manager
from app.services.tts_processor import tts_processor
@asynccontextmanager @asynccontextmanager
@@ -28,13 +29,17 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
logger = get_logger(__name__) logger = get_logger(__name__)
logger.info("Starting application") logger.info("Starting application")
await init_db() # await init_db()
logger.info("Database initialized") # logger.info("Database initialized")
# Start the extraction processor # Start the extraction processor
await extraction_processor.start() await extraction_processor.start()
logger.info("Extraction processor started") logger.info("Extraction processor started")
# Start the TTS processor
await tts_processor.start()
logger.info("TTS processor started")
# Start the player service # Start the player service
await initialize_player_service(get_session_factory()) await initialize_player_service(get_session_factory())
logger.info("Player service started") logger.info("Player service started")
@@ -43,7 +48,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
try: try:
player_service = get_player_service() # Get the initialized player service player_service = get_player_service() # Get the initialized player service
app_services.scheduler_service = SchedulerService( app_services.scheduler_service = SchedulerService(
get_session_factory(), player_service, get_session_factory(),
player_service,
) )
await app_services.scheduler_service.start() await app_services.scheduler_service.start()
logger.info("Enhanced scheduler service started") logger.info("Enhanced scheduler service started")
@@ -64,6 +70,10 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
await shutdown_player_service() await shutdown_player_service()
logger.info("Player service stopped") logger.info("Player service stopped")
# Stop the TTS processor
await tts_processor.stop()
logger.info("TTS processor stopped")
# Stop the extraction processor # Stop the extraction processor
await extraction_processor.stop() await extraction_processor.stop()
logger.info("Extraction processor stopped") logger.info("Extraction processor stopped")

View File

@@ -12,10 +12,12 @@ from .playlist_sound import PlaylistSound
from .scheduled_task import ScheduledTask from .scheduled_task import ScheduledTask
from .sound import Sound from .sound import Sound
from .sound_played import SoundPlayed from .sound_played import SoundPlayed
from .tts import TTS
from .user import User from .user import User
from .user_oauth import UserOauth from .user_oauth import UserOauth
__all__ = [ __all__ = [
"TTS",
"BaseModel", "BaseModel",
"CreditAction", "CreditAction",
"CreditTransaction", "CreditTransaction",

28
app/models/tts.py Normal file
View File

@@ -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)

65
app/repositories/tts.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,6 @@
"""Text-to-Speech services package."""
from .base import TTSProvider
from .service import TTSService
__all__ = ["TTSProvider", "TTSService"]

39
app/services/tts/base.py Normal file
View File

@@ -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."""

View File

@@ -0,0 +1,5 @@
"""TTS providers package."""
from .gtts import GTTSProvider
__all__ = ["GTTSProvider"]

View File

@@ -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",
},
}

524
app/services/tts/service.py Normal file
View File

@@ -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)

View File

@@ -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()

View File

@@ -10,16 +10,17 @@ dependencies = [
"apscheduler==3.11.0", "apscheduler==3.11.0",
"bcrypt==4.3.0", "bcrypt==4.3.0",
"email-validator==2.3.0", "email-validator==2.3.0",
"fastapi[standard]==0.116.1", "fastapi[standard]==0.117.1",
"ffmpeg-python==0.2.0", "ffmpeg-python==0.2.0",
"gtts==2.5.4",
"httpx==0.28.1", "httpx==0.28.1",
"pydantic-settings==2.10.1", "pydantic-settings==2.10.1",
"pyjwt==2.10.1", "pyjwt==2.10.1",
"python-socketio==5.13.0", "python-socketio==5.13.0",
"pytz==2025.2", "pytz==2025.2",
"python-vlc==3.0.21203", "python-vlc==3.0.21203",
"sqlmodel==0.0.24", "sqlmodel==0.0.25",
"uvicorn[standard]==0.35.0", "uvicorn[standard]==0.36.0",
"yt-dlp==2025.9.5", "yt-dlp==2025.9.5",
"asyncpg==0.30.0", "asyncpg==0.30.0",
"psycopg[binary]==3.2.10", "psycopg[binary]==3.2.10",
@@ -28,7 +29,7 @@ dependencies = [
[tool.uv] [tool.uv]
dev-dependencies = [ dev-dependencies = [
"coverage==7.10.6", "coverage==7.10.6",
"faker==37.6.0", "faker==37.8.0",
"httpx==0.28.1", "httpx==0.28.1",
"mypy==1.18.1", "mypy==1.18.1",
"pytest==8.4.2", "pytest==8.4.2",

110
uv.lock generated
View File

@@ -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 }, { 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]] [[package]]
name = "click" name = "click"
version = "8.2.1" version = "8.1.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { 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 = [ 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]] [[package]]
@@ -264,28 +306,28 @@ wheels = [
[[package]] [[package]]
name = "faker" name = "faker"
version = "37.6.0" version = "37.8.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "tzdata" }, { 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 = [ 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]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.116.1" version = "0.117.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "starlette" }, { name = "starlette" },
{ name = "typing-extensions" }, { 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 = [ 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] [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 }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" 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 }, { 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]] [[package]]
name = "rich" name = "rich"
version = "14.0.0" version = "14.0.0"
@@ -992,6 +1062,7 @@ dependencies = [
{ name = "email-validator" }, { name = "email-validator" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "ffmpeg-python" }, { name = "ffmpeg-python" },
{ name = "gtts" },
{ name = "httpx" }, { name = "httpx" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -1023,8 +1094,9 @@ requires-dist = [
{ name = "asyncpg", specifier = "==0.30.0" }, { name = "asyncpg", specifier = "==0.30.0" },
{ name = "bcrypt", specifier = "==4.3.0" }, { name = "bcrypt", specifier = "==4.3.0" },
{ name = "email-validator", specifier = "==2.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 = "ffmpeg-python", specifier = "==0.2.0" },
{ name = "gtts", specifier = "==2.5.4" },
{ name = "httpx", specifier = "==0.28.1" }, { name = "httpx", specifier = "==0.28.1" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.10" }, { name = "psycopg", extras = ["binary"], specifier = "==3.2.10" },
{ name = "pydantic-settings", specifier = "==2.10.1" }, { name = "pydantic-settings", specifier = "==2.10.1" },
@@ -1032,15 +1104,15 @@ requires-dist = [
{ name = "python-socketio", specifier = "==5.13.0" }, { name = "python-socketio", specifier = "==5.13.0" },
{ name = "python-vlc", specifier = "==3.0.21203" }, { name = "python-vlc", specifier = "==3.0.21203" },
{ name = "pytz", specifier = "==2025.2" }, { name = "pytz", specifier = "==2025.2" },
{ name = "sqlmodel", specifier = "==0.0.24" }, { name = "sqlmodel", specifier = "==0.0.25" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.36.0" },
{ name = "yt-dlp", specifier = "==2025.9.5" }, { name = "yt-dlp", specifier = "==2025.9.5" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "coverage", specifier = "==7.10.6" }, { 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 = "httpx", specifier = "==0.28.1" },
{ name = "mypy", specifier = "==1.18.1" }, { name = "mypy", specifier = "==1.18.1" },
{ name = "pytest", specifier = "==8.4.2" }, { name = "pytest", specifier = "==8.4.2" },
@@ -1122,15 +1194,15 @@ wheels = [
[[package]] [[package]]
name = "sqlmodel" name = "sqlmodel"
version = "0.0.24" version = "0.0.25"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "sqlalchemy" }, { 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 = [ 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]] [[package]]
@@ -1214,15 +1286,15 @@ wheels = [
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.35.0" version = "0.36.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "h11" }, { 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 = [ 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] [package.optional-dependencies]