Compare commits

...

30 Commits

Author SHA1 Message Date
JSC
e4c72f3b19 chore: Remove unused .env.template and SCHEDULER_EXAMPLE.md files
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m39s
2025-10-05 16:33:29 +02:00
JSC
17eafa4872 feat: Enhance play_next functionality by storing and restoring playlist index
Some checks failed
Backend CI / test (push) Failing after 2m17s
Backend CI / lint (push) Failing after 14m55s
2025-10-05 04:07:34 +02:00
JSC
c9f6bff723 refactor: Improve code readability by formatting function signatures and descriptions
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m29s
2025-10-04 22:27:12 +02:00
JSC
12243b1424 feat: Clear and manage play_next queue on playlist changes
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m36s
2025-10-04 19:39:44 +02:00
JSC
f7197a89a7 feat: Add play next functionality to player service and API 2025-10-04 19:16:37 +02:00
JSC
b66b8e36bb feat: Enhance user metrics retrieval by integrating Extraction model and updating related queries
Some checks failed
Backend CI / lint (push) Failing after 17s
Backend CI / test (push) Failing after 2m32s
2025-10-04 13:45:36 +02:00
JSC
95e166eefb feat: Add endpoint and service method to retrieve top users by various metrics
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m36s
2025-09-27 21:52:00 +02:00
JSC
d9697c2dd7 feat: Add TTS statistics endpoint and service method for comprehensive TTS data 2025-09-27 21:37:59 +02:00
JSC
7b59a8216a fix: Correct import formatting for CreditService in VLCPlayerService
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m28s
2025-09-27 03:34:19 +02:00
JSC
4b8496d025 feat: Implement host system volume control and update player service to use it
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Has been cancelled
2025-09-27 03:33:11 +02:00
JSC
0806d541f2 Upgrade packages
Some checks failed
Backend CI / lint (push) Failing after 11s
Backend CI / test (push) Failing after 1m59s
2025-09-27 02:32:59 +02:00
JSC
acdf191a5a refactor: Improve code readability and structure across TTS modules
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m36s
2025-09-21 19:07:32 +02:00
JSC
35b857fd0d feat: Add GitHub as an available OAuth provider and remove database initialization logs 2025-09-21 18:58:20 +02:00
JSC
c13e18c290 feat: Implement playlist sound deletion and update current playlist logic on deletion
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m34s
2025-09-21 18:32:48 +02:00
JSC
702d7ee577 Merge branch 'tts'
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m35s
2025-09-21 18:19:26 +02:00
JSC
d3b6e90262 style: Format code for consistency and readability across TTS modules 2025-09-21 18:05:20 +02:00
JSC
50eeae4c62 refactor: Clean up TTSService methods for improved readability and consistency 2025-09-21 15:38:35 +02:00
JSC
e005dedcd3 refactor: Update supported languages list in GTTSProvider and remove TLD option from schema 2025-09-21 15:20:23 +02:00
JSC
72ddd98b25 feat: Add status and error fields to TTS model and implement background processing for TTS generations 2025-09-21 14:39:41 +02:00
JSC
b2e513a915 feat: Add endpoint to retrieve TTS history for the current user and improve request model formatting 2025-09-21 13:55:24 +02:00
JSC
c8b796aa94 refactor: Simplify TTS API endpoints by removing specific paths for generate and history 2025-09-21 13:38:12 +02:00
JSC
d5f9a3c736 feat: Run database migrations in a thread pool to avoid blocking during initialization 2025-09-21 13:21:23 +02:00
JSC
2b61d35d6a chore: Update dependencies for fastapi, faker, sqlmodel, and uvicorn; add gtts and charset-normalizer packages 2025-09-20 23:10:59 +02:00
JSC
5e8d619736 feat: Implement Text-to-Speech (TTS) functionality with API endpoints, models, and service integration 2025-09-20 23:10:47 +02:00
JSC
fb0e5e919c fix: Remove GitHub from available OAuth providers list
Some checks failed
Backend CI / lint (push) Failing after 11s
Backend CI / test (push) Failing after 1m34s
2025-09-20 21:11:50 +02:00
JSC
bccfcafe0e feat: Update CORS origins to allow Chrome extensions and improve logging in migration tool
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m37s
2025-09-19 16:41:11 +02:00
JSC
1bef694f38 feat: Enhance play_sound method to accept volume parameter and retrieve current volume
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m33s
2025-09-18 13:57:54 +02:00
JSC
b87a47f199 fix: Update PostgreSQL database URL for Alembic to use psycopg driver
Some checks failed
Backend CI / lint (push) Failing after 12s
Backend CI / test (push) Failing after 1m33s
2025-09-18 13:14:01 +02:00
JSC
83239cb4fa Add Alembic for database migrations and initial migration scripts
- Created alembic.ini configuration file for Alembic migrations.
- Added README file for Alembic with a brief description.
- Implemented env.py for Alembic to manage database migrations.
- Created script.py.mako template for migration scripts.
- Added initial migration script to create database tables.
- Created a migration script to add initial plan and playlist data.
- Updated database initialization to run Alembic migrations.
- Enhanced credit service to automatically recharge user credits based on their plan.
- Implemented delete_task method in scheduler service to remove scheduled tasks.
- Updated scheduler API to reflect task deletion instead of cancellation.
- Added CLI tool for managing database migrations.
- Updated tests to cover new functionality for task deletion and credit recharge.
- Updated pyproject.toml and lock files to include Alembic as a dependency.
2025-09-16 13:45:14 +02:00
JSC
e8f979c137 feat: Add MINUTELY recurrence type and enhance scheduler handling 2025-09-13 23:44:20 +02:00
51 changed files with 3405 additions and 557 deletions

View File

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

View File

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

148
alembic.ini Normal file
View File

@@ -0,0 +1,148 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# sqlalchemy.url = driver://user:pass@localhost/dbname
# URL will be set dynamically in env.py from config
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

86
alembic/env.py Normal file
View File

