Compare commits

...

20 Commits

Author SHA1 Message Date
JSC
e4c72f3b19 chore: Remove unused .env.template and SCHEDULER_EXAMPLE.md files
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m39s
2025-10-05 16:33:29 +02:00
JSC
17eafa4872 feat: Enhance play_next functionality by storing and restoring playlist index
Some checks failed
Backend CI / test (push) Failing after 2m17s
Backend CI / lint (push) Failing after 14m55s
2025-10-05 04:07:34 +02:00
JSC
c9f6bff723 refactor: Improve code readability by formatting function signatures and descriptions
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m29s
2025-10-04 22:27:12 +02:00
JSC
12243b1424 feat: Clear and manage play_next queue on playlist changes
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m36s
2025-10-04 19:39:44 +02:00
JSC
f7197a89a7 feat: Add play next functionality to player service and API 2025-10-04 19:16:37 +02:00
JSC
b66b8e36bb feat: Enhance user metrics retrieval by integrating Extraction model and updating related queries
Some checks failed
Backend CI / lint (push) Failing after 17s
Backend CI / test (push) Failing after 2m32s
2025-10-04 13:45:36 +02:00
JSC
95e166eefb feat: Add endpoint and service method to retrieve top users by various metrics
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m36s
2025-09-27 21:52:00 +02:00
JSC
d9697c2dd7 feat: Add TTS statistics endpoint and service method for comprehensive TTS data 2025-09-27 21:37:59 +02:00
JSC
7b59a8216a fix: Correct import formatting for CreditService in VLCPlayerService
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m28s
2025-09-27 03:34:19 +02:00
JSC
4b8496d025 feat: Implement host system volume control and update player service to use it
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Has been cancelled
2025-09-27 03:33:11 +02:00
JSC
0806d541f2 Upgrade packages
Some checks failed
Backend CI / lint (push) Failing after 11s
Backend CI / test (push) Failing after 1m59s
2025-09-27 02:32:59 +02:00
JSC
acdf191a5a refactor: Improve code readability and structure across TTS modules
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m36s
2025-09-21 19:07:32 +02:00
JSC
35b857fd0d feat: Add GitHub as an available OAuth provider and remove database initialization logs 2025-09-21 18:58:20 +02:00
JSC
c13e18c290 feat: Implement playlist sound deletion and update current playlist logic on deletion
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m34s
2025-09-21 18:32:48 +02:00
JSC
702d7ee577 Merge branch 'tts'
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m35s
2025-09-21 18:19:26 +02:00
JSC
d3b6e90262 style: Format code for consistency and readability across TTS modules 2025-09-21 18:05:20 +02:00
JSC
50eeae4c62 refactor: Clean up TTSService methods for improved readability and consistency 2025-09-21 15:38:35 +02:00
JSC
e005dedcd3 refactor: Update supported languages list in GTTSProvider and remove TLD option from schema 2025-09-21 15:20:23 +02:00
JSC
72ddd98b25 feat: Add status and error fields to TTS model and implement background processing for TTS generations 2025-09-21 14:39:41 +02:00
JSC
b2e513a915 feat: Add endpoint to retrieve TTS history for the current user and improve request model formatting 2025-09-21 13:55:24 +02:00
31 changed files with 1617 additions and 635 deletions

View File

@@ -1,29 +0,0 @@
# Application Configuration
HOST=localhost
PORT=8000
RELOAD=true
# Database Configuration
DATABASE_URL=sqlite+aiosqlite:///data/soundboard.db
DATABASE_ECHO=false
# Logging Configuration
LOG_LEVEL=info
LOG_FILE=logs/app.log
LOG_MAX_SIZE=10485760
LOG_BACKUP_COUNT=5
# JWT Configuration
JWT_SECRET_KEY=your-secret-key-change-in-production
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
# Cookie Configuration
COOKIE_SECURE=false
COOKIE_SAMESITE=lax
# OAuth2 Configuration
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

View File

@@ -1,232 +0,0 @@
# Enhanced Scheduler System - Usage Examples
This document demonstrates how to use the new comprehensive scheduled task system.
## Features Overview
### ✨ **Task Types**
- **Credit Recharge**: Automatic or scheduled credit replenishment
- **Play Sound**: Schedule individual sound playback
- **Play Playlist**: Schedule playlist playback with modes
### 🌍 **Timezone Support**
- Full timezone support with automatic UTC conversion
- Specify any IANA timezone (e.g., "America/New_York", "Europe/Paris")
### 🔄 **Scheduling Options**
- **One-shot**: Execute once at specific date/time
- **Recurring**: Hourly, daily, weekly, monthly, yearly intervals
- **Cron**: Custom cron expressions for complex scheduling
## API Usage Examples
### Create a One-Shot Task
```bash
# Schedule a sound to play in 2 hours
curl -X POST "http://localhost:8000/api/v1/scheduler/tasks" \
-H "Content-Type: application/json" \
-H "Cookie: access_token=YOUR_JWT_TOKEN" \
-d '{
"name": "Play Morning Alarm",
"task_type": "play_sound",
"scheduled_at": "2024-01-01T10:00:00",
"timezone": "America/New_York",
"parameters": {
"sound_id": "sound-uuid-here"
}
}'
```
### Create a Recurring Task
```bash
# Daily credit recharge at midnight UTC
curl -X POST "http://localhost:8000/api/v1/scheduler/admin/system-tasks" \
-H "Content-Type: application/json" \
-H "Cookie: access_token=ADMIN_JWT_TOKEN" \
-d '{
"name": "Daily Credit Recharge",
"task_type": "credit_recharge",
"scheduled_at": "2024-01-01T00:00:00",
"timezone": "UTC",
"recurrence_type": "daily",
"parameters": {}
}'
```
### Create a Cron-Based Task
```bash
# Play playlist every weekday at 9 AM
curl -X POST "http://localhost:8000/api/v1/scheduler/tasks" \
-H "Content-Type: application/json" \
-H "Cookie: access_token=YOUR_JWT_TOKEN" \
-d '{
"name": "Workday Playlist",
"task_type": "play_playlist",
"scheduled_at": "2024-01-01T09:00:00",
"timezone": "America/New_York",
"recurrence_type": "cron",
"cron_expression": "0 9 * * 1-5",
"parameters": {
"playlist_id": "playlist-uuid-here",
"play_mode": "loop",
"shuffle": true
}
}'
```
## Python Service Usage
```python
from datetime import datetime, timedelta
from app.services.scheduler import SchedulerService
from app.models.scheduled_task import TaskType, RecurrenceType
# Initialize scheduler service
scheduler_service = SchedulerService(db_session_factory, player_service)
# Create a one-shot task
task = await scheduler_service.create_task(
name="Test Sound",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow() + timedelta(hours=2),
timezone="America/New_York",
parameters={"sound_id": "sound-uuid-here"},
user_id=user.id
)
# Create a recurring task
recurring_task = await scheduler_service.create_task(
name="Weekly Playlist",
task_type=TaskType.PLAY_PLAYLIST,
scheduled_at=datetime.utcnow() + timedelta(days=1),
recurrence_type=RecurrenceType.WEEKLY,
recurrence_count=10, # Run 10 times then stop
parameters={
"playlist_id": "playlist-uuid",
"play_mode": "continuous",
"shuffle": False
},
user_id=user.id
)
# Cancel a task
success = await scheduler_service.cancel_task(task.id)
# Get user's tasks
user_tasks = await scheduler_service.get_user_tasks(
user_id=user.id,
status=TaskStatus.PENDING,
limit=20
)
```
## Task Parameters
### Credit Recharge Parameters
```json
{
"user_id": "uuid-string-or-null" // null for all users (system task)
}
```
### Play Sound Parameters
```json
{
"sound_id": "uuid-string" // Required: sound to play
}
```
### Play Playlist Parameters
```json
{
"playlist_id": "uuid-string", // Required: playlist to play
"play_mode": "continuous", // Optional: continuous, loop, loop_one, random, single
"shuffle": false // Optional: shuffle playlist
}
```
## Recurrence Types
| Type | Description | Example |
|------|-------------|---------|
| `none` | One-shot execution | Single alarm |
| `hourly` | Every hour | Hourly reminders |
| `daily` | Every day | Daily credit recharge |
| `weekly` | Every week | Weekly reports |
| `monthly` | Every month | Monthly maintenance |
| `yearly` | Every year | Annual renewals |
| `cron` | Custom cron expression | Complex schedules |
## Cron Expression Examples
| Expression | Description |
|------------|-------------|
| `0 9 * * *` | Daily at 9 AM |
| `0 9 * * 1-5` | Weekdays at 9 AM |
| `30 14 1 * *` | 1st of month at 2:30 PM |
| `0 0 * * 0` | Every Sunday at midnight |
| `*/15 * * * *` | Every 15 minutes |
## System Tasks vs User Tasks
### System Tasks
- Created by administrators
- No user association (`user_id` is null)
- Typically for maintenance operations
- Accessible via admin endpoints
### User Tasks
- Created by regular users
- Associated with specific user
- User can only manage their own tasks
- Accessible via regular user endpoints
## Error Handling
The system provides comprehensive error handling:
- **Invalid Parameters**: Validation errors for missing or invalid task parameters
- **Scheduling Conflicts**: Prevention of resource conflicts
- **Timezone Errors**: Invalid timezone specifications handled gracefully
- **Execution Failures**: Failed tasks marked with error messages and retry logic
- **Expired Tasks**: Automatic cleanup of expired tasks
## Monitoring and Management
### Get Task Status
```bash
curl "http://localhost:8000/api/v1/scheduler/tasks/{task-id}" \
-H "Cookie: access_token=YOUR_JWT_TOKEN"
```
### List User Tasks
```bash
curl "http://localhost:8000/api/v1/scheduler/tasks?status=pending&limit=10" \
-H "Cookie: access_token=YOUR_JWT_TOKEN"
```
### Admin: View All Tasks
```bash
curl "http://localhost:8000/api/v1/scheduler/admin/tasks?limit=50" \
-H "Cookie: access_token=ADMIN_JWT_TOKEN"
```
### Cancel Task
```bash
curl -X DELETE "http://localhost:8000/api/v1/scheduler/tasks/{task-id}" \
-H "Cookie: access_token=YOUR_JWT_TOKEN"
```
## Migration from Old Scheduler
The new system automatically:
1. **Creates system tasks**: Daily credit recharge task created on startup
2. **Maintains compatibility**: Existing credit recharge functionality preserved
3. **Enhances functionality**: Adds user tasks and new task types
4. **Improves reliability**: Better error handling and timezone support
The old scheduler is completely replaced - no migration needed for existing functionality.

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

