Compare commits
27 Commits
b87a47f199
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4c72f3b19 | ||
|
|
17eafa4872 | ||
|
|
c9f6bff723 | ||
|
|
12243b1424 | ||
|
|
f7197a89a7 | ||
|
|
b66b8e36bb | ||
|
|
95e166eefb | ||
|
|
d9697c2dd7 | ||
|
|
7b59a8216a | ||
|
|
4b8496d025 | ||
|
|
0806d541f2 | ||
|
|
acdf191a5a | ||
|
|
35b857fd0d | ||
|
|
c13e18c290 | ||
|
|
702d7ee577 | ||
|
|
d3b6e90262 | ||
|
|
50eeae4c62 | ||
|
|
e005dedcd3 | ||
|
|
72ddd98b25 | ||
|
|
b2e513a915 | ||
|
|
c8b796aa94 | ||
|
|
d5f9a3c736 | ||
|
|
2b61d35d6a | ||
|
|
5e8d619736 | ||
|
|
fb0e5e919c | ||
|
|
bccfcafe0e | ||
|
|
1bef694f38 |
@@ -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=
|
|
||||||
@@ -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.
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add status and error fields to TTS table
|
||||||
|
|
||||||
|
Revision ID: 0d9b7f1c367f
|
||||||
|
Revises: e617c155eea9
|
||||||
|
Create Date: 2025-09-21 14:09:56.418372
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0d9b7f1c367f'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'e617c155eea9'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('tts', sa.Column('status', sa.String(), nullable=False, server_default='pending'))
|
||||||
|
op.add_column('tts', sa.Column('error', sa.String(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('tts', 'error')
|
||||||
|
op.drop_column('tts', 'status')
|
||||||
|
# ### end Alembic commands ###
|
||||||
45
alembic/versions/e617c155eea9_add_tts_table.py
Normal file
45
alembic/versions/e617c155eea9_add_tts_table.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add TTS table
|
||||||
|
|
||||||
|
Revision ID: e617c155eea9
|
||||||
|
Revises: a0d322857b2c
|
||||||
|
Create Date: 2025-09-20 21:51:26.557738
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e617c155eea9'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'a0d322857b2c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('tts',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('text', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False),
|
||||||
|
sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
|
||||||
|
sa.Column('options', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('sound_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('tts')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -15,6 +15,7 @@ from app.api.v1 import (
|
|||||||
scheduler,
|
scheduler,
|
||||||
socket,
|
socket,
|
||||||
sounds,
|
sounds,
|
||||||
|
tts,
|
||||||
)
|
)
|
||||||
|
|
||||||
# V1 API router with v1 prefix
|
# V1 API router with v1 prefix
|
||||||
@@ -32,4 +33,5 @@ api_router.include_router(playlists.router, tags=["playlists"])
|
|||||||
api_router.include_router(scheduler.router, tags=["scheduler"])
|
api_router.include_router(scheduler.router, tags=["scheduler"])
|
||||||
api_router.include_router(socket.router, tags=["socket"])
|
api_router.include_router(socket.router, tags=["socket"])
|
||||||
api_router.include_router(sounds.router, tags=["sounds"])
|
api_router.include_router(sounds.router, tags=["sounds"])
|
||||||
|
api_router.include_router(tts.router, tags=["tts"])
|
||||||
api_router.include_router(admin.router)
|
api_router.include_router(admin.router)
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
225
app/api/v1/tts.py
Normal file
225
app/api/v1/tts.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""TTS API endpoints."""
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_active_user_flexible
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.tts import TTSService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tts", tags=["tts"])
|
||||||
|
|
||||||
|
|
||||||
|
class TTSGenerateRequest(BaseModel):
|
||||||
|
"""TTS generation request model."""
|
||||||
|
|
||||||
|
text: str = Field(
|
||||||
|
..., min_length=1, max_length=1000, description="Text to convert to speech",
|
||||||
|
)
|
||||||
|
provider: str = Field(default="gtts", description="TTS provider to use")
|
||||||
|
options: dict[str, Any] = Field(
|
||||||
|
default_factory=dict, description="Provider-specific options",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TTSResponse(BaseModel):
|
||||||
|
"""TTS generation response model."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
text: str
|
||||||
|
provider: str
|
||||||
|
options: dict[str, Any]
|
||||||
|
status: str
|
||||||
|
error: str | None
|
||||||
|
sound_id: int | None
|
||||||
|
user_id: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfo(BaseModel):
|
||||||
|
"""Provider information model."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
file_extension: str
|
||||||
|
supported_languages: list[str]
|
||||||
|
option_schema: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tts_service(
|
||||||
|
session: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> TTSService:
|
||||||
|
"""Get the TTS service."""
|
||||||
|
return TTSService(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_tts_list(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[TTSResponse]:
|
||||||
|
"""Get TTS list for the current user."""
|
||||||
|
try:
|
||||||
|
if current_user.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not available",
|
||||||
|
)
|
||||||
|
|
||||||
|
tts_records = await tts_service.get_user_tts_history(
|
||||||
|
user_id=current_user.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
TTSResponse(
|
||||||
|
id=tts.id,
|
||||||
|
text=tts.text,
|
||||||
|
provider=tts.provider,
|
||||||
|
options=tts.options,
|
||||||
|
status=tts.status,
|
||||||
|
error=tts.error,
|
||||||
|
sound_id=tts.sound_id,
|
||||||
|
user_id=tts.user_id,
|
||||||
|
created_at=tts.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
for tts in tts_records
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to get TTS history: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def generate_tts(
|
||||||
|
request: TTSGenerateRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Generate TTS audio and create sound."""
|
||||||
|
try:
|
||||||
|
if current_user.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not available",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await tts_service.create_tts_request(
|
||||||
|
text=request.text,
|
||||||
|
user_id=current_user.id,
|
||||||
|
provider=request.provider,
|
||||||
|
**request.options,
|
||||||
|
)
|
||||||
|
|
||||||
|
tts_record = result["tts"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": result["message"],
|
||||||
|
"tts": TTSResponse(
|
||||||
|
id=tts_record.id,
|
||||||
|
text=tts_record.text,
|
||||||
|
provider=tts_record.provider,
|
||||||
|
options=tts_record.options,
|
||||||
|
status=tts_record.status,
|
||||||
|
error=tts_record.error,
|
||||||
|
sound_id=tts_record.sound_id,
|
||||||
|
user_id=tts_record.user_id,
|
||||||
|
created_at=tts_record.created_at.isoformat(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to generate TTS: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers")
|
||||||
|
async def get_providers(
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
) -> dict[str, ProviderInfo]:
|
||||||
|
"""Get all available TTS providers."""
|
||||||
|
providers = tts_service.get_providers()
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for name, provider in providers.items():
|
||||||
|
result[name] = ProviderInfo(
|
||||||
|
name=provider.name,
|
||||||
|
file_extension=provider.file_extension,
|
||||||
|
supported_languages=provider.get_supported_languages(),
|
||||||
|
option_schema=provider.get_option_schema(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers/{provider_name}")
|
||||||
|
async def get_provider(
|
||||||
|
provider_name: str,
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
) -> ProviderInfo:
|
||||||
|
"""Get information about a specific TTS provider."""
|
||||||
|
provider = tts_service.get_provider(provider_name)
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Provider '{provider_name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProviderInfo(
|
||||||
|
name=provider.name,
|
||||||
|
file_extension=provider.file_extension,
|
||||||
|
supported_languages=provider.get_supported_languages(),
|
||||||
|
option_schema=provider.get_option_schema(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tts_id}")
|
||||||
|
async def delete_tts(
|
||||||
|
tts_id: int,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Delete a TTS generation and its associated files."""
|
||||||
|
try:
|
||||||
|
if current_user.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not available",
|
||||||
|
)
|
||||||
|
|
||||||
|
await tts_service.delete_tts(tts_id=tts_id, user_id=current_user.id)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete TTS: {e!s}",
|
||||||
|
) from e
|
||||||
|
else:
|
||||||
|
return {"message": "TTS generation deleted successfully"}
|
||||||
@@ -23,7 +23,10 @@ class Settings(BaseSettings):
|
|||||||
BACKEND_URL: str = "http://localhost:8000" # Backend base URL
|
BACKEND_URL: str = "http://localhost:8000" # Backend base URL
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ORIGINS: list[str] = ["http://localhost:8001"] # Allowed origins for CORS
|
CORS_ORIGINS: list[str] = [
|
||||||
|
"http://localhost:8001", # Frontend development
|
||||||
|
"chrome-extension://*", # Chrome extensions
|
||||||
|
]
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL: str = "sqlite+aiosqlite:///data/soundboard.db"
|
DATABASE_URL: str = "sqlite+aiosqlite:///data/soundboard.db"
|
||||||
@@ -37,7 +40,9 @@ class Settings(BaseSettings):
|
|||||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production" # noqa: S105 default value if none set in .env
|
JWT_SECRET_KEY: str = (
|
||||||
|
"your-secret-key-change-in-production" # noqa: S105 default value if none set in .env
|
||||||
|
)
|
||||||
JWT_ALGORITHM: str = "HS256"
|
JWT_ALGORITHM: str = "HS256"
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
from sqlmodel import SQLModel
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
# Import all models to ensure SQLModel metadata discovery
|
# Import all models to ensure SQLModel metadata discovery
|
||||||
import app.models # noqa: F401
|
import app.models # noqa: F401
|
||||||
|
from alembic import command
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
@@ -44,14 +46,14 @@ async def init_db() -> None:
|
|||||||
try:
|
try:
|
||||||
logger.info("Running database migrations")
|
logger.info("Running database migrations")
|
||||||
# Run Alembic migrations programmatically
|
# Run Alembic migrations programmatically
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
|
||||||
|
|
||||||
# Get the alembic config
|
# Get the alembic config
|
||||||
alembic_cfg = Config("alembic.ini")
|
alembic_cfg = Config("alembic.ini")
|
||||||
|
|
||||||
# Run migrations to the latest revision
|
# Run migrations to the latest revision in a thread pool to avoid blocking
|
||||||
command.upgrade(alembic_cfg, "head")
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, command.upgrade, alembic_cfg, "head",
|
||||||
|
)
|
||||||
logger.info("Database migrations completed successfully")
|
logger.info("Database migrations completed successfully")
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
17
app/main.py
17
app/main.py
@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.api import api_router
|
from app.api import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_session_factory, init_db
|
from app.core.database import get_session_factory
|
||||||
from app.core.logging import get_logger, setup_logging
|
from app.core.logging import get_logger, setup_logging
|
||||||
from app.core.services import app_services
|
from app.core.services import app_services
|
||||||
from app.middleware.logging import LoggingMiddleware
|
from app.middleware.logging import LoggingMiddleware
|
||||||
@@ -19,6 +19,7 @@ from app.services.player import (
|
|||||||
)
|
)
|
||||||
from app.services.scheduler import SchedulerService
|
from app.services.scheduler import SchedulerService
|
||||||
from app.services.socket import socket_manager
|
from app.services.socket import socket_manager
|
||||||
|
from app.services.tts_processor import tts_processor
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -28,13 +29,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")
|
||||||
@@ -43,7 +45,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
|
|||||||
try:
|
try:
|
||||||
player_service = get_player_service() # Get the initialized player service
|
player_service = get_player_service() # Get the initialized player service
|
||||||
app_services.scheduler_service = SchedulerService(
|
app_services.scheduler_service = SchedulerService(
|
||||||
get_session_factory(), player_service,
|
get_session_factory(),
|
||||||
|
player_service,
|
||||||
)
|
)
|
||||||
await app_services.scheduler_service.start()
|
await app_services.scheduler_service.start()
|
||||||
logger.info("Enhanced scheduler service started")
|
logger.info("Enhanced scheduler service started")
|
||||||
@@ -64,6 +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")
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ from .playlist_sound import PlaylistSound
|
|||||||
from .scheduled_task import ScheduledTask
|
from .scheduled_task import ScheduledTask
|
||||||
from .sound import Sound
|
from .sound import Sound
|
||||||
from .sound_played import SoundPlayed
|
from .sound_played import SoundPlayed
|
||||||
|
from .tts import TTS
|
||||||
from .user import User
|
from .user import User
|
||||||
from .user_oauth import UserOauth
|
from .user_oauth import UserOauth
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"TTS",
|
||||||
"BaseModel",
|
"BaseModel",
|
||||||
"CreditAction",
|
"CreditAction",
|
||||||
"CreditTransaction",
|
"CreditTransaction",
|
||||||
|
|||||||
30
app/models/tts.py
Normal file
30
app/models/tts.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""TTS model."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Column
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class TTS(SQLModel, table=True):
|
||||||
|
"""Text-to-Speech generation record."""
|
||||||
|
|
||||||
|
__tablename__ = "tts"
|
||||||
|
|
||||||
|
id: int | None = Field(primary_key=True)
|
||||||
|
text: str = Field(max_length=1000, description="Text that was converted to speech")
|
||||||
|
provider: str = Field(max_length=50, description="TTS provider used")
|
||||||
|
options: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
sa_column=Column(JSON),
|
||||||
|
description="Provider-specific options used",
|
||||||
|
)
|
||||||
|
status: str = Field(default="pending", description="Processing status")
|
||||||
|
error: str | None = Field(default=None, description="Error message if failed")
|
||||||
|
sound_id: int | None = Field(
|
||||||
|
foreign_key="sound.id", description="Associated sound ID",
|
||||||
|
)
|
||||||
|
user_id: int = Field(foreign_key="user.id", description="User who created the TTS")
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
@@ -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()
|
||||||
|
|||||||
74
app/repositories/tts.py
Normal file
74
app/repositories/tts.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""TTS repository for database operations."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.models.tts import TTS
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class TTSRepository(BaseRepository[TTS]):
|
||||||
|
"""Repository for TTS operations."""
|
||||||
|
|
||||||
|
def __init__(self, session: "AsyncSession") -> None:
|
||||||
|
"""Initialize TTS repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session for operations
|
||||||
|
|
||||||
|
"""
|
||||||
|
super().__init__(TTS, session)
|
||||||
|
|
||||||
|
async def get_by_user_id(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> Sequence[TTS]:
|
||||||
|
"""Get TTS records by user ID with pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to filter by
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
offset: Number of records to skip
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of TTS records
|
||||||
|
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(self.model.user_id == user_id)
|
||||||
|
.order_by(self.model.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
async def get_by_user_and_id(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
tts_id: int,
|
||||||
|
) -> TTS | None:
|
||||||
|
"""Get a specific TTS record by user ID and TTS ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to filter by
|
||||||
|
tts_id: TTS ID to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TTS record if found and belongs to user, None otherwise
|
||||||
|
|
||||||
|
"""
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.id == tts_id,
|
||||||
|
self.model.user_id == user_id,
|
||||||
|
)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
return result.first()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -598,7 +598,9 @@ class CreditService:
|
|||||||
current_credits = user.credits
|
current_credits = user.credits
|
||||||
plan_credits = user.plan.credits
|
plan_credits = user.plan.credits
|
||||||
max_credits = user.plan.max_credits
|
max_credits = user.plan.max_credits
|
||||||
target_credits = min(current_credits + plan_credits, max_credits)
|
target_credits = min(
|
||||||
|
current_credits + plan_credits, max_credits,
|
||||||
|
)
|
||||||
credits_added = target_credits - current_credits
|
credits_added = target_credits - current_credits
|
||||||
stats["total_credits_added"] += credits_added
|
stats["total_credits_added"] += credits_added
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -348,8 +348,12 @@ class SchedulerService:
|
|||||||
# Check if task is still active and pending
|
# Check if task is still active and pending
|
||||||
if not task.is_active or task.status != TaskStatus.PENDING:
|
if not task.is_active or task.status != TaskStatus.PENDING:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Task %s execution skipped - is_active: %s, status: %s (should be %s)",
|
"Task %s execution skipped - is_active: %s, status: %s "
|
||||||
task_id, task.is_active, task.status, TaskStatus.PENDING,
|
"(should be %s)",
|
||||||
|
task_id,
|
||||||
|
task.is_active,
|
||||||
|
task.status,
|
||||||
|
TaskStatus.PENDING,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -364,7 +368,9 @@ class SchedulerService:
|
|||||||
|
|
||||||
# Mark task as running
|
# Mark task as running
|
||||||
logger.info(
|
logger.info(
|
||||||
"Task %s starting execution (type: %s)", task_id, task.recurrence_type,
|
"Task %s starting execution (type: %s)",
|
||||||
|
task_id,
|
||||||
|
task.recurrence_type,
|
||||||
)
|
)
|
||||||
await repo.mark_as_running(task)
|
await repo.mark_as_running(task)
|
||||||
|
|
||||||
@@ -383,7 +389,8 @@ class SchedulerService:
|
|||||||
# For CRON tasks, update execution metadata but keep PENDING
|
# For CRON tasks, update execution metadata but keep PENDING
|
||||||
# APScheduler handles the recurring schedule automatically
|
# APScheduler handles the recurring schedule automatically
|
||||||
logger.info(
|
logger.info(
|
||||||
"Task %s (CRON) executed successfully, updating metadata", task_id,
|
"Task %s (CRON) executed successfully, updating metadata",
|
||||||
|
task_id,
|
||||||
)
|
)
|
||||||
task.last_executed_at = datetime.now(tz=UTC)
|
task.last_executed_at = datetime.now(tz=UTC)
|
||||||
task.executions_count += 1
|
task.executions_count += 1
|
||||||
@@ -392,8 +399,11 @@ class SchedulerService:
|
|||||||
session.add(task)
|
session.add(task)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Task %s (CRON) metadata updated, status: %s, executions: %s",
|
"Task %s (CRON) metadata updated, status: %s, "
|
||||||
task_id, task.status, task.executions_count,
|
"executions: %s",
|
||||||
|
task_id,
|
||||||
|
task.status,
|
||||||
|
task.executions_count,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# For non-CRON recurring tasks, calculate next execution
|
# For non-CRON recurring tasks, calculate next execution
|
||||||
|
|||||||
@@ -80,11 +80,19 @@ class TaskHandlerRegistry:
|
|||||||
msg = f"Invalid user_id format: {user_id}"
|
msg = f"Invalid user_id format: {user_id}"
|
||||||
raise TaskExecutionError(msg) from e
|
raise TaskExecutionError(msg) from e
|
||||||
|
|
||||||
transaction = await self.credit_service.recharge_user_credits_auto(user_id_int)
|
transaction = await self.credit_service.recharge_user_credits_auto(
|
||||||
|
user_id_int,
|
||||||
|
)
|
||||||
if transaction:
|
if transaction:
|
||||||
logger.info("Recharged credits for user %s: %s credits added", user_id, transaction.amount)
|
logger.info(
|
||||||
|
"Recharged credits for user %s: %s credits added",
|
||||||
|
user_id,
|
||||||
|
transaction.amount,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("No credits added for user %s (already at maximum)", user_id)
|
logger.info(
|
||||||
|
"No credits added for user %s (already at maximum)", user_id,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Recharge all users (system task)
|
# Recharge all users (system task)
|
||||||
stats = await self.credit_service.recharge_all_users_credits()
|
stats = await self.credit_service.recharge_all_users_credits()
|
||||||
|
|||||||
6
app/services/tts/__init__.py
Normal file
6
app/services/tts/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Text-to-Speech services package."""
|
||||||
|
|
||||||
|
from .base import TTSProvider
|
||||||
|
from .service import TTSService
|
||||||
|
|
||||||
|
__all__ = ["TTSProvider", "TTSService"]
|
||||||
41
app/services/tts/base.py
Normal file
41
app/services/tts/base.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Base TTS provider interface."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
# Type alias for TTS options
|
||||||
|
TTSOptions = dict[str, str | bool | int | float]
|
||||||
|
|
||||||
|
|
||||||
|
class TTSProvider(ABC):
|
||||||
|
"""Abstract base class for TTS providers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def generate_speech(self, text: str, **options: str | bool | float) -> bytes:
|
||||||
|
"""Generate speech from text with provider-specific options.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to convert to speech
|
||||||
|
**options: Provider-specific options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Audio data as bytes
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_supported_languages(self) -> list[str]:
|
||||||
|
"""Return list of supported language codes."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_option_schema(self) -> dict[str, dict[str, str | list[str] | bool]]:
|
||||||
|
"""Return schema for provider-specific options."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the provider name."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
"""Return the default file extension for this provider."""
|
||||||
5
app/services/tts/providers/__init__.py
Normal file
5
app/services/tts/providers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""TTS providers package."""
|
||||||
|
|
||||||
|
from .gtts import GTTSProvider
|
||||||
|
|
||||||
|
__all__ = ["GTTSProvider"]
|
||||||
80
app/services/tts/providers/gtts.py
Normal file
80
app/services/tts/providers/gtts.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Google Text-to-Speech provider."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
|
||||||
|
from gtts import gTTS
|
||||||
|
|
||||||
|
from app.services.tts.base import TTSProvider
|
||||||
|
|
||||||
|
|
||||||
|
class GTTSProvider(TTSProvider):
|
||||||
|
"""Google Text-to-Speech provider implementation."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the provider name."""
|
||||||
|
return "gtts"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
"""Return the default file extension for this provider."""
|
||||||
|
return "mp3"
|
||||||
|
|
||||||
|
async def generate_speech(self, text: str, **options: str | bool | float) -> bytes:
|
||||||
|
"""Generate speech from text using Google TTS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to convert to speech
|
||||||
|
**options: GTTS-specific options (lang, tld, slow)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MP3 audio data as bytes
|
||||||
|
|
||||||
|
"""
|
||||||
|
lang = options.get("lang", "en")
|
||||||
|
tld = options.get("tld", "com")
|
||||||
|
slow = options.get("slow", False)
|
||||||
|
|
||||||
|
# Run TTS generation in thread pool since gTTS is synchronous
|
||||||
|
def _generate() -> bytes:
|
||||||
|
tts = gTTS(text=text, lang=lang, tld=tld, slow=slow)
|
||||||
|
fp = io.BytesIO()
|
||||||
|
tts.write_to_fp(fp)
|
||||||
|
fp.seek(0)
|
||||||
|
return fp.read()
|
||||||
|
|
||||||
|
# Use asyncio.to_thread which is more reliable than run_in_executor
|
||||||
|
return await asyncio.to_thread(_generate)
|
||||||
|
|
||||||
|
def get_supported_languages(self) -> list[str]:
|
||||||
|
"""Return list of supported language codes."""
|
||||||
|
# Complete list of GTTS supported languages including regional variants
|
||||||
|
return [
|
||||||
|
"af", "ar", "bg", "bn", "bs", "ca", "cs", "cy", "da", "de", "el",
|
||||||
|
"en", "en-au", "en-ca", "en-gb", "en-ie", "en-in", "en-ng", "en-nz",
|
||||||
|
"en-ph", "en-za", "en-tz", "en-uk", "en-us",
|
||||||
|
"eo", "es", "es-es", "es-mx", "es-us", "et", "eu", "fa", "fi",
|
||||||
|
"fr", "fr-ca", "fr-fr", "ga", "gu", "he", "hi", "hr", "hu", "hy",
|
||||||
|
"id", "is", "it", "ja", "jw", "ka", "kk", "km", "kn", "ko", "la",
|
||||||
|
"lv", "mk", "ml", "mr", "ms", "mt", "my", "ne", "nl", "no", "pa",
|
||||||
|
"pl", "pt", "pt-br", "pt-pt", "ro", "ru", "si", "sk", "sl", "sq",
|
||||||
|
"sr", "su", "sv", "sw", "ta", "te", "th", "tl", "tr", "uk", "ur",
|
||||||
|
"vi", "yo", "zh", "zh-cn", "zh-tw", "zu",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_option_schema(self) -> dict[str, dict[str, str | list[str] | bool]]:
|
||||||
|
"""Return schema for GTTS-specific options."""
|
||||||
|
return {
|
||||||
|
"lang": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "en",
|
||||||
|
"description": "Language code",
|
||||||
|
"enum": self.get_supported_languages(),
|
||||||
|
},
|
||||||
|
"slow": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Speak slowly",
|
||||||
|
},
|
||||||
|
}
|
||||||
555
app/services/tts/service.py
Normal file
555
app/services/tts/service.py
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
"""TTS service implementation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gtts import gTTS
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_session_factory
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.sound import Sound
|
||||||
|
from app.models.tts import TTS
|
||||||
|
from app.repositories.sound import SoundRepository
|
||||||
|
from app.repositories.tts import TTSRepository
|
||||||
|
from app.services.socket import socket_manager
|
||||||
|
from app.services.sound_normalizer import SoundNormalizerService
|
||||||
|
from app.utils.audio import get_audio_duration, get_file_hash, get_file_size
|
||||||
|
|
||||||
|
from .base import TTSProvider
|
||||||
|
from .providers import GTTSProvider
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
MAX_TEXT_LENGTH = 1000
|
||||||
|
MAX_NAME_LENGTH = 50
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Text-to-Speech service with provider management."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
"""Initialize TTS service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
self.sound_repo = SoundRepository(session)
|
||||||
|
self.tts_repo = TTSRepository(session)
|
||||||
|
self.providers: dict[str, TTSProvider] = {}
|
||||||
|
|
||||||
|
# Register default providers
|
||||||
|
self._register_default_providers()
|
||||||
|
|
||||||
|
def _register_default_providers(self) -> None:
|
||||||
|
"""Register default TTS providers."""
|
||||||
|
self.register_provider(GTTSProvider())
|
||||||
|
|
||||||
|
def register_provider(self, provider: TTSProvider) -> None:
|
||||||
|
"""Register a TTS provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: TTS provider instance
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.providers[provider.name] = provider
|
||||||
|
|
||||||
|
def get_providers(self) -> dict[str, TTSProvider]:
|
||||||
|
"""Get all registered providers."""
|
||||||
|
return self.providers.copy()
|
||||||
|
|
||||||
|
def get_provider(self, name: str) -> TTSProvider | None:
|
||||||
|
"""Get a specific provider by name."""
|
||||||
|
return self.providers.get(name)
|
||||||
|
|
||||||
|
async def create_tts_request(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
user_id: int,
|
||||||
|
provider: str = "gtts",
|
||||||
|
**options: str | bool | float,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a TTS request that will be processed in the background.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to convert to speech
|
||||||
|
user_id: ID of user creating the sound
|
||||||
|
provider: TTS provider name
|
||||||
|
**options: Provider-specific options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with TTS record information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If provider not found or text too long
|
||||||
|
Exception: If request creation fails
|
||||||
|
|
||||||
|
"""
|
||||||
|
provider_not_found_msg = f"Provider '{provider}' not found"
|
||||||
|
if provider not in self.providers:
|
||||||
|
raise ValueError(provider_not_found_msg)
|
||||||
|
|
||||||
|
text_too_long_msg = f"Text too long (max {MAX_TEXT_LENGTH} characters)"
|
||||||
|
if len(text) > MAX_TEXT_LENGTH:
|
||||||
|
raise ValueError(text_too_long_msg)
|
||||||
|
|
||||||
|
empty_text_msg = "Text cannot be empty"
|
||||||
|
if not text.strip():
|
||||||
|
raise ValueError(empty_text_msg)
|
||||||
|
|
||||||
|
# Create TTS record with pending status
|
||||||
|
tts = TTS(
|
||||||
|
text=text,
|
||||||
|
provider=provider,
|
||||||
|
options=options,
|
||||||
|
status="pending",
|
||||||
|
sound_id=None, # Will be set when processing completes
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
self.session.add(tts)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(tts)
|
||||||
|
|
||||||
|
# Queue for background processing using the TTS processor
|
||||||
|
if tts.id is not None:
|
||||||
|
tts_processor = await _get_tts_processor()
|
||||||
|
await tts_processor.queue_tts(tts.id)
|
||||||
|
|
||||||
|
return {"tts": tts, "message": "TTS generation queued successfully"}
|
||||||
|
|
||||||
|
async def _queue_tts_processing(self, tts_id: int) -> None:
|
||||||
|
"""Queue TTS for background processing."""
|
||||||
|
# For now, process immediately in a different way
|
||||||
|
# This could be moved to a proper background queue later
|
||||||
|
task = asyncio.create_task(self._process_tts_in_background(tts_id))
|
||||||
|
# Store reference to prevent garbage collection
|
||||||
|
self._background_tasks = getattr(self, "_background_tasks", set())
|
||||||
|
self._background_tasks.add(task)
|
||||||
|
task.add_done_callback(self._background_tasks.discard)
|
||||||
|
|
||||||
|
async def _process_tts_in_background(self, tts_id: int) -> None:
|
||||||
|
"""Process TTS generation in background."""
|
||||||
|
try:
|
||||||
|
# Create a new session for background processing
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
async with session_factory() as background_session:
|
||||||
|
tts_service = TTSService(background_session)
|
||||||
|
|
||||||
|
# Get the TTS record
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await background_session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
|
||||||
|
if not tts:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use a synchronous approach for the actual generation
|
||||||
|
sound = await tts_service._generate_tts_sync(
|
||||||
|
tts.text,
|
||||||
|
tts.provider,
|
||||||
|
tts.user_id,
|
||||||
|
tts.options,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the TTS record with the sound ID
|
||||||
|
if sound.id is not None:
|
||||||
|
tts.sound_id = sound.id
|
||||||
|
background_session.add(tts)
|
||||||
|
await background_session.commit()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Log error but don't fail - avoiding print for production
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Error processing TTS generation %s", tts_id)
|
||||||
|
|
||||||
|
async def _generate_tts_sync(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
provider: str,
|
||||||
|
user_id: int,
|
||||||
|
options: dict[str, Any],
|
||||||
|
) -> Sound:
|
||||||
|
"""Generate TTS using a synchronous approach."""
|
||||||
|
# Generate the audio using the provider
|
||||||
|
# (avoid async issues by doing it directly)
|
||||||
|
tts_provider = self.providers[provider]
|
||||||
|
|
||||||
|
# Create directories if they don't exist
|
||||||
|
original_dir = Path("sounds/originals/text_to_speech")
|
||||||
|
original_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create UUID filename
|
||||||
|
sound_uuid = str(uuid.uuid4())
|
||||||
|
original_filename = f"{sound_uuid}.{tts_provider.file_extension}"
|
||||||
|
original_path = original_dir / original_filename
|
||||||
|
|
||||||
|
# Generate audio synchronously
|
||||||
|
try:
|
||||||
|
# Generate TTS audio
|
||||||
|
lang = options.get("lang", "en")
|
||||||
|
tld = options.get("tld", "com")
|
||||||
|
slow = options.get("slow", False)
|
||||||
|
|
||||||
|
tts_instance = gTTS(text=text, lang=lang, tld=tld, slow=slow)
|
||||||
|
fp = io.BytesIO()
|
||||||
|
tts_instance.write_to_fp(fp)
|
||||||
|
fp.seek(0)
|
||||||
|
audio_bytes = fp.read()
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
original_path.write_bytes(audio_bytes)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Error generating TTS audio")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Create Sound record with proper metadata
|
||||||
|
sound = await self._create_sound_record_complete(
|
||||||
|
original_path,
|
||||||
|
text,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize the sound
|
||||||
|
if sound.id is not None:
|
||||||
|
await self._normalize_sound_safe(sound.id)
|
||||||
|
|
||||||
|
return sound
|
||||||
|
|
||||||
|
async def get_user_tts_history(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[TTS]:
|
||||||
|
"""Get TTS history for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
limit: Maximum number of records
|
||||||
|
offset: Offset for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of TTS records
|
||||||
|
|
||||||
|
"""
|
||||||
|
result = await self.tts_repo.get_by_user_id(user_id, limit, offset)
|
||||||
|
return list(result)
|
||||||
|
|
||||||
|
async def _create_sound_record(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
text: str,
|
||||||
|
user_id: int,
|
||||||
|
file_hash: str,
|
||||||
|
) -> Sound:
|
||||||
|
"""Create a Sound record for the TTS audio."""
|
||||||
|
# Get audio metadata
|
||||||
|
duration = get_audio_duration(audio_path)
|
||||||
|
size = get_file_size(audio_path)
|
||||||
|
name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "")
|
||||||
|
name = " ".join(word.capitalize() for word in name.split())
|
||||||
|
|
||||||
|
# Create sound data
|
||||||
|
sound_data = {
|
||||||
|
"type": "TTS",
|
||||||
|
"name": name,
|
||||||
|
"filename": audio_path.name,
|
||||||
|
"duration": duration,
|
||||||
|
"size": size,
|
||||||
|
"hash": file_hash,
|
||||||
|
"user_id": user_id,
|
||||||
|
"is_deletable": True,
|
||||||
|
"is_music": False, # TTS is speech, not music
|
||||||
|
"is_normalized": False,
|
||||||
|
"play_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.sound_repo.create(sound_data)
|
||||||
|
|
||||||
|
async def _create_sound_record_simple(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
text: str,
|
||||||
|
user_id: int,
|
||||||
|
) -> Sound:
|
||||||
|
"""Create a Sound record for the TTS audio with minimal processing."""
|
||||||
|
# Create sound data with basic info
|
||||||
|
name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "")
|
||||||
|
name = " ".join(word.capitalize() for word in name.split())
|
||||||
|
|
||||||
|
sound_data = {
|
||||||
|
"type": "TTS",
|
||||||
|
"name": name,
|
||||||
|
"filename": audio_path.name,
|
||||||
|
"duration": 0, # Skip duration calculation for now
|
||||||
|
"size": 0, # Skip size calculation for now
|
||||||
|
"hash": str(uuid.uuid4()), # Use UUID as temporary hash
|
||||||
|
"user_id": user_id,
|
||||||
|
"is_deletable": True,
|
||||||
|
"is_music": False, # TTS is speech, not music
|
||||||
|
"is_normalized": False,
|
||||||
|
"play_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.sound_repo.create(sound_data)
|
||||||
|
|
||||||
|
async def _create_sound_record_complete(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
text: str,
|
||||||
|
user_id: int,
|
||||||
|
) -> Sound:
|
||||||
|
"""Create a Sound record for the TTS audio with complete metadata."""
|
||||||
|
# Get audio metadata
|
||||||
|
duration = get_audio_duration(audio_path)
|
||||||
|
size = get_file_size(audio_path)
|
||||||
|
file_hash = get_file_hash(audio_path)
|
||||||
|
name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "")
|
||||||
|
name = " ".join(word.capitalize() for word in name.split())
|
||||||
|
|
||||||
|
# Check if a sound with this hash already exists
|
||||||
|
existing_sound = await self.sound_repo.get_by_hash(file_hash)
|
||||||
|
|
||||||
|
if existing_sound:
|
||||||
|
# Clean up the temporary file since we have a duplicate
|
||||||
|
if audio_path.exists():
|
||||||
|
audio_path.unlink()
|
||||||
|
return existing_sound
|
||||||
|
|
||||||
|
# Create sound data with complete metadata
|
||||||
|
sound_data = {
|
||||||
|
"type": "TTS",
|
||||||
|
"name": name,
|
||||||
|
"filename": audio_path.name,
|
||||||
|
"duration": duration,
|
||||||
|
"size": size,
|
||||||
|
"hash": file_hash,
|
||||||
|
"user_id": user_id,
|
||||||
|
"is_deletable": True,
|
||||||
|
"is_music": False, # TTS is speech, not music
|
||||||
|
"is_normalized": False,
|
||||||
|
"play_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.sound_repo.create(sound_data)
|
||||||
|
|
||||||
|
async def _normalize_sound_safe(self, sound_id: int) -> None:
|
||||||
|
"""Normalize the TTS sound with error handling."""
|
||||||
|
try:
|
||||||
|
# Get fresh sound object from database for normalization
|
||||||
|
sound = await self.sound_repo.get_by_id(sound_id)
|
||||||
|
if not sound:
|
||||||
|
return
|
||||||
|
|
||||||
|
normalizer_service = SoundNormalizerService(self.session)
|
||||||
|
result = await normalizer_service.normalize_sound(sound)
|
||||||
|
|
||||||
|
if result["status"] == "error":
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.warning(
|
||||||
|
"Warning: Failed to normalize TTS sound %s: %s",
|
||||||
|
sound_id,
|
||||||
|
result.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Exception during TTS sound normalization %s", sound_id)
|
||||||
|
# Don't fail the TTS generation if normalization fails
|
||||||
|
|
||||||
|
async def _normalize_sound(self, sound_id: int) -> None:
|
||||||
|
"""Normalize the TTS sound."""
|
||||||
|
try:
|
||||||
|
# Get fresh sound object from database for normalization
|
||||||
|
sound = await self.sound_repo.get_by_id(sound_id)
|
||||||
|
if not sound:
|
||||||
|
return
|
||||||
|
|
||||||
|
normalizer_service = SoundNormalizerService(self.session)
|
||||||
|
result = await normalizer_service.normalize_sound(sound)
|
||||||
|
|
||||||
|
if result["status"] == "error":
|
||||||
|
# Log warning but don't fail the TTS generation
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Don't fail the TTS generation if normalization fails
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Error normalizing sound %s", sound_id)
|
||||||
|
|
||||||
|
async def delete_tts(self, tts_id: int, user_id: int) -> None:
|
||||||
|
"""Delete a TTS generation and its associated sound and files."""
|
||||||
|
# Get the TTS record
|
||||||
|
tts = await self.tts_repo.get_by_id(tts_id)
|
||||||
|
if not tts:
|
||||||
|
tts_not_found_msg = f"TTS with ID {tts_id} not found"
|
||||||
|
raise ValueError(tts_not_found_msg)
|
||||||
|
|
||||||
|
# Check ownership
|
||||||
|
if tts.user_id != user_id:
|
||||||
|
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 tts.sound_id:
|
||||||
|
sound = await self.sound_repo.get_by_id(tts.sound_id)
|
||||||
|
if sound:
|
||||||
|
# Delete the sound files
|
||||||
|
await self._delete_sound_files(sound)
|
||||||
|
# Delete the sound record
|
||||||
|
await self.sound_repo.delete(sound)
|
||||||
|
|
||||||
|
# Delete the TTS record
|
||||||
|
await self.tts_repo.delete(tts)
|
||||||
|
|
||||||
|
async def _delete_sound_files(self, sound: Sound) -> None:
|
||||||
|
"""Delete all files associated with a sound."""
|
||||||
|
# Delete original file
|
||||||
|
original_path = Path("sounds/originals/text_to_speech") / sound.filename
|
||||||
|
if original_path.exists():
|
||||||
|
original_path.unlink()
|
||||||
|
|
||||||
|
# Delete normalized file if it exists
|
||||||
|
if sound.normalized_filename:
|
||||||
|
normalized_path = (
|
||||||
|
Path("sounds/normalized/text_to_speech") / sound.normalized_filename
|
||||||
|
)
|
||||||
|
if normalized_path.exists():
|
||||||
|
normalized_path.unlink()
|
||||||
|
|
||||||
|
async def get_pending_tts(self) -> list[TTS]:
|
||||||
|
"""Get all pending TTS generations."""
|
||||||
|
stmt = select(TTS).where(TTS.status == "pending").order_by(TTS.created_at)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def mark_tts_processing(self, tts_id: int) -> None:
|
||||||
|
"""Mark a TTS generation as processing."""
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
if tts:
|
||||||
|
tts.status = "processing"
|
||||||
|
self.session.add(tts)
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def mark_tts_completed(self, tts_id: int, sound_id: int) -> None:
|
||||||
|
"""Mark a TTS generation as completed."""
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
if tts:
|
||||||
|
tts.status = "completed"
|
||||||
|
tts.sound_id = sound_id
|
||||||
|
tts.error = None
|
||||||
|
self.session.add(tts)
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def mark_tts_failed(self, tts_id: int, error_message: str) -> None:
|
||||||
|
"""Mark a TTS generation as failed."""
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
if tts:
|
||||||
|
tts.status = "failed"
|
||||||
|
tts.error = error_message
|
||||||
|
self.session.add(tts)
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def reset_stuck_tts(self) -> int:
|
||||||
|
"""Reset stuck TTS generations from processing back to pending."""
|
||||||
|
stmt = select(TTS).where(TTS.status == "processing")
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
stuck_tts = list(result.all())
|
||||||
|
|
||||||
|
for tts in stuck_tts:
|
||||||
|
tts.status = "pending"
|
||||||
|
tts.error = None
|
||||||
|
self.session.add(tts)
|
||||||
|
|
||||||
|
await self.session.commit()
|
||||||
|
return len(stuck_tts)
|
||||||
|
|
||||||
|
async def process_tts_generation(self, tts_id: int) -> None:
|
||||||
|
"""Process a TTS generation (used by the processor)."""
|
||||||
|
# Mark as processing
|
||||||
|
await self.mark_tts_processing(tts_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the TTS record
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
|
||||||
|
if not tts:
|
||||||
|
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)
|
||||||
193
app/services/tts_processor.py
Normal file
193
app/services/tts_processor.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Background TTS processor for handling TTS generation queue."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import engine
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.services.tts import TTSService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TTSProcessor:
|
||||||
|
"""Background processor for handling TTS generation queue."""
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -73,6 +73,9 @@ class VLCPlayerService:
|
|||||||
async def play_sound(self, sound: Sound) -> 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
|
||||||
|
|
||||||
@@ -97,6 +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
|
||||||
|
"--volume=100", # Always use 100% VLC volume
|
||||||
]
|
]
|
||||||
|
|
||||||
# Launch VLC process asynchronously without waiting
|
# Launch VLC process asynchronously without waiting
|
||||||
@@ -144,7 +148,7 @@ class VLCPlayerService:
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout, stderr = await find_process.communicate()
|
stdout, _stderr = await find_process.communicate()
|
||||||
|
|
||||||
if find_process.returncode != 0:
|
if find_process.returncode != 0:
|
||||||
# No VLC processes found
|
# No VLC processes found
|
||||||
@@ -332,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(
|
||||||
@@ -369,7 +376,7 @@ class VLCPlayerService:
|
|||||||
),
|
),
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
# Play the sound using VLC
|
# Play the sound using VLC (always at 100% VLC volume)
|
||||||
success = await self.play_sound(sound)
|
success = await self.play_sound(sound)
|
||||||
|
|
||||||
# Deduct credits based on success
|
# Deduct credits based on success
|
||||||
@@ -407,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
251
app/services/volume.py
Normal 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()
|
||||||
44
migrate.py
44
migrate.py
@@ -2,25 +2,33 @@
|
|||||||
"""Database migration CLI tool."""
|
"""Database migration CLI tool."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Main CLI function for database migrations."""
|
"""Run database migration CLI tool."""
|
||||||
parser = argparse.ArgumentParser(description="Database migration tool")
|
parser = argparse.ArgumentParser(description="Database migration tool")
|
||||||
subparsers = parser.add_subparsers(dest="command", help="Migration commands")
|
subparsers = parser.add_subparsers(dest="command", help="Migration commands")
|
||||||
|
|
||||||
# Upgrade command
|
# Upgrade command
|
||||||
upgrade_parser = subparsers.add_parser("upgrade", help="Upgrade database to latest revision")
|
upgrade_parser = subparsers.add_parser(
|
||||||
|
"upgrade", help="Upgrade database to latest revision",
|
||||||
|
)
|
||||||
upgrade_parser.add_argument(
|
upgrade_parser.add_argument(
|
||||||
"revision",
|
"revision",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default="head",
|
default="head",
|
||||||
help="Target revision (default: head)"
|
help="Target revision (default: head)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Downgrade command
|
# Downgrade command
|
||||||
@@ -35,8 +43,12 @@ def main() -> None:
|
|||||||
|
|
||||||
# Generate migration command
|
# Generate migration command
|
||||||
revision_parser = subparsers.add_parser("revision", help="Create new migration")
|
revision_parser = subparsers.add_parser("revision", help="Create new migration")
|
||||||
revision_parser.add_argument("-m", "--message", required=True, help="Migration message")
|
revision_parser.add_argument(
|
||||||
revision_parser.add_argument("--autogenerate", action="store_true", help="Auto-generate migration")
|
"-m", "--message", required=True, help="Migration message",
|
||||||
|
)
|
||||||
|
revision_parser.add_argument(
|
||||||
|
"--autogenerate", action="store_true", help="Auto-generate migration",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -47,7 +59,7 @@ def main() -> None:
|
|||||||
# Get the alembic config
|
# Get the alembic config
|
||||||
config_path = Path("alembic.ini")
|
config_path = Path("alembic.ini")
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
print("Error: alembic.ini not found. Run from the backend directory.")
|
logger.error("Error: alembic.ini not found. Run from the backend directory.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
alembic_cfg = Config(str(config_path))
|
alembic_cfg = Config(str(config_path))
|
||||||
@@ -55,11 +67,15 @@ def main() -> None:
|
|||||||
try:
|
try:
|
||||||
if args.command == "upgrade":
|
if args.command == "upgrade":
|
||||||
command.upgrade(alembic_cfg, args.revision)
|
command.upgrade(alembic_cfg, args.revision)
|
||||||
print(f"Successfully upgraded database to revision: {args.revision}")
|
logger.info(
|
||||||
|
"Successfully upgraded database to revision: %s", args.revision,
|
||||||
|
)
|
||||||
|
|
||||||
elif args.command == "downgrade":
|
elif args.command == "downgrade":
|
||||||
command.downgrade(alembic_cfg, args.revision)
|
command.downgrade(alembic_cfg, args.revision)
|
||||||
print(f"Successfully downgraded database to revision: {args.revision}")
|
logger.info(
|
||||||
|
"Successfully downgraded database to revision: %s", args.revision,
|
||||||
|
)
|
||||||
|
|
||||||
elif args.command == "current":
|
elif args.command == "current":
|
||||||
command.current(alembic_cfg)
|
command.current(alembic_cfg)
|
||||||
@@ -70,15 +86,15 @@ def main() -> None:
|
|||||||
elif args.command == "revision":
|
elif args.command == "revision":
|
||||||
if args.autogenerate:
|
if args.autogenerate:
|
||||||
command.revision(alembic_cfg, message=args.message, autogenerate=True)
|
command.revision(alembic_cfg, message=args.message, autogenerate=True)
|
||||||
print(f"Created new auto-generated migration: {args.message}")
|
logger.info("Created new auto-generated migration: %s", args.message)
|
||||||
else:
|
else:
|
||||||
command.revision(alembic_cfg, message=args.message)
|
command.revision(alembic_cfg, message=args.message)
|
||||||
print(f"Created new empty migration: {args.message}")
|
logger.info("Created new empty migration: %s", args.message)
|
||||||
|
|
||||||
except Exception as e:
|
except (OSError, RuntimeError):
|
||||||
print(f"Error: {e}")
|
logger.exception("Error occurred during migration")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -8,32 +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.116.1",
|
"fastapi[standard]==0.118.0",
|
||||||
"ffmpeg-python==0.2.0",
|
"ffmpeg-python==0.2.0",
|
||||||
|
"gtts==2.5.4",
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"pydantic-settings==2.10.1",
|
"pydantic-settings==2.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.24",
|
"sqlmodel==0.0.25",
|
||||||
"uvicorn[standard]==0.35.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.6.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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class TestApiTokenDependencies:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test flexible authentication falls back to JWT when no API token."""
|
"""Test flexible authentication falls back to JWT when no API token."""
|
||||||
# Mock the get_current_user function (normally imported)
|
# Mock the get_current_user function (normally imported)
|
||||||
with pytest.raises(Exception, match="Database error|Could not validate"):
|
with pytest.raises(Exception, match=r"Database error|Could not validate"):
|
||||||
# This will fail because we can't easily mock the get_current_user import
|
# This will fail because we can't easily mock the get_current_user import
|
||||||
# In a real test, you'd mock the import or use dependency injection
|
# In a real test, you'd mock the import or use dependency injection
|
||||||
await get_current_user_flexible(mock_auth_service, "jwt_token", None)
|
await get_current_user_flexible(mock_auth_service, "jwt_token", None)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class TestUserRepository:
|
|||||||
test_session: AsyncSession,
|
test_session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test creating a new user."""
|
"""Test creating a new user."""
|
||||||
free_plan, pro_plan = ensure_plans
|
free_plan, _pro_plan = ensure_plans
|
||||||
plan_id = free_plan.id
|
plan_id = free_plan.id
|
||||||
plan_credits = free_plan.credits
|
plan_credits = free_plan.credits
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class TestAuthService:
|
|||||||
assert response.user.role == "admin" # First user gets admin role
|
assert response.user.role == "admin" # First user gets admin role
|
||||||
assert response.user.is_active is True
|
assert response.user.is_active is True
|
||||||
# First user gets pro plan
|
# First user gets pro plan
|
||||||
free_plan, pro_plan = ensure_plans
|
_free_plan, pro_plan = ensure_plans
|
||||||
assert response.user.credits == pro_plan.credits
|
assert response.user.credits == pro_plan.credits
|
||||||
assert response.user.plan["code"] == pro_plan.code
|
assert response.user.plan["code"] == pro_plan.code
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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."""
|
||||||
state = PlayerState()
|
# 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
|
||||||
|
|
||||||
assert state.status == PlayerStatus.STOPPED
|
state = PlayerState()
|
||||||
assert state.mode == PlayerMode.CONTINUOUS
|
|
||||||
assert state.volume == 80
|
assert state.status == PlayerStatus.STOPPED
|
||||||
assert state.previous_volume == 80
|
assert state.mode == PlayerMode.CONTINUOUS
|
||||||
|
assert state.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,7 +186,9 @@ class TestPlayerService:
|
|||||||
mock_socket_manager,
|
mock_socket_manager,
|
||||||
):
|
):
|
||||||
"""Create a player service instance for testing."""
|
"""Create a player service instance for testing."""
|
||||||
return PlayerService(mock_db_session_factory)
|
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)
|
||||||
|
|
||||||
def test_init_creates_player_service(
|
def test_init_creates_player_service(
|
||||||
self,
|
self,
|
||||||
@@ -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,23 +407,32 @@ 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.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
await player_service.set_volume(75)
|
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
||||||
|
mock_volume_service.is_muted.return_value = False
|
||||||
|
|
||||||
assert player_service.state.volume == 75
|
await player_service.set_volume(75)
|
||||||
player_service._player.audio_set_volume.assert_called_once_with(75)
|
|
||||||
|
assert player_service.state.volume == 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.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
# Test upper bound
|
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
||||||
await player_service.set_volume(150)
|
mock_volume_service.is_muted.return_value = False
|
||||||
assert player_service.state.volume == 100
|
|
||||||
|
|
||||||
# Test lower bound
|
# Test upper bound
|
||||||
await player_service.set_volume(-10)
|
await player_service.set_volume(150)
|
||||||
assert player_service.state.volume == 0
|
assert player_service.state.volume == 100
|
||||||
|
|
||||||
|
# Test lower bound
|
||||||
|
await player_service.set_volume(-10)
|
||||||
|
assert player_service.state.volume == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_mode(self, player_service) -> None:
|
async def test_set_mode(self, player_service) -> None:
|
||||||
|
|||||||
507
uv.lock
generated
507
uv.lock
generated
@@ -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]]
|
||||||
@@ -155,16 +171,58 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722 },
|
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.2.1"
|
version = "8.1.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -177,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]]
|
||||||
@@ -264,28 +341,28 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "faker"
|
name = "faker"
|
||||||
version = "37.6.0"
|
version = "37.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "tzdata" },
|
{ name = "tzdata" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/24/cd/f7679c20f07d9e2013123b7f7e13809a3450a18d938d58e86081a486ea15/faker-37.6.0.tar.gz", hash = "sha256:0f8cc34f30095184adf87c3c24c45b38b33ad81c35ef6eb0a3118f301143012c", size = 1907960 }
|
sdist = { url = "https://files.pythonhosted.org/packages/3a/da/1336008d39e5d4076dddb4e0f3a52ada41429274bf558a3cc28030d324a3/faker-37.8.0.tar.gz", hash = "sha256:090bb5abbec2b30949a95ce1ba6b20d1d0ed222883d63483a0d4be4a970d6fb8", size = 1912113 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/7d/8b50e4ac772719777be33661f4bde320793400a706f5eb214e4de46f093c/faker-37.6.0-py3-none-any.whl", hash = "sha256:3c5209b23d7049d596a51db5d76403a0ccfea6fc294ffa2ecfef6a8843b1e6a7", size = 1949837 },
|
{ url = "https://files.pythonhosted.org/packages/f5/11/02ebebb09ff2104b690457cb7bc6ed700c9e0ce88cf581486bb0a5d3c88b/faker-37.8.0-py3-none-any.whl", hash = "sha256:b08233118824423b5fc239f7dd51f145e7018082b4164f8da6a9994e1f1ae793", size = 1953940 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.116.1"
|
version = "0.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/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485 }
|
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/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631 },
|
{ 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]
|
||||||
@@ -390,6 +467,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
|
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtts"
|
||||||
|
version = "2.5.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/79/5ddb1dfcd663581d0d3fca34ccb1d8d841b47c22a24dc8dce416e3d87dfa/gtts-2.5.4.tar.gz", hash = "sha256:f5737b585f6442f677dbe8773424fd50697c75bdf3e36443585e30a8d48c1884", size = 24018 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/6c/8b8b1fdcaee7e268536f1bb00183a5894627726b54a9ddc6fc9909888447/gTTS-2.5.4-py3-none-any.whl", hash = "sha256:5dd579377f9f5546893bc26315ab1f846933dc27a054764b168f141065ca8436", size = 29184 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -552,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]]
|
||||||
@@ -618,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"
|
||||||
@@ -670,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"
|
||||||
@@ -734,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]]
|
||||||
@@ -825,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]]
|
||||||
@@ -880,6 +1008,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
|
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "14.0.0"
|
version = "14.0.0"
|
||||||
@@ -955,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]]
|
||||||
@@ -992,8 +1135,11 @@ dependencies = [
|
|||||||
{ name = "email-validator" },
|
{ name = "email-validator" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "ffmpeg-python" },
|
{ name = "ffmpeg-python" },
|
||||||
|
{ name = "gtts" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
|
{ name = "pulsectl" },
|
||||||
|
{ name = "pycaw" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "python-socketio" },
|
{ name = "python-socketio" },
|
||||||
@@ -1021,31 +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.116.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 = "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.24" },
|
{ name = "sqlmodel", specifier = "==0.0.25" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.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.6.0" },
|
{ name = "faker", specifier = "==37.8.0" },
|
||||||
{ name = "httpx", specifier = "==0.28.1" },
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
{ name = "mypy", specifier = "==1.18.1" },
|
{ name = "mypy", specifier = "==1.18.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]]
|
||||||
@@ -1122,15 +1271,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlmodel"
|
name = "sqlmodel"
|
||||||
version = "0.0.24"
|
version = "0.0.25"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780 }
|
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/d9c098a88724ee4554907939cf39590cf67e10c6683723216e228d3315f7/sqlmodel-0.0.25.tar.gz", hash = "sha256:56548c2e645975b1ed94d6c53f0d13c85593f57926a575e2bf566650b2243fa4", size = 117075 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622 },
|
{ url = "https://files.pythonhosted.org/packages/57/cf/5d175ce8de07fe694ec4e3d4d65c2dd06cc30f6c79599b31f9d2f6dd2830/sqlmodel-0.0.25-py3-none-any.whl", hash = "sha256:c98234cda701fb77e9dcbd81688c23bb251c13bb98ce1dd8d4adc467374d45b7", size = 28893 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1214,15 +1363,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.35.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/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473 }
|
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/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406 },
|
{ 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]
|
||||||
@@ -1368,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 },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user