@@ -0,0 +1,86 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
import app.models # noqa: F401
from app.core.config import settings
from sqlmodel import SQLModel
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Set the database URL from settings - convert async URL to sync for alembic
sync_db_url = settings.DATABASE_URL.replace("sqlite+aiosqlite", "sqlite")
sync_db_url = sync_db_url.replace("postgresql+asyncpg", "postgresql+psycopg")
config.set_main_option("sqlalchemy.url", sync_db_url)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,34 @@
"""Add status and error fields to TTS table
Revision ID: 0d9b7f1c367f
Revises: e617c155eea9
Create Date: 2025-09-21 14:09:56.418372
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0d9b7f1c367f'
down_revision: Union[str, Sequence[str], None] = 'e617c155eea9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tts', sa.Column('status', sa.String(), nullable=False, server_default='pending'))
op.add_column('tts', sa.Column('error', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tts', 'error')
op.drop_column('tts', 'status')
# ### end Alembic commands ###

View File

@@ -0,0 +1,222 @@
"""Initial migration
Revision ID: 7aa9892ceff3
Revises:
Create Date: 2025-09-16 13:16:58.233360
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '7aa9892ceff3'
down_revision: Union[str, Sequence[str], None] = None
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('plan',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('credits', sa.Integer(), nullable=False),
sa.Column('max_credits', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_plan_code'), 'plan', ['code'], unique=True)
op.create_table('sound',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('duration', sa.Integer(), nullable=False),
sa.Column('size', sa.Integer(), nullable=False),
sa.Column('hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('normalized_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('normalized_duration', sa.Integer(), nullable=True),
sa.Column('normalized_size', sa.Integer(), nullable=True),
sa.Column('normalized_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('thumbnail', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('play_count', sa.Integer(), nullable=False),
sa.Column('is_normalized', sa.Boolean(), nullable=False),
sa.Column('is_music', sa.Boolean(), nullable=False),
sa.Column('is_deletable', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('hash', name='uq_sound_hash')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('plan_id', sa.Integer(), nullable=False),
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('picture', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('credits', sa.Integer(), nullable=False),
sa.Column('api_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('api_token_expires_at', sa.DateTime(), nullable=True),
sa.Column('refresh_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('refresh_token_expires_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['plan_id'], ['plan.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('api_token'),
sa.UniqueConstraint('email')
)
op.create_table('credit_transaction',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('action_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('balance_before', sa.Integer(), nullable=False),
sa.Column('balance_after', sa.Integer(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('success', sa.Boolean(), nullable=False),
sa.Column('metadata_json', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('extraction',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('service', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('service_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('sound_id', sa.Integer(), nullable=True),
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('track', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('artist', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('album', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('genre', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('error', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('playlist',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('genre', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_main', sa.Boolean(), nullable=False),
sa.Column('is_current', sa.Boolean(), nullable=False),
sa.Column('is_deletable', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('scheduled_task',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('task_type', sa.Enum('CREDIT_RECHARGE', 'PLAY_SOUND', 'PLAY_PLAYLIST', name='tasktype'), nullable=False),
sa.Column('status', sa.Enum('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED', name='taskstatus'), nullable=False),
sa.Column('scheduled_at', sa.DateTime(), nullable=False),
sa.Column('timezone', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('recurrence_type', sa.Enum('NONE', 'MINUTELY', 'HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY', 'CRON', name='recurrencetype'), nullable=False),
sa.Column('cron_expression', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('recurrence_count', sa.Integer(), nullable=True),
sa.Column('executions_count', sa.Integer(), nullable=False),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('last_executed_at', sa.DateTime(), nullable=True),
sa.Column('next_execution_at', sa.DateTime(), nullable=True),
sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('sound_played',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('sound_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user_oauth',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('provider_user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('picture', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider', 'provider_user_id', name='uq_user_oauth_provider_user_id')
)
op.create_table('favorite',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('sound_id', sa.Integer(), nullable=True),
sa.Column('playlist_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlist.id'], ),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'playlist_id', name='uq_favorite_user_playlist'),
sa.UniqueConstraint('user_id', 'sound_id', name='uq_favorite_user_sound')
)
op.create_table('playlist_sound',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('playlist_id', sa.Integer(), nullable=False),
sa.Column('sound_id', sa.Integer(), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['playlist_id'], ['playlist.id'], ),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('playlist_id', 'position', name='uq_playlist_sound_playlist_position'),
sa.UniqueConstraint('playlist_id', 'sound_id', name='uq_playlist_sound_playlist_sound')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('playlist_sound')
op.drop_table('favorite')
op.drop_table('user_oauth')
op.drop_table('sound_played')
op.drop_table('scheduled_task')
op.drop_table('playlist')
op.drop_table('extraction')
op.drop_table('credit_transaction')
op.drop_table('user')
op.drop_table('sound')
op.drop_index(op.f('ix_plan_code'), table_name='plan')
op.drop_table('plan')
# ### end Alembic commands ###

View File

@@ -0,0 +1,106 @@
"""Add initial plan and playlist data
Revision ID: a0d322857b2c
Revises: 7aa9892ceff3
Create Date: 2025-09-16 13:23:31.682276
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a0d322857b2c'
down_revision: Union[str, Sequence[str], None] = '7aa9892ceff3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema and add initial data."""
# Get the current timestamp
now = datetime.utcnow()
# Insert initial plans
plans_table = sa.table(
'plan',
sa.column('code', sa.String),
sa.column('name', sa.String),
sa.column('description', sa.String),
sa.column('credits', sa.Integer),
sa.column('max_credits', sa.Integer),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime),
)
op.bulk_insert(
plans_table,
[
{
'code': 'free',
'name': 'Free Plan',
'description': 'Basic free plan with limited features',
'credits': 25,
'max_credits': 75,
'created_at': now,
'updated_at': now,
},
{
'code': 'premium',
'name': 'Premium Plan',
'description': 'Premium plan with more features',
'credits': 50,
'max_credits': 150,
'created_at': now,
'updated_at': now,
},
{
'code': 'pro',
'name': 'Pro Plan',
'description': 'Pro plan with unlimited features',
'credits': 100,
'max_credits': 300,
'created_at': now,
'updated_at': now,
},
]
)
# Insert main playlist
playlist_table = sa.table(
'playlist',
sa.column('name', sa.String),
sa.column('description', sa.String),
sa.column('is_main', sa.Boolean),
sa.column('is_deletable', sa.Boolean),
sa.column('is_current', sa.Boolean),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime),
)
op.bulk_insert(
playlist_table,
[
{
'name': 'All',
'description': 'The default main playlist with all the tracks',
'is_main': True,
'is_deletable': False,
'is_current': True,
'created_at': now,
'updated_at': now,
}
]
)
def downgrade() -> None:
"""Downgrade schema and remove initial data."""
# Remove initial plans
op.execute("DELETE FROM plan WHERE code IN ('free', 'premium', 'pro')")
# Remove main playlist
op.execute("DELETE FROM playlist WHERE is_main = 1")

View File

@@ -0,0 +1,45 @@
"""Add TTS table
Revision ID: e617c155eea9
Revises: a0d322857b2c
Create Date: 2025-09-20 21:51:26.557738
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'e617c155eea9'
down_revision: Union[str, Sequence[str], None] = 'a0d322857b2c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('text', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False),
sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
sa.Column('options', sa.JSON(), nullable=True),
sa.Column('sound_id', sa.Integer(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('tts')
# ### end Alembic commands ###

View File

@@ -15,6 +15,7 @@ from app.api.v1 import (
scheduler,
socket,
sounds,
tts,
)
# V1 API router with v1 prefix
@@ -32,4 +33,5 @@ api_router.include_router(playlists.router, tags=["playlists"])
api_router.include_router(scheduler.router, tags=["scheduler"])
api_router.include_router(socket.router, tags=["socket"])
api_router.include_router(sounds.router, tags=["sounds"])
api_router.include_router(tts.router, tags=["tts"])
api_router.include_router(admin.router)

View File

@@ -41,6 +41,59 @@ async def get_track_statistics(
) 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")
async def get_top_sounds(
_current_user: Annotated[User, Depends(get_current_user)],

View File

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

View File

@@ -129,7 +129,7 @@ async def update_task(
@router.delete("/tasks/{task_id}")
async def cancel_task(
async def delete_task(
task_id: int,
current_user: Annotated[User, Depends(get_current_active_user)] = ...,
scheduler_service: Annotated[
@@ -137,7 +137,7 @@ async def cancel_task(
] = ...,
db_session: Annotated[AsyncSession, Depends(get_db)] = ...,
) -> dict:
"""Cancel a scheduled task."""
"""Delete a scheduled task completely."""
repo = ScheduledTaskRepository(db_session)
task = await repo.get_by_id(task_id)
@@ -148,11 +148,11 @@ async def cancel_task(
if task.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Access denied")
success = await scheduler_service.cancel_task(task_id)
success = await scheduler_service.delete_task(task_id)
if not success:
raise HTTPException(status_code=400, detail="Failed to cancel task")
raise HTTPException(status_code=400, detail="Failed to delete task")
return {"message": "Task cancelled successfully"}
return {"message": "Task deleted successfully"}
# Admin-only endpoints

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

@@ -0,0 +1,225 @@
"""TTS API endpoints."""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_active_user_flexible
from app.models.user import User
from app.services.tts import TTSService
router = APIRouter(prefix="/tts", tags=["tts"])
class TTSGenerateRequest(BaseModel):
"""TTS generation request model."""
text: str = Field(
..., min_length=1, max_length=1000, description="Text to convert to speech",
)
provider: str = Field(default="gtts", description="TTS provider to use")
options: dict[str, Any] = Field(
default_factory=dict, description="Provider-specific options",
)
class TTSResponse(BaseModel):
"""TTS generation response model."""
id: int
text: str
provider: str
options: dict[str, Any]
status: str
error: str | None
sound_id: int | None
user_id: int
created_at: str
class ProviderInfo(BaseModel):
"""Provider information model."""
name: str
file_extension: str
supported_languages: list[str]
option_schema: dict[str, Any]
async def get_tts_service(
session: Annotated[AsyncSession, Depends(get_db)],
) -> TTSService:
"""Get the TTS service."""
return TTSService(session)
@router.get("")
async def get_tts_list(
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
tts_service: Annotated[TTSService, Depends(get_tts_service)],
limit: int = 50,
offset: int = 0,
) -> list[TTSResponse]:
"""Get TTS list for the current user."""
try:
if current_user.id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not available",
)
tts_records = await tts_service.get_user_tts_history(
user_id=current_user.id,
limit=limit,
offset=offset,
)
return [
TTSResponse(
id=tts.id,
text=tts.text,
provider=tts.provider,
options=tts.options,
status=tts.status,
error=tts.error,
sound_id=tts.sound_id,
user_id=tts.user_id,
created_at=tts.created_at.isoformat(),
)
for tts in tts_records
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get TTS history: {e!s}",
) from e
@router.post("")
async def generate_tts(
request: TTSGenerateRequest,
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
tts_service: Annotated[TTSService, Depends(get_tts_service)],
) -> dict[str, Any]:
"""Generate TTS audio and create sound."""
try:
if current_user.id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not available",
)
result = await tts_service.create_tts_request(
text=request.text,
user_id=current_user.id,
provider=request.provider,
**request.options,
)
tts_record = result["tts"]
return {
"message": result["message"],
"tts": TTSResponse(
id=tts_record.id,
text=tts_record.text,
provider=tts_record.provider,
options=tts_record.options,
status=tts_record.status,
error=tts_record.error,
sound_id=tts_record.sound_id,
user_id=tts_record.user_id,
created_at=tts_record.created_at.isoformat(),
),
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to generate TTS: {e!s}",
) from e
@router.get("/providers")
async def get_providers(
tts_service: Annotated[TTSService, Depends(get_tts_service)],
) -> dict[str, ProviderInfo]:
"""Get all available TTS providers."""
providers = tts_service.get_providers()
result = {}
for name, provider in providers.items():
result[name] = ProviderInfo(
name=provider.name,
file_extension=provider.file_extension,
supported_languages=provider.get_supported_languages(),
option_schema=provider.get_option_schema(),
)
return result
@router.get("/providers/{provider_name}")
async def get_provider(
provider_name: str,
tts_service: Annotated[TTSService, Depends(get_tts_service)],
) -> ProviderInfo:
"""Get information about a specific TTS provider."""
provider = tts_service.get_provider(provider_name)
if not provider:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Provider '{provider_name}' not found",
)
return ProviderInfo(
name=provider.name,
file_extension=provider.file_extension,
supported_languages=provider.get_supported_languages(),
option_schema=provider.get_option_schema(),
)
@router.delete("/{tts_id}")
async def delete_tts(
tts_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
tts_service: Annotated[TTSService, Depends(get_tts_service)],
) -> dict[str, str]:
"""Delete a TTS generation and its associated files."""
try:
if current_user.id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not available",
)
await tts_service.delete_tts(tts_id=tts_id, user_id=current_user.id)
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"}

View File

@@ -23,7 +23,10 @@ class Settings(BaseSettings):
BACKEND_URL: str = "http://localhost:8000" # Backend base URL
# 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_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"
# 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_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7

View File

@@ -1,14 +1,15 @@
import asyncio
from collections.abc import AsyncGenerator, Callable
from alembic.config import Config
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
# Import all models to ensure SQLModel metadata discovery
import app.models # noqa: F401
from alembic import command
from app.core.config import settings
from app.core.logging import get_logger
from app.core.seeds import seed_all_data
engine: AsyncEngine = create_async_engine(
settings.DATABASE_URL,
@@ -40,26 +41,23 @@ def get_session_factory() -> Callable[[], AsyncSession]:
async def init_db() -> None:
"""Initialize the database and create tables if they do not exist."""
"""Initialize the database using Alembic migrations."""
logger = get_logger(__name__)
try:
logger.info("Initializing database tables")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
logger.info("Database tables created successfully")
logger.info("Running database migrations")
# Run Alembic migrations programmatically
# Seed initial data
await seed_initial_data()
# Get the alembic config
alembic_cfg = Config("alembic.ini")
# Run migrations to the latest revision in a thread pool to avoid blocking
await asyncio.get_event_loop().run_in_executor(
None, command.upgrade, alembic_cfg, "head",
)
logger.info("Database migrations completed successfully")
except Exception:
logger.exception("Failed to initialize database")
raise
async def seed_initial_data() -> None:
"""Seed initial data into the database."""
logger = get_logger(__name__)
logger.info("Starting initial data seeding")
async with AsyncSession(engine) as session:
await seed_all_data(session)

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ class RecurrenceType(str, Enum):
"""Recurrence patterns."""
NONE = "none" # One-shot task
MINUTELY = "minutely"
HOURLY = "hourly"
DAILY = "daily"
WEEKLY = "weekly"

30
app/models/tts.py Normal file
View 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)

View File

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

74
app/repositories/tts.py Normal file
View 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()

View File

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

View File

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

View File

@@ -403,6 +403,44 @@ class CreditService:
finally:
await session.close()
async def recharge_user_credits_auto(
self,
user_id: int,
) -> CreditTransaction | None:
"""Recharge credits for a user automatically based on their plan.
Args:
user_id: The user ID
Returns:
The created credit transaction if credits were added, None if no recharge
needed
Raises:
ValueError: If user not found or has no plan
"""
session = self.db_session_factory()
try:
user_repo = UserRepository(session)
user = await user_repo.get_by_id_with_plan(user_id)
if not user:
msg = f"User {user_id} not found"
raise ValueError(msg)
if not user.plan:
msg = f"User {user_id} has no plan assigned"
raise ValueError(msg)
# Call the main method with plan details
return await self.recharge_user_credits(
user_id,
user.plan.credits,
user.plan.max_credits,
)
finally:
await session.close()
async def recharge_user_credits(
self,
user_id: int,
@@ -556,7 +594,15 @@ class CreditService:
if transaction:
stats["recharged_users"] += 1
stats["total_credits_added"] += transaction.amount
# Calculate the amount from plan data to avoid session issues
current_credits = user.credits
plan_credits = user.plan.credits
max_credits = user.plan.max_credits
target_credits = min(
current_credits + plan_credits, max_credits,
)
credits_added = target_credits - current_credits
stats["total_credits_added"] += credits_added
else:
stats["skipped_users"] += 1

View File

@@ -5,6 +5,7 @@ from typing import Any
from app.core.logging import get_logger
from app.repositories.sound import SoundRepository
from app.repositories.user import UserRepository
logger = get_logger(__name__)
@@ -12,9 +13,14 @@ logger = get_logger(__name__)
class DashboardService:
"""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."""
self.sound_repository = sound_repository
self.user_repository = user_repository
async def get_soundboard_statistics(self) -> dict[str, Any]:
"""Get comprehensive soundboard statistics."""
@@ -85,6 +91,55 @@ class DashboardService:
)
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
"""Calculate the date filter based on the period."""
now = datetime.now(UTC)

View File

@@ -8,6 +8,8 @@ from enum import Enum
from typing import Any
import vlc # type: ignore[import-untyped]
from sqlalchemy.orm import selectinload
from sqlmodel import select
from app.core.logging import get_logger
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.sound import SoundRepository
from app.services.socket import socket_manager
from app.services.volume import volume_service
from app.utils.audio import get_sound_file_path
logger = get_logger(__name__)
@@ -46,8 +49,11 @@ class PlayerState:
"""Initialize player state."""
self.status: PlayerStatus = PlayerStatus.STOPPED
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_index: int | None = None
self.current_sound_position: int = 0
@@ -58,6 +64,8 @@ class PlayerState:
self.playlist_length: int = 0
self.playlist_duration: int = 0
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]:
"""Convert player state to dictionary for serialization."""
@@ -83,6 +91,9 @@ class PlayerState:
if self.playlist_id
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:
@@ -153,8 +164,8 @@ class PlayerService:
)
self._position_thread.start()
# Set initial volume
self._player.audio_set_volume(self.state.volume)
# Set VLC to 100% volume - host volume is controlled separately
self._player.audio_set_volume(100)
logger.info("Player service started")
@@ -338,6 +349,31 @@ class PlayerService:
async def next(self) -> None:
"""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:
return
@@ -378,7 +414,7 @@ class PlayerService:
logger.debug("Seeked to position: %sms", position_ms)
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
# 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.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()
logger.debug("Volume set to: %s", volume)
logger.debug("Host volume set to: %s", volume)
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:
await self.set_volume(0)
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:
await self.set_volume(self.state.previous_volume)
@@ -415,6 +463,66 @@ class PlayerService:
await self._broadcast_state()
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:
"""Reload current playlist from database."""
session = self.db_session_factory()
@@ -503,6 +611,16 @@ class PlayerService:
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:
await self._stop_playback()
@@ -518,6 +636,9 @@ class PlayerService:
sounds: list[Sound],
) -> None:
"""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
new_index = self._find_sound_index(previous_sound_id, sounds)
@@ -575,6 +696,29 @@ class PlayerService:
return i
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:
"""Set the first track as the current track."""
self.state.current_sound_index = 0
@@ -764,7 +908,12 @@ class PlayerService:
"""Handle when a track finishes playing."""
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:
next_index = self._get_next_index(self.state.current_sound_index)
if next_index is not None:
@@ -772,6 +921,32 @@ class PlayerService:
else:
await self._stop_playback()
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:
"""Broadcast current player state via WebSocket."""

View File

@@ -3,10 +3,12 @@
from typing import Any, TypedDict
from fastapi import HTTPException, status
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger
from app.models.playlist import Playlist
from app.models.playlist_sound import PlaylistSound
from app.models.sound import Sound
from app.repositories.playlist import PlaylistRepository, PlaylistSortField, SortOrder
from app.repositories.sound import SoundRepository
@@ -231,11 +233,23 @@ class PlaylistService:
# Check if this was the current playlist before deleting
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)
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:
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()
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)
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:
"""Unset any current playlist globally."""
current_playlist = await self.playlist_repo.get_current_playlist()

View File

@@ -144,6 +144,25 @@ class SchedulerService:
logger.info("Cancelled task: %s (%s)", task.name, task_id)
return True
async def delete_task(self, task_id: int) -> bool:
"""Delete a scheduled task completely."""
async with self.db_session_factory() as session:
repo = ScheduledTaskRepository(session)
task = await repo.get_by_id(task_id)
if not task:
return False
# Remove from APScheduler first (job might not exist in scheduler)
with suppress(Exception):
self.scheduler.remove_job(str(task_id))
# Delete from database
await repo.delete(task)
logger.info("Deleted task: %s (%s)", task.name, task_id)
return True
async def get_user_tasks(
self,
user_id: int,
@@ -267,6 +286,7 @@ class SchedulerService:
# Handle interval-based recurrence types
interval_configs = {
RecurrenceType.MINUTELY: {"minutes": 1},
RecurrenceType.HOURLY: {"hours": 1},
RecurrenceType.DAILY: {"days": 1},
RecurrenceType.WEEKLY: {"weeks": 1},
@@ -301,6 +321,8 @@ class SchedulerService:
"""Execute a scheduled task."""
task_id_str = str(task_id)
logger.info("APScheduler triggered task %s execution", task_id)
# Prevent concurrent execution of the same task
if task_id_str in self._running_tasks:
logger.warning("Task %s is already running, skipping execution", task_id)
@@ -318,9 +340,21 @@ class SchedulerService:
logger.warning("Task %s not found", task_id)
return
logger.info(
"Task %s current state - status: %s, is_active: %s, executions: %s",
task_id, task.status, task.is_active, task.executions_count,
)
# Check if task is still active and pending
if not task.is_active or task.status != TaskStatus.PENDING:
logger.info("Task %s not active or not pending, skipping", task_id)
logger.warning(
"Task %s execution skipped - is_active: %s, status: %s "
"(should be %s)",
task_id,
task.is_active,
task.status,
TaskStatus.PENDING,
)
return
# Check if task has expired
@@ -333,6 +367,11 @@ class SchedulerService:
return
# Mark task as running
logger.info(
"Task %s starting execution (type: %s)",
task_id,
task.recurrence_type,
)
await repo.mark_as_running(task)
# Execute the task
@@ -345,19 +384,41 @@ class SchedulerService:
)
await handler_registry.execute_task(task)
# Calculate next execution time for recurring tasks
next_execution_at = None
if task.should_repeat():
next_execution_at = self._calculate_next_execution(task)
# Handle completion based on task type
if task.recurrence_type == RecurrenceType.CRON:
# For CRON tasks, update execution metadata but keep PENDING
# APScheduler handles the recurring schedule automatically
logger.info(
"Task %s (CRON) executed successfully, updating metadata",
task_id,
)
task.last_executed_at = datetime.now(tz=UTC)
task.executions_count += 1
task.error_message = None
task.status = TaskStatus.PENDING # Explicitly set to PENDING
session.add(task)
await session.commit()
logger.info(
"Task %s (CRON) metadata updated, status: %s, "
"executions: %s",
task_id,
task.status,
task.executions_count,
)
else:
# For non-CRON recurring tasks, calculate next execution
next_execution_at = None
if task.should_repeat():
next_execution_at = self._calculate_next_execution(task)
# Mark as completed
await repo.mark_as_completed(task, next_execution_at)
# Mark as completed
await repo.mark_as_completed(task, next_execution_at)
# Reschedule if recurring
if next_execution_at and task.should_repeat():
# Refresh task to get updated data
await session.refresh(task)
await self._schedule_apscheduler_job(task)
# Reschedule if recurring
if next_execution_at and task.should_repeat():
# Refresh task to get updated data
await session.refresh(task)
await self._schedule_apscheduler_job(task)
except Exception as e:
await repo.mark_as_failed(task, str(e))
@@ -370,17 +431,21 @@ class SchedulerService:
"""Calculate the next execution time for a recurring task."""
now = datetime.now(tz=UTC)
if task.recurrence_type == RecurrenceType.HOURLY:
return now + timedelta(hours=1)
if task.recurrence_type == RecurrenceType.DAILY:
return now + timedelta(days=1)
if task.recurrence_type == RecurrenceType.WEEKLY:
return now + timedelta(weeks=1)
if task.recurrence_type == RecurrenceType.MONTHLY:
# Add approximately one month
return now + timedelta(days=30)
if task.recurrence_type == RecurrenceType.YEARLY:
return now + timedelta(days=365)
recurrence_deltas = {
RecurrenceType.MINUTELY: timedelta(minutes=1),
RecurrenceType.HOURLY: timedelta(hours=1),
RecurrenceType.DAILY: timedelta(days=1),
RecurrenceType.WEEKLY: timedelta(weeks=1),
RecurrenceType.MONTHLY: timedelta(days=30), # Approximate
RecurrenceType.YEARLY: timedelta(days=365), # Approximate
}
if task.recurrence_type in recurrence_deltas:
return now + recurrence_deltas[task.recurrence_type]
if task.recurrence_type == RecurrenceType.CRON and task.cron_expression:
# For CRON tasks, let APScheduler handle the timing
return now
return None

View File

@@ -80,8 +80,19 @@ class TaskHandlerRegistry:
msg = f"Invalid user_id format: {user_id}"
raise TaskExecutionError(msg) from e
stats = await self.credit_service.recharge_user_credits(user_id_int)
logger.info("Recharged credits for user %s: %s", user_id, stats)
transaction = await self.credit_service.recharge_user_credits_auto(
user_id_int,
)
if transaction:
logger.info(
"Recharged credits for user %s: %s credits added",
user_id,
transaction.amount,
)
else:
logger.info(
"No credits added for user %s (already at maximum)", user_id,
)
else:
# Recharge all users (system task)
stats = await self.credit_service.recharge_all_users_credits()

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

View File

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

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

View File

@@ -0,0 +1,193 @@
"""Background TTS processor for handling TTS generation queue."""
import asyncio
import contextlib
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.config import settings
from app.core.database import engine
from app.core.logging import get_logger
from app.services.tts import TTSService
logger = get_logger(__name__)
class TTSProcessor:
"""Background processor for handling TTS generation queue."""
def __init__(self) -> None:
"""Initialize the TTS processor."""
self.max_concurrent = getattr(settings, "TTS_MAX_CONCURRENT", 3)
self.running_tts: set[int] = set()
self.processing_lock = asyncio.Lock()
self.shutdown_event = asyncio.Event()
self.processor_task: asyncio.Task | None = None
logger.info(
"Initialized TTS processor with max concurrent: %d",
self.max_concurrent,
)
async def start(self) -> None:
"""Start the background TTS processor."""
if self.processor_task and not self.processor_task.done():
logger.warning("TTS processor is already running")
return
# Reset any stuck TTS generations from previous runs
await self._reset_stuck_tts()
self.shutdown_event.clear()
self.processor_task = asyncio.create_task(self._process_queue())
logger.info("Started TTS processor")
async def stop(self) -> None:
"""Stop the background TTS processor."""
logger.info("Stopping TTS processor...")
self.shutdown_event.set()
if self.processor_task and not self.processor_task.done():
try:
await asyncio.wait_for(self.processor_task, timeout=30.0)
except TimeoutError:
logger.warning(
"TTS processor did not stop gracefully, cancelling...",
)
self.processor_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self.processor_task
logger.info("TTS processor stopped")
async def queue_tts(self, tts_id: int) -> None:
"""Queue a TTS generation for processing."""
async with self.processing_lock:
if tts_id not in self.running_tts:
logger.info("Queued TTS %d for processing", tts_id)
# The processor will pick it up on the next cycle
else:
logger.warning(
"TTS %d is already being processed",
tts_id,
)
async def _process_queue(self) -> None:
"""Process the TTS queue in the main processing loop."""
logger.info("Starting TTS queue processor")
while not self.shutdown_event.is_set():
try:
await self._process_pending_tts()
# Wait before checking for new TTS generations
try:
await asyncio.wait_for(self.shutdown_event.wait(), timeout=5.0)
break # Shutdown requested
except TimeoutError:
continue # Continue processing
except Exception:
logger.exception("Error in TTS queue processor")
# Wait a bit before retrying to avoid tight error loops
try:
await asyncio.wait_for(self.shutdown_event.wait(), timeout=10.0)
break # Shutdown requested
except TimeoutError:
continue
logger.info("TTS queue processor stopped")
async def _process_pending_tts(self) -> None:
"""Process pending TTS generations up to the concurrency limit."""
async with self.processing_lock:
# Check how many slots are available
available_slots = self.max_concurrent - len(self.running_tts)
if available_slots <= 0:
return # No available slots
# Get pending TTS generations from database
async with AsyncSession(engine) as session:
tts_service = TTSService(session)
pending_tts = await tts_service.get_pending_tts()
# Filter out TTS that are already being processed
available_tts = [
tts
for tts in pending_tts
if tts.id not in self.running_tts
]
# Start processing up to available slots
tts_to_start = available_tts[:available_slots]
for tts in tts_to_start:
tts_id = tts.id
self.running_tts.add(tts_id)
# Start processing this TTS in the background
task = asyncio.create_task(
self._process_single_tts(tts_id),
)
task.add_done_callback(
lambda t, tid=tts_id: self._on_tts_completed(
tid,
t,
),
)
logger.info(
"Started processing TTS %d (%d/%d slots used)",
tts_id,
len(self.running_tts),
self.max_concurrent,
)
async def _process_single_tts(self, tts_id: int) -> None:
"""Process a single TTS generation."""
try:
async with AsyncSession(engine) as session:
tts_service = TTSService(session)
await tts_service.process_tts_generation(tts_id)
logger.info("Successfully processed TTS %d", tts_id)
except Exception:
logger.exception("Failed to process TTS %d", tts_id)
# Mark TTS as failed in database
try:
async with AsyncSession(engine) as session:
tts_service = TTSService(session)
await tts_service.mark_tts_failed(tts_id, "Processing failed")
except Exception:
logger.exception("Failed to mark TTS %d as failed", tts_id)
def _on_tts_completed(self, tts_id: int, task: asyncio.Task) -> None:
"""Handle completion of a TTS processing task."""
self.running_tts.discard(tts_id)
if task.exception():
logger.error(
"TTS processing task %d failed: %s",
tts_id,
task.exception(),
)
else:
logger.info("TTS processing task %d completed successfully", tts_id)
async def _reset_stuck_tts(self) -> None:
"""Reset any TTS generations that were stuck in 'processing' state."""
try:
async with AsyncSession(engine) as session:
tts_service = TTSService(session)
reset_count = await tts_service.reset_stuck_tts()
if reset_count > 0:
logger.info("Reset %d stuck TTS generations", reset_count)
else:
logger.info("No stuck TTS generations found to reset")
except Exception:
logger.exception("Failed to reset stuck TTS generations")
# Global TTS processor instance
tts_processor = TTSProcessor()

View File

@@ -73,6 +73,9 @@ class VLCPlayerService:
async def play_sound(self, sound: Sound) -> bool:
"""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:
sound: The Sound object to play
@@ -97,6 +100,7 @@ class VLCPlayerService:
"--no-video", # Audio only
"--no-repeat", # Don't repeat
"--no-loop", # Don't loop
"--volume=100", # Always use 100% VLC volume
]
# Launch VLC process asynchronously without waiting
@@ -144,7 +148,7 @@ class VLCPlayerService:
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await find_process.communicate()
stdout, _stderr = await find_process.communicate()
if find_process.returncode != 0:
# No VLC processes found
@@ -332,7 +336,10 @@ class VLCPlayerService:
"""
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:
raise HTTPException(
@@ -369,7 +376,7 @@ class VLCPlayerService:
),
) from e
# Play the sound using VLC
# Play the sound using VLC (always at 100% VLC volume)
success = await self.play_sound(sound)
# Deduct credits based on success
@@ -407,4 +414,3 @@ def get_vlc_player_service(
if vlc_player_service is None:
vlc_player_service = VLCPlayerService(db_session_factory)
return vlc_player_service
return vlc_player_service

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

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

100
migrate.py Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Database migration CLI tool."""
import argparse
import logging
import sys
from pathlib import Path
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:
"""Run database migration CLI tool."""
parser = argparse.ArgumentParser(description="Database migration tool")
subparsers = parser.add_subparsers(dest="command", help="Migration commands")
# Upgrade command
upgrade_parser = subparsers.add_parser(
"upgrade", help="Upgrade database to latest revision",
)
upgrade_parser.add_argument(
"revision",
nargs="?",
default="head",
help="Target revision (default: head)",
)
# Downgrade command
downgrade_parser = subparsers.add_parser("downgrade", help="Downgrade database")
downgrade_parser.add_argument("revision", help="Target revision")
# Current command
subparsers.add_parser("current", help="Show current revision")
# History command
subparsers.add_parser("history", help="Show revision history")
# Generate migration command
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(
"--autogenerate", action="store_true", help="Auto-generate migration",
)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
# Get the alembic config
config_path = Path("alembic.ini")
if not config_path.exists():
logger.error("Error: alembic.ini not found. Run from the backend directory.")
sys.exit(1)
alembic_cfg = Config(str(config_path))
try:
if args.command == "upgrade":
command.upgrade(alembic_cfg, args.revision)
logger.info(
"Successfully upgraded database to revision: %s", args.revision,
)
elif args.command == "downgrade":
command.downgrade(alembic_cfg, args.revision)
logger.info(
"Successfully downgraded database to revision: %s", args.revision,
)
elif args.command == "current":
command.current(alembic_cfg)
elif args.command == "history":
command.history(alembic_cfg)
elif args.command == "revision":
if args.autogenerate:
command.revision(alembic_cfg, message=args.message, autogenerate=True)
logger.info("Created new auto-generated migration: %s", args.message)
else:
command.revision(alembic_cfg, message=args.message)
logger.info("Created new empty migration: %s", args.message)
except (OSError, RuntimeError):
logger.exception("Error occurred during migration")
sys.exit(1)
if __name__ == "__main__":
main()

View File

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

View File

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

View File

@@ -196,7 +196,7 @@ class TestApiTokenDependencies:
) -> None:
"""Test flexible authentication falls back to JWT when no API token."""
# 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
# In a real test, you'd mock the import or use dependency injection
await get_current_user_flexible(mock_auth_service, "jwt_token", None)

View File

@@ -110,7 +110,7 @@ class TestUserRepository:
test_session: AsyncSession,
) -> None:
"""Test creating a new user."""
free_plan, pro_plan = ensure_plans
free_plan, _pro_plan = ensure_plans
plan_id = free_plan.id
plan_credits = free_plan.credits

View File

@@ -42,7 +42,7 @@ class TestAuthService:
assert response.user.role == "admin" # First user gets admin role
assert response.user.is_active is True
# 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.plan["code"] == pro_plan.code

View File

@@ -15,19 +15,32 @@ def mock_sound_repository():
@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."""
return DashboardService(sound_repository=mock_sound_repository)
return DashboardService(
sound_repository=mock_sound_repository,
user_repository=mock_user_repository,
)
class TestDashboardService:
"""Test dashboard service."""
@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."""
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.user_repository == mock_user_repository
@pytest.mark.asyncio
async def test_get_soundboard_statistics_success(

View File

@@ -26,12 +26,17 @@ class TestPlayerState:
def test_init_creates_default_state(self) -> None:
"""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
assert state.mode == PlayerMode.CONTINUOUS
assert state.volume == 80
assert state.previous_volume == 80
state = PlayerState()
assert state.status == PlayerStatus.STOPPED
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_index is None
assert state.current_sound_position == 0
@@ -181,7 +186,9 @@ class TestPlayerService:
mock_socket_manager,
):
"""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(
self,
@@ -217,7 +224,8 @@ class TestPlayerService:
assert player_service._loop is not None
assert player_service._position_thread is not None
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
async def test_stop_cleans_up_service(self, player_service) -> None:
@@ -399,23 +407,32 @@ class TestPlayerService:
@pytest.mark.asyncio
async def test_set_volume(self, player_service) -> None:
"""Test setting volume."""
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
await player_service.set_volume(75)
with patch("app.services.player.volume_service") as mock_volume_service:
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
mock_volume_service.is_muted.return_value = False
assert player_service.state.volume == 75
player_service._player.audio_set_volume.assert_called_once_with(75)
await player_service.set_volume(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
async def test_set_volume_clamping(self, player_service) -> None:
"""Test volume clamping to valid range."""
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
# Test upper bound
await player_service.set_volume(150)
assert player_service.state.volume == 100
with patch("app.services.player.volume_service") as mock_volume_service:
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
mock_volume_service.is_muted.return_value = False
# Test lower bound
await player_service.set_volume(-10)
assert player_service.state.volume == 0
# Test upper bound
await player_service.set_volume(150)
assert player_service.state.volume == 100
# Test lower bound
await player_service.set_volume(-10)
assert player_service.state.volume == 0
@pytest.mark.asyncio
async def test_set_mode(self, player_service) -> None:

View File

@@ -174,6 +174,39 @@ class TestSchedulerService:
result = await scheduler_service.cancel_task(uuid.uuid4())
assert result is False
async def test_delete_task(
self,
scheduler_service: SchedulerService,
sample_task_data: dict,
):
"""Test deleting a task."""
# Create a task first
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
schema = self._create_task_schema(sample_task_data)
task = await scheduler_service.create_task(task_data=schema)
# Mock the scheduler remove_job method
with patch.object(scheduler_service.scheduler, "remove_job") as mock_remove:
result = await scheduler_service.delete_task(task.id)
assert result is True
mock_remove.assert_called_once_with(str(task.id))
# Check task is completely deleted from database
from app.repositories.scheduled_task import ScheduledTaskRepository
async with scheduler_service.db_session_factory() as session:
repo = ScheduledTaskRepository(session)
deleted_task = await repo.get_by_id(task.id)
assert deleted_task is None
async def test_delete_nonexistent_task(
self,
scheduler_service: SchedulerService,
):
"""Test deleting a non-existent task."""
result = await scheduler_service.delete_task(uuid.uuid4())
assert result is False
async def test_get_user_tasks(
self,
scheduler_service: SchedulerService,

View File

@@ -92,14 +92,14 @@ class TestTaskHandlerRegistry:
parameters={"user_id": str(test_user_id)},
)
mock_credit_service.recharge_user_credits.return_value = {
"user_id": str(test_user_id),
"credits_added": 100,
}
# Mock transaction object
mock_transaction = MagicMock()
mock_transaction.amount = 100
mock_credit_service.recharge_user_credits_auto.return_value = mock_transaction
await task_registry.execute_task(task)
mock_credit_service.recharge_user_credits.assert_called_once_with(test_user_id)
mock_credit_service.recharge_user_credits_auto.assert_called_once_with(test_user_id)
async def test_handle_credit_recharge_uuid_user_id(
self,
@@ -117,7 +117,7 @@ class TestTaskHandlerRegistry:
await task_registry.execute_task(task)
mock_credit_service.recharge_user_credits.assert_called_once_with(test_user_id)
mock_credit_service.recharge_user_credits_auto.assert_called_once_with(test_user_id)
async def test_handle_play_sound_success(
self,

535
uv.lock generated
View File

@@ -14,6 +14,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792 },
]
[[package]]
name = "alembic"
version = "1.16.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355 },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -75,52 +89,68 @@ wheels = [
[[package]]
name = "bcrypt"
version = "4.3.0"
version = "5.0.0"
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498 },
{ 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/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626 },
{ 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/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/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/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/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/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/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/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/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/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/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449 },
{ 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/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752 },
{ 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]]
@@ -141,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 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 },
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 },
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 },
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 },
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 },
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 },
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 },
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 },
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 },
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 },
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 },
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 },
]
[[package]]
name = "click"
version = "8.2.1"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
]
[[package]]
@@ -163,67 +235,86 @@ wheels = [
]
[[package]]
name = "coverage"
version = "7.10.6"
name = "comtypes"
version = "1.4.12"
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 = [
{ 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/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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/04/12/7a55b0bdde78a98e2eb2356771fd2dcddb96579e8342bb52aa5bc52e96f0/coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d", size = 219724 },
{ 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/08/3a/d5d8dc703e4998038c3099eaf77adddb00536a3cec08c8dcd556a36a3eb4/coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80", size = 219171 },
{ 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/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/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/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/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/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/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/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/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770 },
{ 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/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195 },
{ 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/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/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/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/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/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/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/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/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429 },
{ 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/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757 },
{ 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/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/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/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/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/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/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/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/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054 },
{ 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/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429 },
{ 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/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/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/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/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/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/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/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/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831 },
{ 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/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969 },
{ 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/2d/01/89285549c5138009db68f26c80f2174d0ec82a858547df0cc40a8b0a47d6/comtypes-1.4.12-py3-none-any.whl", hash = "sha256:e0fa9cc19c489fa7feea4c1710f4575c717e2673edef5b99bf99efd507908e44", size = 253704 },
]
[[package]]
name = "coverage"
version = "7.10.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704 }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699 },
{ 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/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147 },
{ 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/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/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/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/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/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/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/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/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/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/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746 },
{ 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/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170 },
{ 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/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/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/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/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/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/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/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/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/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/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403 },
{ 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/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731 },
{ 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/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/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/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/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/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/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/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]]
@@ -250,28 +341,28 @@ wheels = [
[[package]]
name = "faker"
version = "37.6.0"
version = "37.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/cd/f7679c20f07d9e2013123b7f7e13809a3450a18d938d58e86081a486ea15/faker-37.6.0.tar.gz", hash = "sha256:0f8cc34f30095184adf87c3c24c45b38b33ad81c35ef6eb0a3118f301143012c", size = 1907960 }
sdist = { url = "https://files.pythonhosted.org/packages/3a/da/1336008d39e5d4076dddb4e0f3a52ada41429274bf558a3cc28030d324a3/faker-37.8.0.tar.gz", hash = "sha256:090bb5abbec2b30949a95ce1ba6b20d1d0ed222883d63483a0d4be4a970d6fb8", size = 1912113 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/7d/8b50e4ac772719777be33661f4bde320793400a706f5eb214e4de46f093c/faker-37.6.0-py3-none-any.whl", hash = "sha256:3c5209b23d7049d596a51db5d76403a0ccfea6fc294ffa2ecfef6a8843b1e6a7", size = 1949837 },
{ url = "https://files.pythonhosted.org/packages/f5/11/02ebebb09ff2104b690457cb7bc6ed700c9e0ce88cf581486bb0a5d3c88b/faker-37.8.0-py3-none-any.whl", hash = "sha256:b08233118824423b5fc239f7dd51f145e7018082b4164f8da6a9994e1f1ae793", size = 1953940 },
]
[[package]]
name = "fastapi"
version = "0.116.1"
version = "0.118.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485 }
sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536 }
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]
@@ -376,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 },
]
[[package]]
name = "gtts"
version = "2.5.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/79/5ddb1dfcd663581d0d3fca34ccb1d8d841b47c22a24dc8dce416e3d87dfa/gtts-2.5.4.tar.gz", hash = "sha256:f5737b585f6442f677dbe8773424fd50697c75bdf3e36443585e30a8d48c1884", size = 24018 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/6c/8b8b1fdcaee7e268536f1bb00183a5894627726b54a9ddc6fc9909888447/gTTS-2.5.4-py3-none-any.whl", hash = "sha256:5dd579377f9f5546893bc26315ab1f846933dc27a054764b168f141065ca8436", size = 29184 },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -465,6 +569,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@@ -526,34 +642,34 @@ wheels = [
[[package]]
name = "mypy"
version = "1.18.1"
version = "1.18.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ 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 = [
{ 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/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/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/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/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/da/8d/6d991ae631f80d58edbf9d7066e3f2a96e479dca955d9a968cd6e90850a3/mypy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:2657654d82fcd2a87e02a33e0d23001789a554059bbf34702d623dafe353eabf", size = 9828426 },
{ 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/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/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/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/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/f5/f8/7661021a5b0e501b76440454d786b0f01bb05d5c4b125fcbda02023d0250/mypy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:2cd2c1e0f3a7465f22731987fff6fc427e3dcbb4ca5f7db5bbeaff2ff9a31f6d", size = 9837684 },
{ 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/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/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/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/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/b1/df/462866163c99ea73bb28f0eb4d415c087e30de5d36ee0f5429d42e28689b/mypy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:e4f16c0019d48941220ac60b893615be2f63afedaba6a0801bdcd041b96991ce", size = 9985739 },
{ 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/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/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/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/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/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/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828 },
{ 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/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/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/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/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/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262 },
{ 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/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/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/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/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/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959 },
{ url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367 },
]
[[package]]
@@ -592,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 },
]
[[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]]
name = "psycopg"
version = "3.2.10"
@@ -644,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 },
]
[[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]]
name = "pydantic"
version = "2.11.7"
@@ -708,16 +862,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.10.1"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ 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 = [
{ 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]]
@@ -799,15 +953,15 @@ wheels = [
[[package]]
name = "python-socketio"
version = "5.13.0"
version = "5.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
{ 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 = [
{ 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]]
@@ -854,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 },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
]
[[package]]
name = "rich"
version = "14.0.0"
@@ -929,28 +1098,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.13.0"
version = "0.13.3"
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613 },
{ 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/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488 },
{ 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/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484 },
]
[[package]]
@@ -959,14 +1128,18 @@ version = "2.0.0"
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "apscheduler" },
{ name = "asyncpg" },
{ name = "bcrypt" },
{ name = "email-validator" },
{ name = "fastapi", extra = ["standard"] },
{ name = "ffmpeg-python" },
{ name = "gtts" },
{ name = "httpx" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pulsectl" },
{ name = "pycaw" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-socketio" },
@@ -991,33 +1164,37 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = "==0.21.0" },
{ name = "alembic", specifier = "==1.16.5" },
{ name = "apscheduler", specifier = "==3.11.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 = "fastapi", extras = ["standard"], specifier = "==0.116.1" },
{ name = "fastapi", extras = ["standard"], specifier = "==0.118.0" },
{ name = "ffmpeg-python", specifier = "==0.2.0" },
{ name = "gtts", specifier = "==2.5.4" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.10" },
{ name = "pydantic-settings", specifier = "==2.10.1" },
{ name = "pulsectl", specifier = ">=24.12.0" },
{ name = "pycaw", specifier = ">=20240210" },
{ name = "pydantic-settings", specifier = "==2.11.0" },
{ 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 = "pytz", specifier = "==2025.2" },
{ name = "sqlmodel", specifier = "==0.0.24" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
{ name = "yt-dlp", specifier = "==2025.9.5" },
{ name = "sqlmodel", specifier = "==0.0.25" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.37.0" },
{ name = "yt-dlp", specifier = "==2025.9.26" },
]
[package.metadata.requires-dev]
dev = [
{ name = "coverage", specifier = "==7.10.6" },
{ name = "faker", specifier = "==37.6.0" },
{ name = "coverage", specifier = "==7.10.7" },
{ name = "faker", specifier = "==37.8.0" },
{ 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-asyncio", specifier = "==1.2.0" },
{ name = "ruff", specifier = "==0.13.0" },
{ name = "ruff", specifier = "==0.13.3" },
]
[[package]]
@@ -1094,15 +1271,15 @@ wheels = [
[[package]]
name = "sqlmodel"
version = "0.0.24"
version = "0.0.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780 }
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/d9c098a88724ee4554907939cf39590cf67e10c6683723216e228d3315f7/sqlmodel-0.0.25.tar.gz", hash = "sha256:56548c2e645975b1ed94d6c53f0d13c85593f57926a575e2bf566650b2243fa4", size = 117075 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622 },
{ url = "https://files.pythonhosted.org/packages/57/cf/5d175ce8de07fe694ec4e3d4d65c2dd06cc30f6c79599b31f9d2f6dd2830/sqlmodel-0.0.25-py3-none-any.whl", hash = "sha256:c98234cda701fb77e9dcbd81688c23bb251c13bb98ce1dd8d4adc467374d45b7", size = 28893 },
]
[[package]]
@@ -1186,15 +1363,15 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.35.0"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473 }
sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367 }
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]
@@ -1340,9 +1517,9 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.9.5"
version = "2025.9.26"
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 = [
{ 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 },
]