@@ -331,7 +331,7 @@ async def oauth_callback(
async def get_oauth_providers() -> dict[str, list[str]]: async def get_oauth_providers() -> dict[str, list[str]]:
"""Get list of available OAuth providers.""" """Get list of available OAuth providers."""
return { return {
"providers": ["google"], "providers": ["google", "github"],
} }

View File

@@ -41,6 +41,59 @@ async def get_track_statistics(
) from e ) from e
@router.get("/tts-statistics")
async def get_tts_statistics(
_current_user: Annotated[User, Depends(get_current_user)],
dashboard_service: Annotated[DashboardService, Depends(get_dashboard_service)],
) -> dict[str, Any]:
"""Get TTS statistics."""
try:
return await dashboard_service.get_tts_statistics()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch TTS statistics: {e!s}",
) from e
@router.get("/top-users")
async def get_top_users(
_current_user: Annotated[User, Depends(get_current_user)],
dashboard_service: Annotated[DashboardService, Depends(get_dashboard_service)],
metric_type: Annotated[
str,
Query(
description=(
"Metric type: sounds_played, credits_used, tracks_added, "
"tts_added, playlists_created"
),
),
],
period: Annotated[
str,
Query(
description="Time period (today, 1_day, 1_week, 1_month, 1_year, all_time)",
),
] = "all_time",
limit: Annotated[
int,
Query(description="Number of top users to return", ge=1, le=100),
] = 10,
) -> list[dict[str, Any]]:
"""Get top users by metric for a specific period."""
try:
return await dashboard_service.get_top_users(
metric_type=metric_type,
period=period,
limit=limit,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch top users: {e!s}",
) from e
@router.get("/top-sounds") @router.get("/top-sounds")
async def get_top_sounds( async def get_top_sounds(
_current_user: Annotated[User, Depends(get_current_user)], _current_user: Annotated[User, Depends(get_current_user)],

View File

@@ -249,3 +249,21 @@ async def get_state(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get player state", detail="Failed to get player state",
) from e ) from e
@router.post("/play-next/{sound_id}")
async def add_to_play_next(
sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
) -> MessageResponse:
"""Add a sound to the play next queue."""
try:
player = get_player_service()
await player.add_to_play_next(sound_id)
return MessageResponse(message=f"Added sound {sound_id} to play next queue")
except Exception as e:
logger.exception("Error adding sound to play next queue: %s", sound_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add sound to play next queue",
) from e

View File

@@ -11,16 +11,19 @@ from app.core.dependencies import get_current_active_user_flexible
from app.models.user import User from app.models.user import User
from app.services.tts import TTSService from app.services.tts import TTSService
router = APIRouter(prefix="/tts", tags=["tts"]) router = APIRouter(prefix="/tts", tags=["tts"])
class TTSGenerateRequest(BaseModel): class TTSGenerateRequest(BaseModel):
"""TTS generation request model.""" """TTS generation request model."""
text: str = Field(..., min_length=1, max_length=1000, description="Text to convert to speech") 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") provider: str = Field(default="gtts", description="TTS provider to use")
options: dict[str, Any] = Field(default_factory=dict, description="Provider-specific options") options: dict[str, Any] = Field(
default_factory=dict, description="Provider-specific options",
)
class TTSResponse(BaseModel): class TTSResponse(BaseModel):
@@ -30,6 +33,8 @@ class TTSResponse(BaseModel):
text: str text: str
provider: str provider: str
options: dict[str, Any] options: dict[str, Any]
status: str
error: str | None
sound_id: int | None sound_id: int | None
user_id: int user_id: int
created_at: str created_at: str
@@ -51,6 +56,49 @@ async def get_tts_service(
return TTSService(session) 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("") @router.post("")
async def generate_tts( async def generate_tts(
request: TTSGenerateRequest, request: TTSGenerateRequest,
@@ -69,7 +117,7 @@ async def generate_tts(
text=request.text, text=request.text,
user_id=current_user.id, user_id=current_user.id,
provider=request.provider, provider=request.provider,
**request.options **request.options,
) )
tts_record = result["tts"] tts_record = result["tts"]
@@ -81,10 +129,12 @@ async def generate_tts(
text=tts_record.text, text=tts_record.text,
provider=tts_record.provider, provider=tts_record.provider,
options=tts_record.options, options=tts_record.options,
status=tts_record.status,
error=tts_record.error,
sound_id=tts_record.sound_id, sound_id=tts_record.sound_id,
user_id=tts_record.user_id, user_id=tts_record.user_id,
created_at=tts_record.created_at.isoformat(), created_at=tts_record.created_at.isoformat(),
) ),
} }
except ValueError as e: except ValueError as e:
@@ -140,47 +190,6 @@ async def get_provider(
) )
@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 generation history 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,
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.delete("/{tts_id}") @router.delete("/{tts_id}")
async def delete_tts( async def delete_tts(
tts_id: int, tts_id: int,
@@ -197,8 +206,6 @@ async def delete_tts(
await tts_service.delete_tts(tts_id=tts_id, user_id=current_user.id) await tts_service.delete_tts(tts_id=tts_id, user_id=current_user.id)
return {"message": "TTS generation deleted successfully"}
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -214,3 +221,5 @@ async def delete_tts(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete TTS: {e!s}", detail=f"Failed to delete TTS: {e!s}",
) from e ) from e
else:
return {"message": "TTS generation deleted successfully"}

View File

@@ -9,6 +9,7 @@ from app.core.database import get_db
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.user import User from app.models.user import User
from app.repositories.sound import SoundRepository from app.repositories.sound import SoundRepository
from app.repositories.user import UserRepository
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.dashboard import DashboardService from app.services.dashboard import DashboardService
from app.services.oauth import OAuthService from app.services.oauth import OAuthService
@@ -193,4 +194,5 @@ async def get_dashboard_service(
) -> DashboardService: ) -> DashboardService:
"""Get the dashboard service.""" """Get the dashboard service."""
sound_repository = SoundRepository(session) sound_repository = SoundRepository(session)
return DashboardService(sound_repository) user_repository = UserRepository(session)
return DashboardService(sound_repository, user_repository)

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,14 @@ 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()
# 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")
@@ -65,6 +67,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

@@ -17,6 +17,7 @@ from .user import User
from .user_oauth import UserOauth from .user_oauth import UserOauth
__all__ = [ __all__ = [
"TTS",
"BaseModel", "BaseModel",
"CreditAction", "CreditAction",
"CreditTransaction", "CreditTransaction",
@@ -28,7 +29,6 @@ __all__ = [
"ScheduledTask", "ScheduledTask",
"Sound", "Sound",
"SoundPlayed", "SoundPlayed",
"TTS",
"User", "User",
"UserOauth", "UserOauth",
] ]

View File

@@ -18,9 +18,13 @@ class TTS(SQLModel, table=True):
options: dict[str, Any] = Field( options: dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
sa_column=Column(JSON), sa_column=Column(JSON),
description="Provider-specific options used" 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",
) )
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") user_id: int = Field(foreign_key="user.id", description="User who created the TTS")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -201,8 +201,11 @@ class SoundRepository(BaseRepository[Sound]):
) )
raise raise
async def get_soundboard_statistics(self) -> dict[str, int | float]: async def get_soundboard_statistics(
"""Get statistics for SDB type sounds.""" self,
sound_type: str = "SDB",
) -> dict[str, int | float]:
"""Get statistics for sounds of a specific type."""
try: try:
statement = select( statement = select(
func.count(Sound.id).label("count"), func.count(Sound.id).label("count"),
@@ -211,7 +214,7 @@ class SoundRepository(BaseRepository[Sound]):
func.sum( func.sum(
Sound.size + func.coalesce(Sound.normalized_size, 0), Sound.size + func.coalesce(Sound.normalized_size, 0),
).label("total_size"), ).label("total_size"),
).where(Sound.type == "SDB") ).where(Sound.type == sound_type)
result = await self.session.exec(statement) result = await self.session.exec(statement)
row = result.first() row = result.first()

View File

