Merge branch 'tts'
This commit is contained in:
@@ -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 ###
|
||||||
45
alembic/versions/e617c155eea9_add_tts_table.py
Normal file
45
alembic/versions/e617c155eea9_add_tts_table.py
Normal 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 ###
|
||||||
@@ -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
225
app/api/v1/tts.py
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
18
app/main.py
18
app/main.py
@@ -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")
|
||||||
|
|||||||
@@ -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
28
app/models/tts.py
Normal 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
65
app/repositories/tts.py
Normal 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()
|
||||||
6
app/services/tts/__init__.py
Normal file
6
app/services/tts/__init__.py
Normal 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
39
app/services/tts/base.py
Normal 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."""
|
||||||
5
app/services/tts/providers/__init__.py
Normal file
5
app/services/tts/providers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""TTS providers package."""
|
||||||
|
|
||||||
|
from .gtts import GTTSProvider
|
||||||
|
|
||||||
|
__all__ = ["GTTSProvider"]
|
||||||
81
app/services/tts/providers/gtts.py
Normal file
81
app/services/tts/providers/gtts.py
Normal 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
524
app/services/tts/service.py
Normal 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)
|
||||||
193
app/services/tts_processor.py
Normal file
193
app/services/tts_processor.py
Normal 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()
|
||||||
@@ -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
110
uv.lock
generated
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user