@@ -1,6 +1,10 @@
"""TTS repository for database operations.""" """TTS repository for database operations."""
from typing import Any, Sequence from collections.abc import Sequence
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select from sqlmodel import select
@@ -11,7 +15,13 @@ from app.repositories.base import BaseRepository
class TTSRepository(BaseRepository[TTS]): class TTSRepository(BaseRepository[TTS]):
"""Repository for TTS operations.""" """Repository for TTS operations."""
def __init__(self, session: Any) -> None: def __init__(self, session: "AsyncSession") -> None:
"""Initialize TTS repository.
Args:
session: Database session for operations
"""
super().__init__(TTS, session) super().__init__(TTS, session)
async def get_by_user_id( async def get_by_user_id(
@@ -29,6 +39,7 @@ class TTSRepository(BaseRepository[TTS]):
Returns: Returns:
List of TTS records List of TTS records
""" """
stmt = ( stmt = (
select(self.model) select(self.model)
@@ -53,6 +64,7 @@ class TTSRepository(BaseRepository[TTS]):
Returns: Returns:
TTS record if found and belongs to user, None otherwise TTS record if found and belongs to user, None otherwise
""" """
stmt = select(self.model).where( stmt = select(self.model).where(
self.model.id == tts_id, self.model.id == tts_id,

View File

@@ -1,15 +1,21 @@
"""User repository.""" """User repository."""
from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from sqlalchemy import func from sqlalchemy import Select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.credit_transaction import CreditTransaction
from app.models.extraction import Extraction
from app.models.plan import Plan from app.models.plan import Plan
from app.models.playlist import Playlist
from app.models.sound_played import SoundPlayed
from app.models.tts import TTS
from app.models.user import User from app.models.user import User
from app.repositories.base import BaseRepository from app.repositories.base import BaseRepository
@@ -217,3 +223,146 @@ class UserRepository(BaseRepository[User]):
except Exception: except Exception:
logger.exception("Failed to check if email exists: %s", email) logger.exception("Failed to check if email exists: %s", email)
raise raise
async def get_top_users(
self,
metric_type: str,
date_filter: datetime | None = None,
limit: int = 10,
) -> list[dict[str, Any]]:
"""Get top users by different metrics."""
try:
query = self._build_top_users_query(metric_type, date_filter)
# Add ordering and limit
query = query.order_by(func.count().desc()).limit(limit)
result = await self.session.exec(query)
rows = result.all()
return [
{
"id": row[0],
"name": row[1],
"count": int(row[2]),
}
for row in rows
]
except Exception:
logger.exception(
"Failed to get top users for metric=%s, date_filter=%s",
metric_type,
date_filter,
)
raise
def _build_top_users_query(
self,
metric_type: str,
date_filter: datetime | None,
) -> Select:
"""Build query for top users based on metric type."""
match metric_type:
case "sounds_played":
query = self._build_sounds_played_query()
case "credits_used":
query = self._build_credits_used_query()
case "tracks_added":
query = self._build_tracks_added_query()
case "tts_added":
query = self._build_tts_added_query()
case "playlists_created":
query = self._build_playlists_created_query()
case _:
msg = f"Unknown metric type: {metric_type}"
raise ValueError(msg)
# Apply date filter if provided
if date_filter:
query = self._apply_date_filter(query, metric_type, date_filter)
return query
def _build_sounds_played_query(self) -> Select:
"""Build query for sounds played metric."""
return (
select(
User.id,
User.name,
func.count(SoundPlayed.id).label("count"),
)
.join(SoundPlayed, User.id == SoundPlayed.user_id)
.group_by(User.id, User.name)
)
def _build_credits_used_query(self) -> Select:
"""Build query for credits used metric."""
return (
select(
User.id,
User.name,
func.sum(func.abs(CreditTransaction.amount)).label("count"),
)
.join(CreditTransaction, User.id == CreditTransaction.user_id)
.where(CreditTransaction.amount < 0)
.group_by(User.id, User.name)
)
def _build_tracks_added_query(self) -> Select:
"""Build query for tracks added metric."""
return (
select(
User.id,
User.name,
func.count(Extraction.id).label("count"),
)
.join(Extraction, User.id == Extraction.user_id)
.where(Extraction.sound_id.is_not(None))
.group_by(User.id, User.name)
)
def _build_tts_added_query(self) -> Select:
"""Build query for TTS added metric."""
return (
select(
User.id,
User.name,
func.count(TTS.id).label("count"),
)
.join(TTS, User.id == TTS.user_id)
.group_by(User.id, User.name)
)
def _build_playlists_created_query(self) -> Select:
"""Build query for playlists created metric."""
return (
select(
User.id,
User.name,
func.count(Playlist.id).label("count"),
)
.join(Playlist, User.id == Playlist.user_id)
.group_by(User.id, User.name)
)
def _apply_date_filter(
self,
query: Select,
metric_type: str,
date_filter: datetime,
) -> Select:
"""Apply date filter to query based on metric type."""
match metric_type:
case "sounds_played":
return query.where(SoundPlayed.created_at >= date_filter)
case "credits_used":
return query.where(CreditTransaction.created_at >= date_filter)
case "tracks_added":
return query.where(Extraction.created_at >= date_filter)
case "tts_added":
return query.where(TTS.created_at >= date_filter)
case "playlists_created":
return query.where(Playlist.created_at >= date_filter)
case _:
return query

View File

@@ -49,3 +49,7 @@ class PlayerStateResponse(BaseModel):
None, None,
description="Current track index in playlist", description="Current track index in playlist",
) )
play_next_queue: list[dict[str, Any]] = Field(
default_factory=list,
description="Play next queue",
)

View File

@@ -5,6 +5,7 @@ from typing import Any
from app.core.logging import get_logger from app.core.logging import get_logger
from app.repositories.sound import SoundRepository from app.repositories.sound import SoundRepository
from app.repositories.user import UserRepository
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -12,9 +13,14 @@ logger = get_logger(__name__)
class DashboardService: class DashboardService:
"""Service for dashboard statistics and analytics.""" """Service for dashboard statistics and analytics."""
def __init__(self, sound_repository: SoundRepository) -> None: def __init__(
self,
sound_repository: SoundRepository,
user_repository: UserRepository,
) -> None:
"""Initialize the dashboard service.""" """Initialize the dashboard service."""
self.sound_repository = sound_repository self.sound_repository = sound_repository
self.user_repository = user_repository
async def get_soundboard_statistics(self) -> dict[str, Any]: async def get_soundboard_statistics(self) -> dict[str, Any]:
"""Get comprehensive soundboard statistics.""" """Get comprehensive soundboard statistics."""
@@ -85,6 +91,55 @@ class DashboardService:
) )
raise raise
async def get_tts_statistics(self) -> dict[str, Any]:
"""Get comprehensive TTS statistics."""
try:
stats = await self.sound_repository.get_soundboard_statistics("TTS")
return {
"sound_count": stats["count"],
"total_play_count": stats["total_plays"],
"total_duration": stats["total_duration"],
"total_size": stats["total_size"],
}
except Exception:
logger.exception("Failed to get TTS statistics")
raise
async def get_top_users(
self,
metric_type: str,
period: str = "all_time",
limit: int = 10,
) -> list[dict[str, Any]]:
"""Get top users by different metrics for a specific period."""
try:
# Calculate the date filter based on period
date_filter = self._get_date_filter(period)
# Get top users from repository
top_users = await self.user_repository.get_top_users(
metric_type=metric_type,
date_filter=date_filter,
limit=limit,
)
return [
{
"id": user["id"],
"name": user["name"],
"count": user["count"],
}
for user in top_users
]
except Exception:
logger.exception(
"Failed to get top users for metric=%s, period=%s",
metric_type,
period,
)
raise
def _get_date_filter(self, period: str) -> datetime | None: # noqa: PLR0911 def _get_date_filter(self, period: str) -> datetime | None: # noqa: PLR0911
"""Calculate the date filter based on the period.""" """Calculate the date filter based on the period."""
now = datetime.now(UTC) now = datetime.now(UTC)

View File

@@ -8,6 +8,8 @@ from enum import Enum
from typing import Any from typing import Any
import vlc # type: ignore[import-untyped] import vlc # type: ignore[import-untyped]
from sqlalchemy.orm import selectinload
from sqlmodel import select
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.playlist import Playlist from app.models.playlist import Playlist
@@ -16,6 +18,7 @@ from app.models.sound_played import SoundPlayed
from app.repositories.playlist import PlaylistRepository from app.repositories.playlist import PlaylistRepository
from app.repositories.sound import SoundRepository from app.repositories.sound import SoundRepository
from app.services.socket import socket_manager from app.services.socket import socket_manager
from app.services.volume import volume_service
from app.utils.audio import get_sound_file_path from app.utils.audio import get_sound_file_path
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -46,8 +49,11 @@ class PlayerState:
"""Initialize player state.""" """Initialize player state."""
self.status: PlayerStatus = PlayerStatus.STOPPED self.status: PlayerStatus = PlayerStatus.STOPPED
self.mode: PlayerMode = PlayerMode.CONTINUOUS self.mode: PlayerMode = PlayerMode.CONTINUOUS
self.volume: int = 80
self.previous_volume: int = 80 # Initialize volume from host system or default to 80
host_volume = volume_service.get_volume()
self.volume: int = host_volume if host_volume is not None else 80
self.previous_volume: int = self.volume
self.current_sound_id: int | None = None self.current_sound_id: int | None = None
self.current_sound_index: int | None = None self.current_sound_index: int | None = None
self.current_sound_position: int = 0 self.current_sound_position: int = 0
@@ -58,6 +64,8 @@ class PlayerState:
self.playlist_length: int = 0 self.playlist_length: int = 0
self.playlist_duration: int = 0 self.playlist_duration: int = 0
self.playlist_sounds: list[Sound] = [] self.playlist_sounds: list[Sound] = []
self.play_next_queue: list[Sound] = []
self.playlist_index_before_play_next: int | None = None
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""Convert player state to dictionary for serialization.""" """Convert player state to dictionary for serialization."""
@@ -83,6 +91,9 @@ class PlayerState:
if self.playlist_id if self.playlist_id
else None else None
), ),
"play_next_queue": [
self._serialize_sound(sound) for sound in self.play_next_queue
],
} }
def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None: def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None:
@@ -153,8 +164,8 @@ class PlayerService:
) )
self._position_thread.start() self._position_thread.start()
# Set initial volume # Set VLC to 100% volume - host volume is controlled separately
self._player.audio_set_volume(self.state.volume) self._player.audio_set_volume(100)
logger.info("Player service started") logger.info("Player service started")
@@ -338,6 +349,31 @@ class PlayerService:
async def next(self) -> None: async def next(self) -> None:
"""Skip to next track.""" """Skip to next track."""
# Check if there's a track in the play_next queue
if self.state.play_next_queue:
await self._play_next_from_queue()
return
# If currently playing from play_next queue (no index but have stored index)
if (
self.state.current_sound_index is None
and self.state.playlist_index_before_play_next is not None
and self.state.playlist_sounds
):
# Skipped the last play_next track, go to next in playlist
restored_index = self.state.playlist_index_before_play_next
next_index = self._get_next_index(restored_index)
# Clear the stored index
self.state.playlist_index_before_play_next = None
if next_index is not None:
await self.play(next_index)
else:
await self._stop_playback()
await self._broadcast_state()
return
if not self.state.playlist_sounds: if not self.state.playlist_sounds:
return return
@@ -378,7 +414,7 @@ class PlayerService:
logger.debug("Seeked to position: %sms", position_ms) logger.debug("Seeked to position: %sms", position_ms)
async def set_volume(self, volume: int) -> None: async def set_volume(self, volume: int) -> None:
"""Set playback volume (0-100).""" """Set playback volume (0-100) by controlling host system volume."""
volume = max(0, min(100, volume)) # Clamp to valid range volume = max(0, min(100, volume)) # Clamp to valid range
# Store previous volume when muting (going from >0 to 0) # Store previous volume when muting (going from >0 to 0)
@@ -386,18 +422,30 @@ class PlayerService:
self.state.previous_volume = self.state.volume self.state.previous_volume = self.state.volume
self.state.volume = volume self.state.volume = volume
self._player.audio_set_volume(volume)
# Control host system volume instead of VLC volume
if volume == 0:
# Mute the host system
volume_service.set_mute(muted=True)
else:
# Unmute and set host volume
if volume_service.is_muted():
volume_service.set_mute(muted=False)
volume_service.set_volume(volume)
# Keep VLC at 100% volume
self._player.audio_set_volume(100)
await self._broadcast_state() await self._broadcast_state()
logger.debug("Volume set to: %s", volume) logger.debug("Host volume set to: %s", volume)
async def mute(self) -> None: async def mute(self) -> None:
"""Mute the player (stores current volume as previous_volume).""" """Mute the host system (stores current volume as previous_volume)."""
if self.state.volume > 0: if self.state.volume > 0:
await self.set_volume(0) await self.set_volume(0)
async def unmute(self) -> None: async def unmute(self) -> None:
"""Unmute the player (restores previous_volume).""" """Unmute the host system (restores previous_volume)."""
if self.state.volume == 0 and self.state.previous_volume > 0: if self.state.volume == 0 and self.state.previous_volume > 0:
await self.set_volume(self.state.previous_volume) await self.set_volume(self.state.previous_volume)
@@ -415,6 +463,66 @@ class PlayerService:
await self._broadcast_state() await self._broadcast_state()
logger.info("Playback mode set to: %s", mode.value) logger.info("Playback mode set to: %s", mode.value)
async def add_to_play_next(self, sound_id: int) -> None:
"""Add a sound to the play_next queue."""
session = self.db_session_factory()
try:
# Eagerly load extractions to avoid lazy loading issues
statement = select(Sound).where(Sound.id == sound_id)
statement = statement.options(selectinload(Sound.extractions)) # type: ignore[arg-type]
result = await session.exec(statement)
sound = result.first()
if not sound:
logger.warning("Sound %s not found for play_next", sound_id)
return
self.state.play_next_queue.append(sound)
await self._broadcast_state()
logger.info("Added sound %s to play_next queue", sound.name)
finally:
await session.close()
async def _play_next_from_queue(self) -> None:
"""Play the first track from the play_next queue."""
if not self.state.play_next_queue:
return
# Store current playlist index before switching to play_next track
# Only store if we're currently playing from the playlist
if (
self.state.current_sound_index is not None
and self.state.playlist_index_before_play_next is None
):
self.state.playlist_index_before_play_next = (
self.state.current_sound_index
)
logger.info(
"Stored playlist index %s before playing from play_next queue",
self.state.playlist_index_before_play_next,
)
# Get the first sound from the queue
next_sound = self.state.play_next_queue.pop(0)
# Stop current playback and process play count
if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback()
# Set the sound as current (without index since it's from play_next)
self.state.current_sound = next_sound
self.state.current_sound_id = next_sound.id
self.state.current_sound_index = None # No index for play_next tracks
# Play the sound
if not self._validate_sound_file():
return
if not self._load_and_play_media():
return
await self._handle_successful_playback()
async def reload_playlist(self) -> None: async def reload_playlist(self) -> None:
"""Reload current playlist from database.""" """Reload current playlist from database."""
session = self.db_session_factory() session = self.db_session_factory()
@@ -503,6 +611,16 @@ class PlayerService:
current_id, current_id,
) )
# Clear play_next queue when playlist changes
if self.state.play_next_queue:
logger.info("Clearing play_next queue due to playlist change")
self.state.play_next_queue.clear()
# Clear stored playlist index
if self.state.playlist_index_before_play_next is not None:
logger.info("Clearing stored playlist index due to playlist change")
self.state.playlist_index_before_play_next = None
if self.state.status != PlayerStatus.STOPPED: if self.state.status != PlayerStatus.STOPPED:
await self._stop_playback() await self._stop_playback()
@@ -518,6 +636,9 @@ class PlayerService:
sounds: list[Sound], sounds: list[Sound],
) -> None: ) -> None:
"""Handle track checking when playlist ID is the same.""" """Handle track checking when playlist ID is the same."""
# Remove tracks from play_next queue that are no longer in the playlist
self._clean_play_next_queue(sounds)
# Find the current track in the new playlist # Find the current track in the new playlist
new_index = self._find_sound_index(previous_sound_id, sounds) new_index = self._find_sound_index(previous_sound_id, sounds)
@@ -575,6 +696,29 @@ class PlayerService:
return i return i
return None return None
def _clean_play_next_queue(self, playlist_sounds: list[Sound]) -> None:
"""Remove tracks from play_next queue that are no longer in the playlist."""
if not self.state.play_next_queue:
return
# Get IDs of all sounds in the current playlist
playlist_sound_ids = {sound.id for sound in playlist_sounds}
# Filter out tracks that are no longer in the playlist
original_length = len(self.state.play_next_queue)
self.state.play_next_queue = [
sound
for sound in self.state.play_next_queue
if sound.id in playlist_sound_ids
]
removed_count = original_length - len(self.state.play_next_queue)
if removed_count > 0:
logger.info(
"Removed %s track(s) from play_next queue (no longer in playlist)",
removed_count,
)
def _set_first_track_as_current(self, sounds: list[Sound]) -> None: def _set_first_track_as_current(self, sounds: list[Sound]) -> None:
"""Set the first track as the current track.""" """Set the first track as the current track."""
self.state.current_sound_index = 0 self.state.current_sound_index = 0
@@ -764,7 +908,12 @@ class PlayerService:
"""Handle when a track finishes playing.""" """Handle when a track finishes playing."""
await self._process_play_count() await self._process_play_count()
# Auto-advance to next track # Check if there's a track in the play_next queue
if self.state.play_next_queue:
await self._play_next_from_queue()
return
# Auto-advance to next track in playlist
if self.state.current_sound_index is not None: if self.state.current_sound_index is not None:
next_index = self._get_next_index(self.state.current_sound_index) next_index = self._get_next_index(self.state.current_sound_index)
if next_index is not None: if next_index is not None:
@@ -772,6 +921,32 @@ class PlayerService:
else: else:
await self._stop_playback() await self._stop_playback()
await self._broadcast_state() await self._broadcast_state()
elif (
self.state.playlist_sounds
and self.state.playlist_index_before_play_next is not None
):
# Current track was from play_next queue, restore to next track in playlist
restored_index = self.state.playlist_index_before_play_next
logger.info(
"Play next queue finished, continuing from playlist index %s",
restored_index,
)
# Get the next index based on the stored position
next_index = self._get_next_index(restored_index)
# Clear the stored index since we're done with play_next queue
self.state.playlist_index_before_play_next = None
if next_index is not None:
await self.play(next_index)
else:
# No next track (end of playlist in non-loop mode)
await self._stop_playback()
await self._broadcast_state()
else:
await self._stop_playback()
await self._broadcast_state()
async def _broadcast_state(self) -> None: async def _broadcast_state(self) -> None:
"""Broadcast current player state via WebSocket.""" """Broadcast current player state via WebSocket."""

View File

@@ -3,10 +3,12 @@
from typing import Any, TypedDict from typing import Any, TypedDict
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.playlist import Playlist from app.models.playlist import Playlist
from app.models.playlist_sound import PlaylistSound
from app.models.sound import Sound from app.models.sound import Sound
from app.repositories.playlist import PlaylistRepository, PlaylistSortField, SortOrder from app.repositories.playlist import PlaylistRepository, PlaylistSortField, SortOrder
from app.repositories.sound import SoundRepository from app.repositories.sound import SoundRepository
@@ -231,11 +233,23 @@ class PlaylistService:
# Check if this was the current playlist before deleting # Check if this was the current playlist before deleting
was_current = playlist.is_current was_current = playlist.is_current
# First, delete all playlist_sound relationships
await self._delete_playlist_sounds(playlist_id)
# Then delete the playlist itself
await self.playlist_repo.delete(playlist) await self.playlist_repo.delete(playlist)
logger.info("Deleted playlist %s for user %s", playlist_id, user_id) logger.info("Deleted playlist %s for user %s", playlist_id, user_id)
# If the deleted playlist was current, reload player to use main fallback # If the deleted playlist was current, set main playlist as current
if was_current: if was_current:
main_playlist = await self.get_main_playlist()
await self.playlist_repo.update(main_playlist, {"is_current": True})
logger.info(
"Set main playlist as current after deleting current playlist %s",
playlist_id,
)
# Reload player to reflect the change
await _reload_player_playlist() await _reload_player_playlist()
async def search_playlists(self, query: str, user_id: int) -> list[Playlist]: async def search_playlists(self, query: str, user_id: int) -> list[Playlist]:
@@ -539,6 +553,24 @@ class PlaylistService:
# Reload player playlist to reflect the change (will fallback to main) # Reload player playlist to reflect the change (will fallback to main)
await _reload_player_playlist() await _reload_player_playlist()
async def _delete_playlist_sounds(self, playlist_id: int) -> None:
"""Delete all playlist_sound records for a given playlist."""
# Get all playlist_sound records for this playlist
stmt = select(PlaylistSound).where(PlaylistSound.playlist_id == playlist_id)
result = await self.session.exec(stmt)
playlist_sounds = result.all()
# Delete each playlist_sound record
for playlist_sound in playlist_sounds:
await self.session.delete(playlist_sound)
await self.session.commit()
logger.info(
"Deleted %d playlist_sound records for playlist %s",
len(playlist_sounds),
playlist_id,
)
async def _unset_current_playlist(self) -> None: async def _unset_current_playlist(self) -> None:
"""Unset any current playlist globally.""" """Unset any current playlist globally."""
current_playlist = await self.playlist_repo.get_current_playlist() current_playlist = await self.playlist_repo.get_current_playlist()

View File

@@ -1,14 +1,16 @@
"""Base TTS provider interface.""" """Base TTS provider interface."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any
# Type alias for TTS options
TTSOptions = dict[str, str | bool | int | float]
class TTSProvider(ABC): class TTSProvider(ABC):
"""Abstract base class for TTS providers.""" """Abstract base class for TTS providers."""
@abstractmethod @abstractmethod
async def generate_speech(self, text: str, **options: Any) -> bytes: async def generate_speech(self, text: str, **options: str | bool | float) -> bytes:
"""Generate speech from text with provider-specific options. """Generate speech from text with provider-specific options.
Args: Args:
@@ -17,6 +19,7 @@ class TTSProvider(ABC):
Returns: Returns:
Audio data as bytes Audio data as bytes
""" """
@abstractmethod @abstractmethod
@@ -24,7 +27,7 @@ class TTSProvider(ABC):
"""Return list of supported language codes.""" """Return list of supported language codes."""
@abstractmethod @abstractmethod
def get_option_schema(self) -> dict[str, Any]: def get_option_schema(self) -> dict[str, dict[str, str | list[str] | bool]]:
"""Return schema for provider-specific options.""" """Return schema for provider-specific options."""
@property @property

View File

@@ -2,11 +2,10 @@
import asyncio import asyncio
import io import io
from typing import Any
from gtts import gTTS from gtts import gTTS
from ..base import TTSProvider from app.services.tts.base import TTSProvider
class GTTSProvider(TTSProvider): class GTTSProvider(TTSProvider):
@@ -22,7 +21,7 @@ class GTTSProvider(TTSProvider):
"""Return the default file extension for this provider.""" """Return the default file extension for this provider."""
return "mp3" return "mp3"
async def generate_speech(self, text: str, **options: Any) -> bytes: async def generate_speech(self, text: str, **options: str | bool | float) -> bytes:
"""Generate speech from text using Google TTS. """Generate speech from text using Google TTS.
Args: Args:
@@ -31,13 +30,14 @@ class GTTSProvider(TTSProvider):
Returns: Returns:
MP3 audio data as bytes MP3 audio data as bytes
""" """
lang = options.get("lang", "en") lang = options.get("lang", "en")
tld = options.get("tld", "com") tld = options.get("tld", "com")
slow = options.get("slow", False) slow = options.get("slow", False)
# Run TTS generation in thread pool since gTTS is synchronous # Run TTS generation in thread pool since gTTS is synchronous
def _generate(): def _generate() -> bytes:
tts = gTTS(text=text, lang=lang, tld=tld, slow=slow) tts = gTTS(text=text, lang=lang, tld=tld, slow=slow)
fp = io.BytesIO() fp = io.BytesIO()
tts.write_to_fp(fp) tts.write_to_fp(fp)
@@ -49,33 +49,32 @@ class GTTSProvider(TTSProvider):
def get_supported_languages(self) -> list[str]: def get_supported_languages(self) -> list[str]:
"""Return list of supported language codes.""" """Return list of supported language codes."""
# Common GTTS supported languages # Complete list of GTTS supported languages including regional variants
return [ return [
"af", "ar", "bg", "bn", "bs", "ca", "cs", "cy", "da", "de", "el", "en", "af", "ar", "bg", "bn", "bs", "ca", "cs", "cy", "da", "de", "el",
"eo", "es", "et", "fi", "fr", "gu", "hi", "hr", "hu", "hy", "id", "is", "en", "en-au", "en-ca", "en-gb", "en-ie", "en-in", "en-ng", "en-nz",
"it", "ja", "jw", "km", "kn", "ko", "la", "lv", "mk", "ml", "mr", "my", "en-ph", "en-za", "en-tz", "en-uk", "en-us",
"ne", "nl", "no", "pl", "pt", "ro", "ru", "si", "sk", "sq", "sr", "su", "eo", "es", "es-es", "es-mx", "es-us", "et", "eu", "fa", "fi",
"sv", "sw", "ta", "te", "th", "tl", "tr", "uk", "ur", "vi", "zh-cn", "zh-tw" "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]: def get_option_schema(self) -> dict[str, dict[str, str | list[str] | bool]]:
"""Return schema for GTTS-specific options.""" """Return schema for GTTS-specific options."""
return { return {
"lang": { "lang": {
"type": "string", "type": "string",
"default": "en", "default": "en",
"description": "Language code", "description": "Language code",
"enum": self.get_supported_languages() "enum": self.get_supported_languages(),
},
"tld": {
"type": "string",
"default": "com",
"description": "Top-level domain for Google TTS",
"enum": ["com", "co.uk", "com.au", "ca", "co.in", "ie", "co.za"]
}, },
"slow": { "slow": {
"type": "boolean", "type": "boolean",
"default": False, "default": False,
"description": "Speak slowly" "description": "Speak slowly",
} },
} }

View File

@@ -10,10 +10,13 @@ from gtts import gTTS
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_session_factory
from app.core.logging import get_logger
from app.models.sound import Sound from app.models.sound import Sound
from app.models.tts import TTS from app.models.tts import TTS
from app.repositories.sound import SoundRepository from app.repositories.sound import SoundRepository
from app.repositories.tts import TTSRepository from app.repositories.tts import TTSRepository
from app.services.socket import socket_manager
from app.services.sound_normalizer import SoundNormalizerService from app.services.sound_normalizer import SoundNormalizerService
from app.utils.audio import get_audio_duration, get_file_hash, get_file_size from app.utils.audio import get_audio_duration, get_file_hash, get_file_size
@@ -25,6 +28,12 @@ MAX_TEXT_LENGTH = 1000
MAX_NAME_LENGTH = 50 MAX_NAME_LENGTH = 50
async def _get_tts_processor() -> object:
"""Get TTS processor instance, avoiding circular import."""
from app.services.tts_processor import tts_processor # noqa: PLC0415
return tts_processor
class TTSService: class TTSService:
"""Text-to-Speech service with provider management.""" """Text-to-Speech service with provider management."""
@@ -33,6 +42,7 @@ class TTSService:
Args: Args:
session: Database session session: Database session
""" """
self.session = session self.session = session
self.sound_repo = SoundRepository(session) self.sound_repo = SoundRepository(session)
@@ -51,6 +61,7 @@ class TTSService:
Args: Args:
provider: TTS provider instance provider: TTS provider instance
""" """
self.providers[provider.name] = provider self.providers[provider.name] = provider
@@ -67,7 +78,7 @@ class TTSService:
text: str, text: str,
user_id: int, user_id: int,
provider: str = "gtts", provider: str = "gtts",
**options: Any, **options: str | bool | float,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a TTS request that will be processed in the background. """Create a TTS request that will be processed in the background.
@@ -83,6 +94,7 @@ class TTSService:
Raises: Raises:
ValueError: If provider not found or text too long ValueError: If provider not found or text too long
Exception: If request creation fails Exception: If request creation fails
""" """
provider_not_found_msg = f"Provider '{provider}' not found" provider_not_found_msg = f"Provider '{provider}' not found"
if provider not in self.providers: if provider not in self.providers:
@@ -101,6 +113,7 @@ class TTSService:
text=text, text=text,
provider=provider, provider=provider,
options=options, options=options,
status="pending",
sound_id=None, # Will be set when processing completes sound_id=None, # Will be set when processing completes
user_id=user_id, user_id=user_id,
) )
@@ -108,9 +121,10 @@ class TTSService:
await self.session.commit() await self.session.commit()
await self.session.refresh(tts) await self.session.refresh(tts)
# Queue for background processing # Queue for background processing using the TTS processor
if tts.id is not None: if tts.id is not None:
await self._queue_tts_processing(tts.id) tts_processor = await _get_tts_processor()
await tts_processor.queue_tts(tts.id)
return {"tts": tts, "message": "TTS generation queued successfully"} return {"tts": tts, "message": "TTS generation queued successfully"}
@@ -120,14 +134,12 @@ class TTSService:
# This could be moved to a proper background queue later # This could be moved to a proper background queue later
task = asyncio.create_task(self._process_tts_in_background(tts_id)) task = asyncio.create_task(self._process_tts_in_background(tts_id))
# Store reference to prevent garbage collection # Store reference to prevent garbage collection
self._background_tasks = getattr(self, '_background_tasks', set()) self._background_tasks = getattr(self, "_background_tasks", set())
self._background_tasks.add(task) self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard) task.add_done_callback(self._background_tasks.discard)
async def _process_tts_in_background(self, tts_id: int) -> None: async def _process_tts_in_background(self, tts_id: int) -> None:
"""Process TTS generation in background.""" """Process TTS generation in background."""
from app.core.database import get_session_factory
try: try:
# Create a new session for background processing # Create a new session for background processing
session_factory = get_session_factory() session_factory = get_session_factory()
@@ -158,17 +170,19 @@ class TTSService:
except Exception: except Exception:
# Log error but don't fail - avoiding print for production # Log error but don't fail - avoiding print for production
pass logger = get_logger(__name__)
logger.exception("Error processing TTS generation %s", tts_id)
async def _generate_tts_sync( async def _generate_tts_sync(
self, self,
text: str, text: str,
provider: str, provider: str,
user_id: int, user_id: int,
options: dict[str, Any] options: dict[str, Any],
) -> Sound: ) -> Sound:
"""Generate TTS using a synchronous approach.""" """Generate TTS using a synchronous approach."""
# Generate the audio using the provider (avoid async issues by doing it directly) # Generate the audio using the provider
# (avoid async issues by doing it directly)
tts_provider = self.providers[provider] tts_provider = self.providers[provider]
# Create directories if they don't exist # Create directories if they don't exist
@@ -197,17 +211,19 @@ class TTSService:
original_path.write_bytes(audio_bytes) original_path.write_bytes(audio_bytes)
except Exception: except Exception:
logger = get_logger(__name__)
logger.exception("Error generating TTS audio")
raise raise
# Create Sound record with proper metadata # Create Sound record with proper metadata
sound = await self._create_sound_record_complete( sound = await self._create_sound_record_complete(
original_path, original_path,
text, text,
provider, user_id,
user_id
) )
# Normalize the sound # Normalize the sound
if sound.id is not None:
await self._normalize_sound_safe(sound.id) await self._normalize_sound_safe(sound.id)
return sound return sound
@@ -216,7 +232,7 @@ class TTSService:
self, self,
user_id: int, user_id: int,
limit: int = 50, limit: int = 50,
offset: int = 0 offset: int = 0,
) -> list[TTS]: ) -> list[TTS]:
"""Get TTS history for a user. """Get TTS history for a user.
@@ -227,6 +243,7 @@ class TTSService:
Returns: Returns:
List of TTS records List of TTS records
""" """
result = await self.tts_repo.get_by_user_id(user_id, limit, offset) result = await self.tts_repo.get_by_user_id(user_id, limit, offset)
return list(result) return list(result)
@@ -235,19 +252,20 @@ class TTSService:
self, self,
audio_path: Path, audio_path: Path,
text: str, text: str,
provider: str,
user_id: int, user_id: int,
file_hash: str file_hash: str,
) -> Sound: ) -> Sound:
"""Create a Sound record for the TTS audio.""" """Create a Sound record for the TTS audio."""
# Get audio metadata # Get audio metadata
duration = get_audio_duration(audio_path) duration = get_audio_duration(audio_path)
size = get_file_size(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 # Create sound data
sound_data = { sound_data = {
"type": "TTS", "type": "TTS",
"name": text[:50] + ("..." if len(text) > 50 else ""), "name": name,
"filename": audio_path.name, "filename": audio_path.name,
"duration": duration, "duration": duration,
"size": size, "size": size,
@@ -259,21 +277,22 @@ class TTSService:
"play_count": 0, "play_count": 0,
} }
sound = await self.sound_repo.create(sound_data) return await self.sound_repo.create(sound_data)
return sound
async def _create_sound_record_simple( async def _create_sound_record_simple(
self, self,
audio_path: Path, audio_path: Path,
text: str, text: str,
provider: str, user_id: int,
user_id: int
) -> Sound: ) -> Sound:
"""Create a Sound record for the TTS audio with minimal processing.""" """Create a Sound record for the TTS audio with minimal processing."""
# Create sound data with basic info # 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 = { sound_data = {
"type": "TTS", "type": "TTS",
"name": text[:50] + ("..." if len(text) > 50 else ""), "name": name,
"filename": audio_path.name, "filename": audio_path.name,
"duration": 0, # Skip duration calculation for now "duration": 0, # Skip duration calculation for now
"size": 0, # Skip size calculation for now "size": 0, # Skip size calculation for now
@@ -285,21 +304,21 @@ class TTSService:
"play_count": 0, "play_count": 0,
} }
sound = await self.sound_repo.create(sound_data) return await self.sound_repo.create(sound_data)
return sound
async def _create_sound_record_complete( async def _create_sound_record_complete(
self, self,
audio_path: Path, audio_path: Path,
text: str, text: str,
provider: str, user_id: int,
user_id: int
) -> Sound: ) -> Sound:
"""Create a Sound record for the TTS audio with complete metadata.""" """Create a Sound record for the TTS audio with complete metadata."""
# Get audio metadata # Get audio metadata
duration = get_audio_duration(audio_path) duration = get_audio_duration(audio_path)
size = get_file_size(audio_path) size = get_file_size(audio_path)
file_hash = get_file_hash(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 # Check if a sound with this hash already exists
existing_sound = await self.sound_repo.get_by_hash(file_hash) existing_sound = await self.sound_repo.get_by_hash(file_hash)
@@ -313,7 +332,7 @@ class TTSService:
# Create sound data with complete metadata # Create sound data with complete metadata
sound_data = { sound_data = {
"type": "TTS", "type": "TTS",
"name": text[:50] + ("..." if len(text) > 50 else ""), "name": name,
"filename": audio_path.name, "filename": audio_path.name,
"duration": duration, "duration": duration,
"size": size, "size": size,
@@ -325,8 +344,7 @@ class TTSService:
"play_count": 0, "play_count": 0,
} }
sound = await self.sound_repo.create(sound_data) return await self.sound_repo.create(sound_data)
return sound
async def _normalize_sound_safe(self, sound_id: int) -> None: async def _normalize_sound_safe(self, sound_id: int) -> None:
"""Normalize the TTS sound with error handling.""" """Normalize the TTS sound with error handling."""
@@ -340,10 +358,16 @@ class TTSService:
result = await normalizer_service.normalize_sound(sound) result = await normalizer_service.normalize_sound(sound)
if result["status"] == "error": if result["status"] == "error":
print(f"Warning: Failed to normalize TTS sound {sound_id}: {result.get('error')}") logger = get_logger(__name__)
logger.warning(
"Warning: Failed to normalize TTS sound %s: %s",
sound_id,
result.get("error"),
)
except Exception as e: except Exception:
print(f"Exception during TTS sound normalization {sound_id}: {e}") logger = get_logger(__name__)
logger.exception("Exception during TTS sound normalization %s", sound_id)
# Don't fail the TTS generation if normalization fails # Don't fail the TTS generation if normalization fails
async def _normalize_sound(self, sound_id: int) -> None: async def _normalize_sound(self, sound_id: int) -> None:
@@ -363,18 +387,23 @@ class TTSService:
except Exception: except Exception:
# Don't fail the TTS generation if normalization fails # Don't fail the TTS generation if normalization fails
pass logger = get_logger(__name__)
logger.exception("Error normalizing sound %s", sound_id)
async def delete_tts(self, tts_id: int, user_id: int) -> None: async def delete_tts(self, tts_id: int, user_id: int) -> None:
"""Delete a TTS generation and its associated sound and files.""" """Delete a TTS generation and its associated sound and files."""
# Get the TTS record # Get the TTS record
tts = await self.tts_repo.get_by_id(tts_id) tts = await self.tts_repo.get_by_id(tts_id)
if not tts: if not tts:
raise ValueError(f"TTS with ID {tts_id} not found") tts_not_found_msg = f"TTS with ID {tts_id} not found"
raise ValueError(tts_not_found_msg)
# Check ownership # Check ownership
if tts.user_id != user_id: if tts.user_id != user_id:
raise PermissionError("You don't have permission to delete this TTS generation") permission_error_msg = (
"You don't have permission to delete this TTS generation"
)
raise PermissionError(permission_error_msg)
# If there's an associated sound, delete it and its files # If there's an associated sound, delete it and its files
if tts.sound_id: if tts.sound_id:
@@ -390,8 +419,6 @@ class TTSService:
async def _delete_sound_files(self, sound: Sound) -> None: async def _delete_sound_files(self, sound: Sound) -> None:
"""Delete all files associated with a sound.""" """Delete all files associated with a sound."""
from pathlib import Path
# Delete original file # Delete original file
original_path = Path("sounds/originals/text_to_speech") / sound.filename original_path = Path("sounds/originals/text_to_speech") / sound.filename
if original_path.exists(): if original_path.exists():
@@ -399,6 +426,130 @@ class TTSService:
# Delete normalized file if it exists # Delete normalized file if it exists
if sound.normalized_filename: if sound.normalized_filename:
normalized_path = Path("sounds/normalized/text_to_speech") / sound.normalized_filename normalized_path = (
Path("sounds/normalized/text_to_speech") / sound.normalized_filename
)
if normalized_path.exists(): if normalized_path.exists():
normalized_path.unlink() 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:
tts_not_found_msg = f"TTS with ID {tts_id} not found"
raise ValueError(tts_not_found_msg)
# 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
if sound_id is None:
sound_creation_error = "Sound creation failed - no ID assigned"
raise ValueError(sound_creation_error)
# 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:
logger = get_logger(__name__)
data = {
"tts_id": tts_id,
"sound_id": sound_id,
}
if error:
data["error"] = error
logger.info("Emitting TTS socket event: %s with data: %s", event, data)
await socket_manager.broadcast_to_all(event, data)
logger.info("Successfully emitted TTS socket event: %s", event)
except Exception:
# Don't fail TTS processing if socket emission fails
logger = get_logger(__name__)
logger.exception("Failed to emit TTS socket event %s", event)

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

@@ -70,12 +70,14 @@ class VLCPlayerService:
) )
return "vlc" return "vlc"
async def play_sound(self, sound: Sound, volume: int | None = None) -> bool: async def play_sound(self, sound: Sound) -> bool:
"""Play a sound using a new VLC subprocess instance. """Play a sound using a new VLC subprocess instance.
VLC always plays at 100% volume. Host system volume is controlled separately
by the player service.
Args: Args:
sound: The Sound object to play sound: The Sound object to play
volume: Volume level (0-100). If None, uses current player volume.
Returns: Returns:
bool: True if VLC process was launched successfully, False otherwise bool: True if VLC process was launched successfully, False otherwise
@@ -88,19 +90,6 @@ class VLCPlayerService:
logger.error("Sound file not found: %s", sound_path) logger.error("Sound file not found: %s", sound_path)
return False return False
# Get volume from player service if not provided
if volume is None:
try:
from app.services.player import get_player_service # noqa: PLC0415
player = get_player_service()
volume = player.state.volume
except RuntimeError:
logger.warning("Could not get player volume, using default 80")
volume = 80
# Ensure volume is in valid range and is an integer
volume = max(0, min(100, int(volume))) if volume is not None else 80
# VLC command arguments for immediate playback # VLC command arguments for immediate playback
cmd = [ cmd = [
self.vlc_executable, self.vlc_executable,
@@ -111,7 +100,7 @@ class VLCPlayerService:
"--no-video", # Audio only "--no-video", # Audio only
"--no-repeat", # Don't repeat "--no-repeat", # Don't repeat
"--no-loop", # Don't loop "--no-loop", # Don't loop
f"--volume={volume}", # Set volume to match player "--volume=100", # Always use 100% VLC volume
] ]
# Launch VLC process asynchronously without waiting # Launch VLC process asynchronously without waiting
@@ -347,7 +336,10 @@ class VLCPlayerService:
""" """
from fastapi import HTTPException, status # noqa: PLC0415, I001 from fastapi import HTTPException, status # noqa: PLC0415, I001
from app.services.credit import CreditService, InsufficientCreditsError # noqa: PLC0415 from app.services.credit import ( # noqa: PLC0415
CreditService,
InsufficientCreditsError,
)
if not self.db_session_factory: if not self.db_session_factory:
raise HTTPException( raise HTTPException(
@@ -384,19 +376,8 @@ class VLCPlayerService:
), ),
) from e ) from e
# Get current player volume # Play the sound using VLC (always at 100% VLC volume)
try: success = await self.play_sound(sound)
from app.services.player import get_player_service # noqa: PLC0415
player = get_player_service()
current_volume = player.state.volume
except RuntimeError:
logger.warning(
"Could not get player volume for credit play, using default 80",
)
current_volume = 80
# Play the sound using VLC with current player volume
success = await self.play_sound(sound, current_volume)
# Deduct credits based on success # Deduct credits based on success
await credit_service.deduct_credits( await credit_service.deduct_credits(
@@ -433,4 +414,3 @@ def get_vlc_player_service(
if vlc_player_service is None: if vlc_player_service is None:
vlc_player_service = VLCPlayerService(db_session_factory) vlc_player_service = VLCPlayerService(db_session_factory)
return vlc_player_service return vlc_player_service
return vlc_player_service

251
app/services/volume.py Normal file
View File

@@ -0,0 +1,251 @@
"""Volume service for host system volume control."""
import platform
from app.core.logging import get_logger
logger = get_logger(__name__)
# Constants
MIN_VOLUME = 0
MAX_VOLUME = 100
class VolumeService:
"""Service for controlling host system volume."""
def __init__(self) -> None:
"""Initialize volume service."""
self._system = platform.system().lower()
self._pycaw_available = False
self._pulsectl_available = False
# Try to import Windows volume control
if self._system == "windows":
try:
from comtypes import ( # noqa: PLC0415
CLSCTX_ALL, # type: ignore[import-untyped]
)
from pycaw.pycaw import ( # type: ignore[import-untyped] # noqa: PLC0415
AudioUtilities,
IAudioEndpointVolume,
)
self._AudioUtilities = AudioUtilities
self._IAudioEndpointVolume = IAudioEndpointVolume
self._CLSCTX_ALL = CLSCTX_ALL
self._pycaw_available = True
logger.info("Windows volume control (pycaw) initialized")
except ImportError as e:
logger.warning("pycaw not available: %s", e)
# Try to import Linux volume control
elif self._system == "linux":
try:
import pulsectl # type: ignore[import-untyped] # noqa: PLC0415
self._pulsectl = pulsectl
self._pulsectl_available = True
logger.info("Linux volume control (pulsectl) initialized")
except ImportError as e:
logger.warning("pulsectl not available: %s", e)
else:
logger.warning("Volume control not supported on %s", self._system)
def get_volume(self) -> int | None:
"""Get the current system volume as a percentage (0-100).
Returns:
Volume level as percentage, or None if not available
"""
try:
if self._system == "windows" and self._pycaw_available:
return self._get_windows_volume()
if self._system == "linux" and self._pulsectl_available:
return self._get_linux_volume()
except Exception:
logger.exception("Failed to get volume")
return None
else:
logger.warning("Volume control not available for this system")
return None
def set_volume(self, volume: int) -> bool:
"""Set the system volume to a percentage (0-100).
Args:
volume: Volume level as percentage (0-100)
Returns:
True if successful, False otherwise
"""
if not (MIN_VOLUME <= volume <= MAX_VOLUME):
logger.error(
"Volume must be between %s and %s, got %s",
MIN_VOLUME,
MAX_VOLUME,
volume,
)
return False
try:
if self._system == "windows" and self._pycaw_available:
return self._set_windows_volume(volume)
if self._system == "linux" and self._pulsectl_available:
return self._set_linux_volume(volume)
except Exception:
logger.exception("Failed to set volume")
return False
else:
logger.warning("Volume control not available for this system")
return False
def is_muted(self) -> bool | None:
"""Check if the system is muted.
Returns:
True if muted, False if not muted, None if not available
"""
try:
if self._system == "windows" and self._pycaw_available:
return self._get_windows_mute_status()
if self._system == "linux" and self._pulsectl_available:
return self._get_linux_mute_status()
except Exception:
logger.exception("Failed to get mute status")
return None
else:
logger.warning("Mute status not available for this system")
return None
def set_mute(self, *, muted: bool) -> bool:
"""Set the system mute status.
Args:
muted: True to mute, False to unmute
Returns:
True if successful, False otherwise
"""
try:
if self._system == "windows" and self._pycaw_available:
return self._set_windows_mute(muted=muted)
if self._system == "linux" and self._pulsectl_available:
return self._set_linux_mute(muted=muted)
except Exception:
logger.exception("Failed to set mute status")
return False
else:
logger.warning("Mute control not available for this system")
return False
def _get_windows_volume(self) -> int:
"""Get Windows volume using pycaw."""
devices = self._AudioUtilities.GetSpeakers()
interface = devices.Activate(
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
)
volume = interface.QueryInterface(self._IAudioEndpointVolume)
current_volume = volume.GetMasterVolume()
# Convert from scalar (0.0-1.0) to percentage (0-100)
return int(current_volume * MAX_VOLUME)
def _set_windows_volume(self, volume_percent: int) -> bool:
"""Set Windows volume using pycaw."""
devices = self._AudioUtilities.GetSpeakers()
interface = devices.Activate(
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
)
volume = interface.QueryInterface(self._IAudioEndpointVolume)
# Convert from percentage (0-100) to scalar (0.0-1.0)
volume_scalar = volume_percent / MAX_VOLUME
volume.SetMasterVolume(volume_scalar, None)
logger.info("Windows volume set to %s%%", volume_percent)
return True
def _get_windows_mute_status(self) -> bool:
"""Get Windows mute status using pycaw."""
devices = self._AudioUtilities.GetSpeakers()
interface = devices.Activate(
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
)
volume = interface.QueryInterface(self._IAudioEndpointVolume)
return bool(volume.GetMute())
def _set_windows_mute(self, *, muted: bool) -> bool:
"""Set Windows mute status using pycaw."""
devices = self._AudioUtilities.GetSpeakers()
interface = devices.Activate(
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
)
volume = interface.QueryInterface(self._IAudioEndpointVolume)
volume.SetMute(muted, None)
logger.info("Windows mute set to %s", muted)
return True
def _get_linux_volume(self) -> int:
"""Get Linux volume using pulsectl."""
with self._pulsectl.Pulse("volume-service") as pulse:
# Get the default sink (output device)
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
if default_sink is None:
logger.error("No default audio sink found")
return MIN_VOLUME
# Get volume as percentage (PulseAudio uses 0.0-1.0, we convert to 0-100)
volume = default_sink.volume
avg_volume = sum(volume.values) / len(volume.values)
return int(avg_volume * MAX_VOLUME)
def _set_linux_volume(self, volume_percent: int) -> bool:
"""Set Linux volume using pulsectl."""
with self._pulsectl.Pulse("volume-service") as pulse:
# Get the default sink (output device)
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
if default_sink is None:
logger.error("No default audio sink found")
return False
# Convert percentage to PulseAudio volume (0.0-1.0)
volume_scalar = volume_percent / MAX_VOLUME
# Set volume for all channels
pulse.volume_set_all_chans(default_sink, volume_scalar)
logger.info("Linux volume set to %s%%", volume_percent)
return True
def _get_linux_mute_status(self) -> bool:
"""Get Linux mute status using pulsectl."""
with self._pulsectl.Pulse("volume-service") as pulse:
# Get the default sink (output device)
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
if default_sink is None:
logger.error("No default audio sink found")
return False
return bool(default_sink.mute)
def _set_linux_mute(self, *, muted: bool) -> bool:
"""Set Linux mute status using pulsectl."""
with self._pulsectl.Pulse("volume-service") as pulse:
# Get the default sink (output device)
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
if default_sink is None:
logger.error("No default audio sink found")
return False
# Set mute status
pulse.mute(default_sink, muted)
logger.info("Linux mute set to %s", muted)
return True
# Global volume service instance
volume_service = VolumeService()

View File

@@ -8,33 +8,35 @@ dependencies = [
"aiosqlite==0.21.0", "aiosqlite==0.21.0",
"alembic==1.16.5", "alembic==1.16.5",
"apscheduler==3.11.0", "apscheduler==3.11.0",
"bcrypt==4.3.0", "bcrypt==5.0.0",
"email-validator==2.3.0", "email-validator==2.3.0",
"fastapi[standard]==0.117.1", "fastapi[standard]==0.118.0",
"ffmpeg-python==0.2.0", "ffmpeg-python==0.2.0",
"gtts==2.5.4", "gtts==2.5.4",
"httpx==0.28.1", "httpx==0.28.1",
"pydantic-settings==2.10.1", "pydantic-settings==2.11.0",
"pyjwt==2.10.1", "pyjwt==2.10.1",
"python-socketio==5.13.0", "python-socketio==5.14.1",
"pytz==2025.2", "pytz==2025.2",
"python-vlc==3.0.21203", "python-vlc==3.0.21203",
"sqlmodel==0.0.25", "sqlmodel==0.0.25",
"uvicorn[standard]==0.36.0", "uvicorn[standard]==0.37.0",
"yt-dlp==2025.9.5", "yt-dlp==2025.9.26",
"asyncpg==0.30.0", "asyncpg==0.30.0",
"psycopg[binary]==3.2.10", "psycopg[binary]==3.2.10",
"pycaw>=20240210",
"pulsectl>=24.12.0",
] ]
[tool.uv] [tool.uv]
dev-dependencies = [ dev-dependencies = [
"coverage==7.10.6", "coverage==7.10.7",
"faker==37.8.0", "faker==37.8.0",
"httpx==0.28.1", "httpx==0.28.1",
"mypy==1.18.1", "mypy==1.18.2",
"pytest==8.4.2", "pytest==8.4.2",
"pytest-asyncio==1.2.0", "pytest-asyncio==1.2.0",
"ruff==0.13.0", "ruff==0.13.3",
] ]
[tool.mypy] [tool.mypy]

View File

@@ -537,6 +537,7 @@ class TestPlayerEndpoints:
"duration": 30000, "duration": 30000,
"sounds": [], "sounds": [],
}, },
"play_next_queue": [],
} }
mock_player_service.get_state.return_value = mock_state mock_player_service.get_state.return_value = mock_state

View File

@@ -15,19 +15,32 @@ def mock_sound_repository():
@pytest.fixture @pytest.fixture
def dashboard_service(mock_sound_repository): def mock_user_repository():
"""Mock user repository."""
return Mock()
@pytest.fixture
def dashboard_service(mock_sound_repository, mock_user_repository):
"""Dashboard service with mocked dependencies.""" """Dashboard service with mocked dependencies."""
return DashboardService(sound_repository=mock_sound_repository) return DashboardService(
sound_repository=mock_sound_repository,
user_repository=mock_user_repository,
)
class TestDashboardService: class TestDashboardService:
"""Test dashboard service.""" """Test dashboard service."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_init(self, mock_sound_repository): async def test_init(self, mock_sound_repository, mock_user_repository):
"""Test dashboard service initialization.""" """Test dashboard service initialization."""
service = DashboardService(sound_repository=mock_sound_repository) service = DashboardService(
sound_repository=mock_sound_repository,
user_repository=mock_user_repository,
)
assert service.sound_repository == mock_sound_repository assert service.sound_repository == mock_sound_repository
assert service.user_repository == mock_user_repository
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_soundboard_statistics_success( async def test_get_soundboard_statistics_success(

View File

@@ -26,12 +26,17 @@ class TestPlayerState:
def test_init_creates_default_state(self) -> None: def test_init_creates_default_state(self) -> None:
"""Test that player state initializes with default values.""" """Test that player state initializes with default values."""
# Mock volume service to return a specific volume
with patch("app.services.player.volume_service") as mock_volume_service:
mock_volume_service.get_volume.return_value = 80
state = PlayerState() state = PlayerState()
assert state.status == PlayerStatus.STOPPED assert state.status == PlayerStatus.STOPPED
assert state.mode == PlayerMode.CONTINUOUS assert state.mode == PlayerMode.CONTINUOUS
assert state.volume == 80 assert state.volume == 80
assert state.previous_volume == 80 assert state.previous_volume == 80
mock_volume_service.get_volume.assert_called_once()
assert state.current_sound_id is None assert state.current_sound_id is None
assert state.current_sound_index is None assert state.current_sound_index is None
assert state.current_sound_position == 0 assert state.current_sound_position == 0
@@ -181,6 +186,8 @@ class TestPlayerService:
mock_socket_manager, mock_socket_manager,
): ):
"""Create a player service instance for testing.""" """Create a player service instance for testing."""
with patch("app.services.player.volume_service") as mock_volume_service:
mock_volume_service.get_volume.return_value = 80
return PlayerService(mock_db_session_factory) return PlayerService(mock_db_session_factory)
def test_init_creates_player_service( def test_init_creates_player_service(
@@ -217,7 +224,8 @@ class TestPlayerService:
assert player_service._loop is not None assert player_service._loop is not None
assert player_service._position_thread is not None assert player_service._position_thread is not None
assert player_service._position_thread.daemon is True assert player_service._position_thread.daemon is True
player_service._player.audio_set_volume.assert_called_once_with(80) # VLC is now always set to 100% volume
player_service._player.audio_set_volume.assert_called_once_with(100)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_cleans_up_service(self, player_service) -> None: async def test_stop_cleans_up_service(self, player_service) -> None:
@@ -399,16 +407,25 @@ class TestPlayerService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_volume(self, player_service) -> None: async def test_set_volume(self, player_service) -> None:
"""Test setting volume.""" """Test setting volume."""
with patch("app.services.player.volume_service") as mock_volume_service:
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
mock_volume_service.is_muted.return_value = False
await player_service.set_volume(75) await player_service.set_volume(75)
assert player_service.state.volume == 75 assert player_service.state.volume == 75
player_service._player.audio_set_volume.assert_called_once_with(75) # VLC volume is always set to 100%, host volume is controlled separately
player_service._player.audio_set_volume.assert_called_once_with(100)
# Verify host volume was set
mock_volume_service.set_volume.assert_called_once_with(75)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_volume_clamping(self, player_service) -> None: async def test_set_volume_clamping(self, player_service) -> None:
"""Test volume clamping to valid range.""" """Test volume clamping to valid range."""
with patch("app.services.player.volume_service") as mock_volume_service:
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
mock_volume_service.is_muted.return_value = False
# Test upper bound # Test upper bound
await player_service.set_volume(150) await player_service.set_volume(150)
assert player_service.state.volume == 100 assert player_service.state.volume == 100

413
uv.lock generated
View File

@@ -89,52 +89,68 @@ wheels = [
[[package]] [[package]]
name = "bcrypt" name = "bcrypt"
version = "4.3.0" version = "5.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697 } sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719 }, { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806 },
{ url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001 }, { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626 },
{ url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451 }, { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853 },
{ url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792 }, { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793 },
{ url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752 }, { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930 },
{ url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762 }, { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194 },
{ url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384 }, { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381 },
{ url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329 }, { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750 },
{ url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241 }, { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757 },
{ url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617 }, { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740 },
{ url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751 }, { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197 },
{ url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965 }, { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974 },
{ url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316 }, { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498 },
{ url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752 }, { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853 },
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019 }, { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626 },
{ url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174 }, { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862 },
{ url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870 }, { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544 },
{ url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601 }, { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787 },
{ url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660 }, { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753 },
{ url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083 }, { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587 },
{ url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237 }, { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178 },
{ url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737 }, { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295 },
{ url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741 }, { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700 },
{ url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472 }, { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034 },
{ url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606 }, { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766 },
{ url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867 }, { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449 },
{ url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589 }, { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310 },
{ url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794 }, { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761 },
{ url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969 }, { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553 },
{ url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158 }, { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009 },
{ url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285 }, { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029 },
{ url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583 }, { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907 },
{ url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896 }, { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500 },
{ url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492 }, { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412 },
{ url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213 }, { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486 },
{ url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162 }, { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940 },
{ url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856 }, { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776 },
{ url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726 }, { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922 },
{ url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664 }, { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367 },
{ url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128 }, { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187 },
{ url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598 }, { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752 },
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799 }, { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881 },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931 },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313 },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290 },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253 },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084 },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185 },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656 },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662 },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240 },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152 },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284 },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643 },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698 },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725 },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912 },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953 },
] ]
[[package]] [[package]]
@@ -219,67 +235,86 @@ wheels = [
] ]
[[package]] [[package]]
name = "coverage" name = "comtypes"
version = "7.10.6" version = "1.4.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736 } sdist = { url = "https://files.pythonhosted.org/packages/b6/b8/3af03195b9de515448292169c6d6d7a630de02bedf891a47b809638c186f/comtypes-1.4.12.zip", hash = "sha256:3ff06c442c2de8a2b25785407f244eb5b6f809d21cf068a855071ba80a76876f", size = 280541 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/06/263f3305c97ad78aab066d116b52250dd316e74fcc20c197b61e07eb391a/coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea", size = 217324 }, { url = "https://files.pythonhosted.org/packages/2d/01/89285549c5138009db68f26c80f2174d0ec82a858547df0cc40a8b0a47d6/comtypes-1.4.12-py3-none-any.whl", hash = "sha256:e0fa9cc19c489fa7feea4c1710f4575c717e2673edef5b99bf99efd507908e44", size = 253704 },
{ url = "https://files.pythonhosted.org/packages/e9/60/1e1ded9a4fe80d843d7d53b3e395c1db3ff32d6c301e501f393b2e6c1c1f/coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634", size = 217560 }, ]
{ url = "https://files.pythonhosted.org/packages/b8/25/52136173c14e26dfed8b106ed725811bb53c30b896d04d28d74cb64318b3/coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6", size = 249053 },
{ url = "https://files.pythonhosted.org/packages/cb/1d/ae25a7dc58fcce8b172d42ffe5313fc267afe61c97fa872b80ee72d9515a/coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9", size = 251802 }, [[package]]
{ url = "https://files.pythonhosted.org/packages/f5/7a/1f561d47743710fe996957ed7c124b421320f150f1d38523d8d9102d3e2a/coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c", size = 252935 }, name = "coverage"
{ url = "https://files.pythonhosted.org/packages/6c/ad/8b97cd5d28aecdfde792dcbf646bac141167a5cacae2cd775998b45fabb5/coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a", size = 250855 }, version = "7.10.7"
{ url = "https://files.pythonhosted.org/packages/33/6a/95c32b558d9a61858ff9d79580d3877df3eb5bc9eed0941b1f187c89e143/coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5", size = 248974 }, source = { registry = "https://pypi.org/simple" }
{ url = "https://files.pythonhosted.org/packages/0d/9c/8ce95dee640a38e760d5b747c10913e7a06554704d60b41e73fdea6a1ffd/coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972", size = 250409 }, sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704 }
{ url = "https://files.pythonhosted.org/packages/04/12/7a55b0bdde78a98e2eb2356771fd2dcddb96579e8342bb52aa5bc52e96f0/coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d", size = 219724 }, wheels = [
{ url = "https://files.pythonhosted.org/packages/36/4a/32b185b8b8e327802c9efce3d3108d2fe2d9d31f153a0f7ecfd59c773705/coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629", size = 220536 }, { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290 },
{ url = "https://files.pythonhosted.org/packages/08/3a/d5d8dc703e4998038c3099eaf77adddb00536a3cec08c8dcd556a36a3eb4/coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80", size = 219171 }, { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515 },
{ url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351 }, { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020 },
{ url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600 }, { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769 },
{ url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600 }, { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901 },
{ url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206 }, { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413 },
{ url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478 }, { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820 },
{ url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637 }, { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941 },
{ url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529 }, { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519 },
{ url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143 }, { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375 },
{ url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770 }, { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699 },
{ url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566 }, { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512 },
{ url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195 }, { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147 },
{ url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059 }, { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320 },
{ url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287 }, { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575 },
{ url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625 }, { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568 },
{ url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801 }, { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174 },
{ url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027 }, { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447 },
{ url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576 }, { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779 },
{ url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341 }, { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604 },
{ url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468 }, { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497 },
{ url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429 }, { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350 },
{ url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493 }, { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111 },
{ url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757 }, { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746 },
{ url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331 }, { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541 },
{ url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607 }, { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170 },
{ url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663 }, { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029 },
{ url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197 }, { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259 },
{ url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551 }, { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592 },
{ url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553 }, { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768 },
{ url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486 }, { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995 },
{ url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981 }, { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546 },
{ url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054 }, { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544 },
{ url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851 }, { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308 },
{ url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429 }, { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920 },
{ url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080 }, { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434 },
{ url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293 }, { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403 },
{ url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800 }, { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469 },
{ url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965 }, { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731 },
{ url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220 }, { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302 },
{ url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660 }, { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578 },
{ url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417 }, { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629 },
{ url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567 }, { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162 },
{ url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831 }, { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517 },
{ url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950 }, { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632 },
{ url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969 }, { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520 },
{ url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986 }, { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455 },
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287 },
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946 },
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009 },
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804 },
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384 },
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047 },
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266 },
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767 },
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931 },
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186 },
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470 },
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626 },
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386 },
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852 },
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534 },
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784 },
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905 },
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922 },
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952 },
] ]
[[package]] [[package]]
@@ -318,16 +353,16 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.117.1" version = "0.118.0"
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/7e/7e/d9788300deaf416178f61fb3c2ceb16b7d0dc9f82a08fdb87a5e64ee3cc7/fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a", size = 307155 } sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/45/d9d3e8eeefbe93be1c50060a9d9a9f366dba66f288bb518a9566a23a8631/fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552", size = 95959 }, { url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694 },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -607,34 +642,34 @@ wheels = [
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "1.18.1" version = "1.18.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mypy-extensions" }, { name = "mypy-extensions" },
{ name = "pathspec" }, { name = "pathspec" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/14/a3/931e09fc02d7ba96da65266884da4e4a8806adcdb8a57faaacc6edf1d538/mypy-1.18.1.tar.gz", hash = "sha256:9e988c64ad3ac5987f43f5154f884747faf62141b7f842e87465b45299eea5a9", size = 3448447 } sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/14/1c3f54d606cb88a55d1567153ef3a8bc7b74702f2ff5eb64d0994f9e49cb/mypy-1.18.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:502cde8896be8e638588b90fdcb4c5d5b8c1b004dfc63fd5604a973547367bb9", size = 12911082 }, { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273 },
{ url = "https://files.pythonhosted.org/packages/90/83/235606c8b6d50a8eba99773add907ce1d41c068edb523f81eb0d01603a83/mypy-1.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7509549b5e41be279afc1228242d0e397f1af2919a8f2877ad542b199dc4083e", size = 11919107 }, { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910 },
{ url = "https://files.pythonhosted.org/packages/ca/25/4e2ce00f8d15b99d0c68a2536ad63e9eac033f723439ef80290ec32c1ff5/mypy-1.18.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5956ecaabb3a245e3f34100172abca1507be687377fe20e24d6a7557e07080e2", size = 12472551 }, { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585 },
{ url = "https://files.pythonhosted.org/packages/32/bb/92642a9350fc339dd9dcefcf6862d171b52294af107d521dce075f32f298/mypy-1.18.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8750ceb014a96c9890421c83f0db53b0f3b8633e2864c6f9bc0a8e93951ed18d", size = 13340554 }, { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562 },
{ url = "https://files.pythonhosted.org/packages/cd/ee/38d01db91c198fb6350025d28f9719ecf3c8f2c55a0094bfbf3ef478cc9a/mypy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb89ea08ff41adf59476b235293679a6eb53a7b9400f6256272fb6029bec3ce5", size = 13530933 }, { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296 },
{ url = "https://files.pythonhosted.org/packages/da/8d/6d991ae631f80d58edbf9d7066e3f2a96e479dca955d9a968cd6e90850a3/mypy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:2657654d82fcd2a87e02a33e0d23001789a554059bbf34702d623dafe353eabf", size = 9828426 }, { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828 },
{ url = "https://files.pythonhosted.org/packages/e4/ec/ef4a7260e1460a3071628a9277a7579e7da1b071bc134ebe909323f2fbc7/mypy-1.18.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d70d2b5baf9b9a20bc9c730015615ae3243ef47fb4a58ad7b31c3e0a59b5ef1f", size = 12918671 }, { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728 },
{ url = "https://files.pythonhosted.org/packages/a1/82/0ea6c3953f16223f0b8eda40c1aeac6bd266d15f4902556ae6e91f6fca4c/mypy-1.18.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8367e33506300f07a43012fc546402f283c3f8bcff1dc338636affb710154ce", size = 11913023 }, { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758 },
{ url = "https://files.pythonhosted.org/packages/ae/ef/5e2057e692c2690fc27b3ed0a4dbde4388330c32e2576a23f0302bc8358d/mypy-1.18.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:913f668ec50c3337b89df22f973c1c8f0b29ee9e290a8b7fe01cc1ef7446d42e", size = 12473355 }, { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342 },
{ url = "https://files.pythonhosted.org/packages/98/43/b7e429fc4be10e390a167b0cd1810d41cb4e4add4ae50bab96faff695a3b/mypy-1.18.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0e70b87eb27b33209fa4792b051c6947976f6ab829daa83819df5f58330c71", size = 13346944 }, { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709 },
{ url = "https://files.pythonhosted.org/packages/89/4e/899dba0bfe36bbd5b7c52e597de4cf47b5053d337b6d201a30e3798e77a6/mypy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c378d946e8a60be6b6ede48c878d145546fb42aad61df998c056ec151bf6c746", size = 13512574 }, { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806 },
{ url = "https://files.pythonhosted.org/packages/f5/f8/7661021a5b0e501b76440454d786b0f01bb05d5c4b125fcbda02023d0250/mypy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:2cd2c1e0f3a7465f22731987fff6fc427e3dcbb4ca5f7db5bbeaff2ff9a31f6d", size = 9837684 }, { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262 },
{ url = "https://files.pythonhosted.org/packages/bf/87/7b173981466219eccc64c107cf8e5ab9eb39cc304b4c07df8e7881533e4f/mypy-1.18.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ba24603c58e34dd5b096dfad792d87b304fc6470cbb1c22fd64e7ebd17edcc61", size = 12900265 }, { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775 },
{ url = "https://files.pythonhosted.org/packages/ae/cc/b10e65bae75b18a5ac8f81b1e8e5867677e418f0dd2c83b8e2de9ba96ebd/mypy-1.18.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ed36662fb92ae4cb3cacc682ec6656208f323bbc23d4b08d091eecfc0863d4b5", size = 11942890 }, { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852 },
{ url = "https://files.pythonhosted.org/packages/39/d4/aeefa07c44d09f4c2102e525e2031bc066d12e5351f66b8a83719671004d/mypy-1.18.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:040ecc95e026f71a9ad7956fea2724466602b561e6a25c2e5584160d3833aaa8", size = 12472291 }, { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242 },
{ url = "https://files.pythonhosted.org/packages/c6/07/711e78668ff8e365f8c19735594ea95938bff3639a4c46a905e3ed8ff2d6/mypy-1.18.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:937e3ed86cb731276706e46e03512547e43c391a13f363e08d0fee49a7c38a0d", size = 13318610 }, { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683 },
{ url = "https://files.pythonhosted.org/packages/ca/85/df3b2d39339c31d360ce299b418c55e8194ef3205284739b64962f6074e7/mypy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1f95cc4f01c0f1701ca3b0355792bccec13ecb2ec1c469e5b85a6ef398398b1d", size = 13513697 }, { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749 },
{ url = "https://files.pythonhosted.org/packages/b1/df/462866163c99ea73bb28f0eb4d415c087e30de5d36ee0f5429d42e28689b/mypy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:e4f16c0019d48941220ac60b893615be2f63afedaba6a0801bdcd041b96991ce", size = 9985739 }, { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959 },
{ url = "https://files.pythonhosted.org/packages/e0/1d/4b97d3089b48ef3d904c9ca69fab044475bd03245d878f5f0b3ea1daf7ce/mypy-1.18.1-py3-none-any.whl", hash = "sha256:b76a4de66a0ac01da1be14ecc8ae88ddea33b8380284a9e3eae39d57ebcbe26e", size = 2352212 }, { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367 },
] ]
[[package]] [[package]]
@@ -673,6 +708,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
] ]
[[package]]
name = "psutil"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242 },
{ url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682 },
{ url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994 },
{ url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163 },
{ url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625 },
{ url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812 },
{ url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965 },
{ url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971 },
]
[[package]] [[package]]
name = "psycopg" name = "psycopg"
version = "3.2.10" version = "3.2.10"
@@ -725,6 +776,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/dd/464bd739bacb3b745a1c93bc15f20f0b1e27f0a64ec693367794b398673b/psycopg_binary-3.2.10-cp314-cp314-win_amd64.whl", hash = "sha256:d5c6a66a76022af41970bf19f51bc6bf87bd10165783dd1d40484bfd87d6b382", size = 2973554 }, { url = "https://files.pythonhosted.org/packages/5a/dd/464bd739bacb3b745a1c93bc15f20f0b1e27f0a64ec693367794b398673b/psycopg_binary-3.2.10-cp314-cp314-win_amd64.whl", hash = "sha256:d5c6a66a76022af41970bf19f51bc6bf87bd10165783dd1d40484bfd87d6b382", size = 2973554 },
] ]
[[package]]
name = "pulsectl"
version = "24.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/c5/f070a8c5f0a5742f7aebb5d90869ee1805174c03928dfafd3833de58bd57/pulsectl-24.12.0.tar.gz", hash = "sha256:288d6715232ac6f3dcdb123fbecaa2c0b9a50ea4087e6e87c3f841ab0a8a07fc", size = 41200 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a9/5b119f86dd1a053c55da7d0355fca2ad215bae6f7f4777d46b307a8cc3e9/pulsectl-24.12.0-py2.py3-none-any.whl", hash = "sha256:13a60be940594f03ead3245b3dfe3aff4a3f9a792af347674bde5e716d4f76d2", size = 35133 },
]
[[package]]
name = "pycaw"
version = "20240210"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "comtypes" },
{ name = "psutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/1a/f1fa3ceca06eceb5184b907413306b99dd790855ffdf2aee8210fa0fc192/pycaw-20240210.tar.gz", hash = "sha256:55e49359e9f227053f4fa15817a02d4a7bc52fc3db32e123894719a123c41d06", size = 22417 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e2/89e3e096d8926f19cbcf2991ae86d19e6705ea75ad0212862461cb4b83d8/pycaw-20240210-py3-none-any.whl", hash = "sha256:fbbe0ee67a7d32714240e26913266a386ae4375778c417a7e8ad6076eca62f1e", size = 24760 },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.11.7" version = "2.11.7"
@@ -789,16 +862,16 @@ wheels = [
[[package]] [[package]]
name = "pydantic-settings" name = "pydantic-settings"
version = "2.10.1" version = "2.11.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583 } sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235 }, { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608 },
] ]
[[package]] [[package]]
@@ -880,15 +953,15 @@ wheels = [
[[package]] [[package]]
name = "python-socketio" name = "python-socketio"
version = "5.13.0" version = "5.14.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "bidict" }, { name = "bidict" },
{ name = "python-engineio" }, { name = "python-engineio" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125 } sdist = { url = "https://files.pythonhosted.org/packages/05/c2/a9ae3d0eb4488748a2d9c15defddb7277a852234e29e50c73136834dff1b/python_socketio-5.14.1.tar.gz", hash = "sha256:bf49657073b90ee09e4cbd6651044b46bb526694276621e807a1b8fcc0c1b25b", size = 123068 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800 }, { url = "https://files.pythonhosted.org/packages/d5/5e/302c3499a134a52b68e4e6fb345cea52ab1c41460949bcdb09f8bd0e3594/python_socketio-5.14.1-py3-none-any.whl", hash = "sha256:3419f5917f0e3942317836a77146cb4caa23ad804c8fd1a7e3f44a6657a8406e", size = 78496 },
] ]
[[package]] [[package]]
@@ -1025,28 +1098,28 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.13.0" version = "0.13.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863 } sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826 }, { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040 },
{ url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428 }, { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975 },
{ url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543 }, { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621 },
{ url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489 }, { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408 },
{ url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631 }, { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330 },
{ url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602 }, { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815 },
{ url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751 }, { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733 },
{ url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317 }, { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848 },
{ url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418 }, { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890 },
{ url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843 }, { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870 },
{ url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891 }, { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599 },
{ url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119 }, { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893 },
{ url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594 }, { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220 },
{ url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377 }, { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818 },
{ url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555 }, { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715 },
{ url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613 }, { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488 },
{ url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250 }, { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262 },
{ url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357 }, { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484 },
] ]
[[package]] [[package]]
@@ -1065,6 +1138,8 @@ dependencies = [
{ name = "gtts" }, { name = "gtts" },
{ name = "httpx" }, { name = "httpx" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pulsectl" },
{ name = "pycaw" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "python-socketio" }, { name = "python-socketio" },
@@ -1092,32 +1167,34 @@ requires-dist = [
{ name = "alembic", specifier = "==1.16.5" }, { name = "alembic", specifier = "==1.16.5" },
{ name = "apscheduler", specifier = "==3.11.0" }, { name = "apscheduler", specifier = "==3.11.0" },
{ name = "asyncpg", specifier = "==0.30.0" }, { name = "asyncpg", specifier = "==0.30.0" },
{ name = "bcrypt", specifier = "==4.3.0" }, { name = "bcrypt", specifier = "==5.0.0" },
{ name = "email-validator", specifier = "==2.3.0" }, { name = "email-validator", specifier = "==2.3.0" },
{ name = "fastapi", extras = ["standard"], specifier = "==0.117.1" }, { name = "fastapi", extras = ["standard"], specifier = "==0.118.0" },
{ name = "ffmpeg-python", specifier = "==0.2.0" }, { name = "ffmpeg-python", specifier = "==0.2.0" },
{ name = "gtts", specifier = "==2.5.4" }, { 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 = "pulsectl", specifier = ">=24.12.0" },
{ name = "pycaw", specifier = ">=20240210" },
{ name = "pydantic-settings", specifier = "==2.11.0" },
{ name = "pyjwt", specifier = "==2.10.1" }, { name = "pyjwt", specifier = "==2.10.1" },
{ name = "python-socketio", specifier = "==5.13.0" }, { name = "python-socketio", specifier = "==5.14.1" },
{ 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.25" }, { name = "sqlmodel", specifier = "==0.0.25" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.36.0" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.37.0" },
{ name = "yt-dlp", specifier = "==2025.9.5" }, { name = "yt-dlp", specifier = "==2025.9.26" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "coverage", specifier = "==7.10.6" }, { name = "coverage", specifier = "==7.10.7" },
{ name = "faker", specifier = "==37.8.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.2" },
{ name = "pytest", specifier = "==8.4.2" }, { name = "pytest", specifier = "==8.4.2" },
{ name = "pytest-asyncio", specifier = "==1.2.0" }, { name = "pytest-asyncio", specifier = "==1.2.0" },
{ name = "ruff", specifier = "==0.13.0" }, { name = "ruff", specifier = "==0.13.3" },
] ]
[[package]] [[package]]
@@ -1286,15 +1363,15 @@ wheels = [
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.36.0" version = "0.37.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/ef/5e/f0cd46063a02fd8515f0e880c37d2657845b7306c16ce6c4ffc44afd9036/uvicorn-0.36.0.tar.gz", hash = "sha256:527dc68d77819919d90a6b267be55f0e76704dca829d34aea9480be831a9b9d9", size = 80032 } sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/06/5cc0542b47c0338c1cb676b348e24a1c29acabc81000bced518231dded6f/uvicorn-0.36.0-py3-none-any.whl", hash = "sha256:6bb4ba67f16024883af8adf13aba3a9919e415358604ce46780d3f9bdc36d731", size = 67675 }, { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976 },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -1440,9 +1517,9 @@ wheels = [
[[package]] [[package]]
name = "yt-dlp" name = "yt-dlp"
version = "2025.9.5" version = "2025.9.26"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/50/b2/fb255d991857a6a8b2539487ed6063e7bf318f19310d81f039dedb3c2ad6/yt_dlp-2025.9.5.tar.gz", hash = "sha256:9ce080f80b2258e872fe8a75f4707ea2c644e697477186e20b9a04d9a9ea37cf", size = 3045982 } sdist = { url = "https://files.pythonhosted.org/packages/58/8f/0daea0feec1ab85e7df85b98ec7cc8c85d706362e80efc5375c7007dc3dc/yt_dlp-2025.9.26.tar.gz", hash = "sha256:c148ae8233ac4ce6c5fbf6f70fcc390f13a00f59da3776d373cf88c5370bda86", size = 3037475 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/06/64/b3cc116e4f209c493f23d6af033c60ba32df74e086190fbed2bdc0073d12/yt_dlp-2025.9.5-py3-none-any.whl", hash = "sha256:68a03b5c50e3d0f6af7244bd4bf491c1b12e4e2112b051cde05cdfd2647eb9a8", size = 3272317 }, { url = "https://files.pythonhosted.org/packages/35/94/18210c5e6a9d7e622a3b3f4a73dde205f7adf0c46b42b27d0da8c6e5c872/yt_dlp-2025.9.26-py3-none-any.whl", hash = "sha256:36f5fbc153600f759abd48d257231f0e0a547a115ac7ffb05d5b64e5c7fdf8a2", size = 3241906 },
] ]