Compare commits
31 Commits
1388ede1dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4c72f3b19 | ||
|
|
17eafa4872 | ||
|
|
c9f6bff723 | ||
|
|
12243b1424 | ||
|
|
f7197a89a7 | ||
|
|
b66b8e36bb | ||
|
|
95e166eefb | ||
|
|
d9697c2dd7 | ||
|
|
7b59a8216a | ||
|
|
4b8496d025 | ||
|
|
0806d541f2 | ||
|
|
acdf191a5a | ||
|
|
35b857fd0d | ||
|
|
c13e18c290 | ||
|
|
702d7ee577 | ||
|
|
d3b6e90262 | ||
|
|
50eeae4c62 | ||
|
|
e005dedcd3 | ||
|
|
72ddd98b25 | ||
|
|
b2e513a915 | ||
|
|
c8b796aa94 | ||
|
|
d5f9a3c736 | ||
|
|
2b61d35d6a | ||
|
|
5e8d619736 | ||
|
|
fb0e5e919c | ||
|
|
bccfcafe0e | ||
|
|
1bef694f38 | ||
|
|
b87a47f199 | ||
|
|
83239cb4fa | ||
|
|
e8f979c137 | ||
|
|
92571f4de9 |
@@ -1,29 +0,0 @@
|
|||||||
# Application Configuration
|
|
||||||
HOST=localhost
|
|
||||||
PORT=8000
|
|
||||||
RELOAD=true
|
|
||||||
|
|
||||||
# Database Configuration
|
|
||||||
DATABASE_URL=sqlite+aiosqlite:///data/soundboard.db
|
|
||||||
DATABASE_ECHO=false
|
|
||||||
|
|
||||||
# Logging Configuration
|
|
||||||
LOG_LEVEL=info
|
|
||||||
LOG_FILE=logs/app.log
|
|
||||||
LOG_MAX_SIZE=10485760
|
|
||||||
LOG_BACKUP_COUNT=5
|
|
||||||
|
|
||||||
# JWT Configuration
|
|
||||||
JWT_SECRET_KEY=your-secret-key-change-in-production
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15
|
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
|
||||||
|
|
||||||
# Cookie Configuration
|
|
||||||
COOKIE_SECURE=false
|
|
||||||
COOKIE_SAMESITE=lax
|
|
||||||
|
|
||||||
# OAuth2 Configuration
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
GITHUB_CLIENT_ID=
|
|
||||||
GITHUB_CLIENT_SECRET=
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
# Enhanced Scheduler System - Usage Examples
|
|
||||||
|
|
||||||
This document demonstrates how to use the new comprehensive scheduled task system.
|
|
||||||
|
|
||||||
## Features Overview
|
|
||||||
|
|
||||||
### ✨ **Task Types**
|
|
||||||
- **Credit Recharge**: Automatic or scheduled credit replenishment
|
|
||||||
- **Play Sound**: Schedule individual sound playback
|
|
||||||
- **Play Playlist**: Schedule playlist playback with modes
|
|
||||||
|
|
||||||
### 🌍 **Timezone Support**
|
|
||||||
- Full timezone support with automatic UTC conversion
|
|
||||||
- Specify any IANA timezone (e.g., "America/New_York", "Europe/Paris")
|
|
||||||
|
|
||||||
### 🔄 **Scheduling Options**
|
|
||||||
- **One-shot**: Execute once at specific date/time
|
|
||||||
- **Recurring**: Hourly, daily, weekly, monthly, yearly intervals
|
|
||||||
- **Cron**: Custom cron expressions for complex scheduling
|
|
||||||
|
|
||||||
## API Usage Examples
|
|
||||||
|
|
||||||
### Create a One-Shot Task
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Schedule a sound to play in 2 hours
|
|
||||||
curl -X POST "http://localhost:8000/api/v1/scheduler/tasks" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Cookie: access_token=YOUR_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"name": "Play Morning Alarm",
|
|
||||||
"task_type": "play_sound",
|
|
||||||
"scheduled_at": "2024-01-01T10:00:00",
|
|
||||||
"timezone": "America/New_York",
|
|
||||||
"parameters": {
|
|
||||||
"sound_id": "sound-uuid-here"
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create a Recurring Task
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Daily credit recharge at midnight UTC
|
|
||||||
curl -X POST "http://localhost:8000/api/v1/scheduler/admin/system-tasks" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Cookie: access_token=ADMIN_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"name": "Daily Credit Recharge",
|
|
||||||
"task_type": "credit_recharge",
|
|
||||||
"scheduled_at": "2024-01-01T00:00:00",
|
|
||||||
"timezone": "UTC",
|
|
||||||
"recurrence_type": "daily",
|
|
||||||
"parameters": {}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create a Cron-Based Task
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Play playlist every weekday at 9 AM
|
|
||||||
curl -X POST "http://localhost:8000/api/v1/scheduler/tasks" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Cookie: access_token=YOUR_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"name": "Workday Playlist",
|
|
||||||
"task_type": "play_playlist",
|
|
||||||
"scheduled_at": "2024-01-01T09:00:00",
|
|
||||||
"timezone": "America/New_York",
|
|
||||||
"recurrence_type": "cron",
|
|
||||||
"cron_expression": "0 9 * * 1-5",
|
|
||||||
"parameters": {
|
|
||||||
"playlist_id": "playlist-uuid-here",
|
|
||||||
"play_mode": "loop",
|
|
||||||
"shuffle": true
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Python Service Usage
|
|
||||||
|
|
||||||
```python
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from app.services.scheduler import SchedulerService
|
|
||||||
from app.models.scheduled_task import TaskType, RecurrenceType
|
|
||||||
|
|
||||||
# Initialize scheduler service
|
|
||||||
scheduler_service = SchedulerService(db_session_factory, player_service)
|
|
||||||
|
|
||||||
# Create a one-shot task
|
|
||||||
task = await scheduler_service.create_task(
|
|
||||||
name="Test Sound",
|
|
||||||
task_type=TaskType.PLAY_SOUND,
|
|
||||||
scheduled_at=datetime.utcnow() + timedelta(hours=2),
|
|
||||||
timezone="America/New_York",
|
|
||||||
parameters={"sound_id": "sound-uuid-here"},
|
|
||||||
user_id=user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a recurring task
|
|
||||||
recurring_task = await scheduler_service.create_task(
|
|
||||||
name="Weekly Playlist",
|
|
||||||
task_type=TaskType.PLAY_PLAYLIST,
|
|
||||||
scheduled_at=datetime.utcnow() + timedelta(days=1),
|
|
||||||
recurrence_type=RecurrenceType.WEEKLY,
|
|
||||||
recurrence_count=10, # Run 10 times then stop
|
|
||||||
parameters={
|
|
||||||
"playlist_id": "playlist-uuid",
|
|
||||||
"play_mode": "continuous",
|
|
||||||
"shuffle": False
|
|
||||||
},
|
|
||||||
user_id=user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cancel a task
|
|
||||||
success = await scheduler_service.cancel_task(task.id)
|
|
||||||
|
|
||||||
# Get user's tasks
|
|
||||||
user_tasks = await scheduler_service.get_user_tasks(
|
|
||||||
user_id=user.id,
|
|
||||||
status=TaskStatus.PENDING,
|
|
||||||
limit=20
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task Parameters
|
|
||||||
|
|
||||||
### Credit Recharge Parameters
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "uuid-string-or-null" // null for all users (system task)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Play Sound Parameters
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sound_id": "uuid-string" // Required: sound to play
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Play Playlist Parameters
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"playlist_id": "uuid-string", // Required: playlist to play
|
|
||||||
"play_mode": "continuous", // Optional: continuous, loop, loop_one, random, single
|
|
||||||
"shuffle": false // Optional: shuffle playlist
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recurrence Types
|
|
||||||
|
|
||||||
| Type | Description | Example |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `none` | One-shot execution | Single alarm |
|
|
||||||
| `hourly` | Every hour | Hourly reminders |
|
|
||||||
| `daily` | Every day | Daily credit recharge |
|
|
||||||
| `weekly` | Every week | Weekly reports |
|
|
||||||
| `monthly` | Every month | Monthly maintenance |
|
|
||||||
| `yearly` | Every year | Annual renewals |
|
|
||||||
| `cron` | Custom cron expression | Complex schedules |
|
|
||||||
|
|
||||||
## Cron Expression Examples
|
|
||||||
|
|
||||||
| Expression | Description |
|
|
||||||
|------------|-------------|
|
|
||||||
| `0 9 * * *` | Daily at 9 AM |
|
|
||||||
| `0 9 * * 1-5` | Weekdays at 9 AM |
|
|
||||||
| `30 14 1 * *` | 1st of month at 2:30 PM |
|
|
||||||
| `0 0 * * 0` | Every Sunday at midnight |
|
|
||||||
| `*/15 * * * *` | Every 15 minutes |
|
|
||||||
|
|
||||||
## System Tasks vs User Tasks
|
|
||||||
|
|
||||||
### System Tasks
|
|
||||||
- Created by administrators
|
|
||||||
- No user association (`user_id` is null)
|
|
||||||
- Typically for maintenance operations
|
|
||||||
- Accessible via admin endpoints
|
|
||||||
|
|
||||||
### User Tasks
|
|
||||||
- Created by regular users
|
|
||||||
- Associated with specific user
|
|
||||||
- User can only manage their own tasks
|
|
||||||
- Accessible via regular user endpoints
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The system provides comprehensive error handling:
|
|
||||||
|
|
||||||
- **Invalid Parameters**: Validation errors for missing or invalid task parameters
|
|
||||||
- **Scheduling Conflicts**: Prevention of resource conflicts
|
|
||||||
- **Timezone Errors**: Invalid timezone specifications handled gracefully
|
|
||||||
- **Execution Failures**: Failed tasks marked with error messages and retry logic
|
|
||||||
- **Expired Tasks**: Automatic cleanup of expired tasks
|
|
||||||
|
|
||||||
## Monitoring and Management
|
|
||||||
|
|
||||||
### Get Task Status
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:8000/api/v1/scheduler/tasks/{task-id}" \
|
|
||||||
-H "Cookie: access_token=YOUR_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### List User Tasks
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:8000/api/v1/scheduler/tasks?status=pending&limit=10" \
|
|
||||||
-H "Cookie: access_token=YOUR_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Admin: View All Tasks
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:8000/api/v1/scheduler/admin/tasks?limit=50" \
|
|
||||||
-H "Cookie: access_token=ADMIN_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cancel Task
|
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:8000/api/v1/scheduler/tasks/{task-id}" \
|
|
||||||
-H "Cookie: access_token=YOUR_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration from Old Scheduler
|
|
||||||
|
|
||||||
The new system automatically:
|
|
||||||
|
|
||||||
1. **Creates system tasks**: Daily credit recharge task created on startup
|
|
||||||
2. **Maintains compatibility**: Existing credit recharge functionality preserved
|
|
||||||
3. **Enhances functionality**: Adds user tasks and new task types
|
|
||||||
4. **Improves reliability**: Better error handling and timezone support
|
|
||||||
|
|
||||||
The old scheduler is completely replaced - no migration needed for existing functionality.
|
|
||||||
148
alembic.ini
Normal file
148
alembic.ini
Normal 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
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
86
alembic/env.py
Normal file
86
alembic/env.py
Normal 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
28
alembic/script.py.mako
Normal 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"}
|
||||||
@@ -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 ###
|
||||||
222
alembic/versions/7aa9892ceff3_initial_migration.py
Normal file
222
alembic/versions/7aa9892ceff3_initial_migration.py
Normal 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 ###
|
||||||
@@ -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")
|
||||||
45
alembic/versions/e617c155eea9_add_tts_table.py
Normal file
45
alembic/versions/e617c155eea9_add_tts_table.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add TTS table
|
||||||
|
|
||||||
|
Revision ID: e617c155eea9
|
||||||
|
Revises: a0d322857b2c
|
||||||
|
Create Date: 2025-09-20 21:51:26.557738
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e617c155eea9'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'a0d322857b2c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('tts',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('text', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False),
|
||||||
|
sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
|
||||||
|
sa.Column('options', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('sound_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('tts')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -15,6 +15,7 @@ from app.api.v1 import (
|
|||||||
scheduler,
|
scheduler,
|
||||||
socket,
|
socket,
|
||||||
sounds,
|
sounds,
|
||||||
|
tts,
|
||||||
)
|
)
|
||||||
|
|
||||||
# V1 API router with v1 prefix
|
# V1 API router with v1 prefix
|
||||||
@@ -32,4 +33,5 @@ api_router.include_router(playlists.router, tags=["playlists"])
|
|||||||
api_router.include_router(scheduler.router, tags=["scheduler"])
|
api_router.include_router(scheduler.router, tags=["scheduler"])
|
||||||
api_router.include_router(socket.router, tags=["socket"])
|
api_router.include_router(socket.router, tags=["socket"])
|
||||||
api_router.include_router(sounds.router, tags=["sounds"])
|
api_router.include_router(sounds.router, tags=["sounds"])
|
||||||
|
api_router.include_router(tts.router, tags=["tts"])
|
||||||
api_router.include_router(admin.router)
|
api_router.include_router(admin.router)
|
||||||
|
|||||||
@@ -41,6 +41,59 @@ async def get_track_statistics(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tts-statistics")
|
||||||
|
async def get_tts_statistics(
|
||||||
|
_current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
dashboard_service: Annotated[DashboardService, Depends(get_dashboard_service)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get TTS statistics."""
|
||||||
|
try:
|
||||||
|
return await dashboard_service.get_tts_statistics()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch TTS statistics: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/top-users")
|
||||||
|
async def get_top_users(
|
||||||
|
_current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
dashboard_service: Annotated[DashboardService, Depends(get_dashboard_service)],
|
||||||
|
metric_type: Annotated[
|
||||||
|
str,
|
||||||
|
Query(
|
||||||
|
description=(
|
||||||
|
"Metric type: sounds_played, credits_used, tracks_added, "
|
||||||
|
"tts_added, playlists_created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
period: Annotated[
|
||||||
|
str,
|
||||||
|
Query(
|
||||||
|
description="Time period (today, 1_day, 1_week, 1_month, 1_year, all_time)",
|
||||||
|
),
|
||||||
|
] = "all_time",
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
Query(description="Number of top users to return", ge=1, le=100),
|
||||||
|
] = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get top users by metric for a specific period."""
|
||||||
|
try:
|
||||||
|
return await dashboard_service.get_top_users(
|
||||||
|
metric_type=metric_type,
|
||||||
|
period=period,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch top users: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/top-sounds")
|
@router.get("/top-sounds")
|
||||||
async def get_top_sounds(
|
async def get_top_sounds(
|
||||||
_current_user: Annotated[User, Depends(get_current_user)],
|
_current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
|||||||
@@ -249,3 +249,21 @@ async def get_state(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to get player state",
|
detail="Failed to get player state",
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/play-next/{sound_id}")
|
||||||
|
async def add_to_play_next(
|
||||||
|
sound_id: int,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
|
||||||
|
) -> MessageResponse:
|
||||||
|
"""Add a sound to the play next queue."""
|
||||||
|
try:
|
||||||
|
player = get_player_service()
|
||||||
|
await player.add_to_play_next(sound_id)
|
||||||
|
return MessageResponse(message=f"Added sound {sound_id} to play next queue")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error adding sound to play next queue: %s", sound_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to add sound to play next queue",
|
||||||
|
) from e
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ async def update_task(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/tasks/{task_id}")
|
@router.delete("/tasks/{task_id}")
|
||||||
async def cancel_task(
|
async def delete_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)] = ...,
|
current_user: Annotated[User, Depends(get_current_active_user)] = ...,
|
||||||
scheduler_service: Annotated[
|
scheduler_service: Annotated[
|
||||||
@@ -137,7 +137,7 @@ async def cancel_task(
|
|||||||
] = ...,
|
] = ...,
|
||||||
db_session: Annotated[AsyncSession, Depends(get_db)] = ...,
|
db_session: Annotated[AsyncSession, Depends(get_db)] = ...,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Cancel a scheduled task."""
|
"""Delete a scheduled task completely."""
|
||||||
repo = ScheduledTaskRepository(db_session)
|
repo = ScheduledTaskRepository(db_session)
|
||||||
task = await repo.get_by_id(task_id)
|
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:
|
if task.user_id != current_user.id and not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
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:
|
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
|
# Admin-only endpoints
|
||||||
|
|||||||
225
app/api/v1/tts.py
Normal file
225
app/api/v1/tts.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""TTS API endpoints."""
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_active_user_flexible
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.tts import TTSService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tts", tags=["tts"])
|
||||||
|
|
||||||
|
|
||||||
|
class TTSGenerateRequest(BaseModel):
|
||||||
|
"""TTS generation request model."""
|
||||||
|
|
||||||
|
text: str = Field(
|
||||||
|
..., min_length=1, max_length=1000, description="Text to convert to speech",
|
||||||
|
)
|
||||||
|
provider: str = Field(default="gtts", description="TTS provider to use")
|
||||||
|
options: dict[str, Any] = Field(
|
||||||
|
default_factory=dict, description="Provider-specific options",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TTSResponse(BaseModel):
|
||||||
|
"""TTS generation response model."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
text: str
|
||||||
|
provider: str
|
||||||
|
options: dict[str, Any]
|
||||||
|
status: str
|
||||||
|
error: str | None
|
||||||
|
sound_id: int | None
|
||||||
|
user_id: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfo(BaseModel):
|
||||||
|
"""Provider information model."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
file_extension: str
|
||||||
|
supported_languages: list[str]
|
||||||
|
option_schema: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tts_service(
|
||||||
|
session: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> TTSService:
|
||||||
|
"""Get the TTS service."""
|
||||||
|
return TTSService(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_tts_list(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[TTSResponse]:
|
||||||
|
"""Get TTS list for the current user."""
|
||||||
|
try:
|
||||||
|
if current_user.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not available",
|
||||||
|
)
|
||||||
|
|
||||||
|
tts_records = await tts_service.get_user_tts_history(
|
||||||
|
user_id=current_user.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
TTSResponse(
|
||||||
|
id=tts.id,
|
||||||
|
text=tts.text,
|
||||||
|
provider=tts.provider,
|
||||||
|
options=tts.options,
|
||||||
|
status=tts.status,
|
||||||
|
error=tts.error,
|
||||||
|
sound_id=tts.sound_id,
|
||||||
|
user_id=tts.user_id,
|
||||||
|
created_at=tts.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
for tts in tts_records
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to get TTS history: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def generate_tts(
|
||||||
|
request: TTSGenerateRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Generate TTS audio and create sound."""
|
||||||
|
try:
|
||||||
|
if current_user.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not available",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await tts_service.create_tts_request(
|
||||||
|
text=request.text,
|
||||||
|
user_id=current_user.id,
|
||||||
|
provider=request.provider,
|
||||||
|
**request.options,
|
||||||
|
)
|
||||||
|
|
||||||
|
tts_record = result["tts"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": result["message"],
|
||||||
|
"tts": TTSResponse(
|
||||||
|
id=tts_record.id,
|
||||||
|
text=tts_record.text,
|
||||||
|
provider=tts_record.provider,
|
||||||
|
options=tts_record.options,
|
||||||
|
status=tts_record.status,
|
||||||
|
error=tts_record.error,
|
||||||
|
sound_id=tts_record.sound_id,
|
||||||
|
user_id=tts_record.user_id,
|
||||||
|
created_at=tts_record.created_at.isoformat(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to generate TTS: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers")
|
||||||
|
async def get_providers(
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
) -> dict[str, ProviderInfo]:
|
||||||
|
"""Get all available TTS providers."""
|
||||||
|
providers = tts_service.get_providers()
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for name, provider in providers.items():
|
||||||
|
result[name] = ProviderInfo(
|
||||||
|
name=provider.name,
|
||||||
|
file_extension=provider.file_extension,
|
||||||
|
supported_languages=provider.get_supported_languages(),
|
||||||
|
option_schema=provider.get_option_schema(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers/{provider_name}")
|
||||||
|
async def get_provider(
|
||||||
|
provider_name: str,
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
) -> ProviderInfo:
|
||||||
|
"""Get information about a specific TTS provider."""
|
||||||
|
provider = tts_service.get_provider(provider_name)
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Provider '{provider_name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProviderInfo(
|
||||||
|
name=provider.name,
|
||||||
|
file_extension=provider.file_extension,
|
||||||
|
supported_languages=provider.get_supported_languages(),
|
||||||
|
option_schema=provider.get_option_schema(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tts_id}")
|
||||||
|
async def delete_tts(
|
||||||
|
tts_id: int,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
|
||||||
|
tts_service: Annotated[TTSService, Depends(get_tts_service)],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Delete a TTS generation and its associated files."""
|
||||||
|
try:
|
||||||
|
if current_user.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not available",
|
||||||
|
)
|
||||||
|
|
||||||
|
await tts_service.delete_tts(tts_id=tts_id, user_id=current_user.id)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete TTS: {e!s}",
|
||||||
|
) from e
|
||||||
|
else:
|
||||||
|
return {"message": "TTS generation deleted successfully"}
|
||||||
@@ -23,7 +23,10 @@ class Settings(BaseSettings):
|
|||||||
BACKEND_URL: str = "http://localhost:8000" # Backend base URL
|
BACKEND_URL: str = "http://localhost:8000" # Backend base URL
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ORIGINS: list[str] = ["http://localhost:8001"] # Allowed origins for CORS
|
CORS_ORIGINS: list[str] = [
|
||||||
|
"http://localhost:8001", # Frontend development
|
||||||
|
"chrome-extension://*", # Chrome extensions
|
||||||
|
]
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL: str = "sqlite+aiosqlite:///data/soundboard.db"
|
DATABASE_URL: str = "sqlite+aiosqlite:///data/soundboard.db"
|
||||||
@@ -37,7 +40,9 @@ class Settings(BaseSettings):
|
|||||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production" # noqa: S105 default value if none set in .env
|
JWT_SECRET_KEY: str = (
|
||||||
|
"your-secret-key-change-in-production" # noqa: S105 default value if none set in .env
|
||||||
|
)
|
||||||
JWT_ALGORITHM: str = "HS256"
|
JWT_ALGORITHM: str = "HS256"
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import asyncio
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
from sqlmodel import SQLModel
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
# Import all models to ensure SQLModel metadata discovery
|
# Import all models to ensure SQLModel metadata discovery
|
||||||
import app.models # noqa: F401
|
import app.models # noqa: F401
|
||||||
|
from alembic import command
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.core.seeds import seed_all_data
|
|
||||||
|
|
||||||
engine: AsyncEngine = create_async_engine(
|
engine: AsyncEngine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
@@ -40,26 +41,23 @@ def get_session_factory() -> Callable[[], AsyncSession]:
|
|||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
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__)
|
logger = get_logger(__name__)
|
||||||
try:
|
try:
|
||||||
logger.info("Initializing database tables")
|
logger.info("Running database migrations")
|
||||||
async with engine.begin() as conn:
|
# Run Alembic migrations programmatically
|
||||||
await conn.run_sync(SQLModel.metadata.create_all)
|
|
||||||
logger.info("Database tables created successfully")
|
|
||||||
|
|
||||||
# Seed initial data
|
# Get the alembic config
|
||||||
await seed_initial_data()
|
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:
|
except Exception:
|
||||||
logger.exception("Failed to initialize database")
|
logger.exception("Failed to initialize database")
|
||||||
raise
|
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)
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.core.database import get_db
|
|||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.sound import SoundRepository
|
from app.repositories.sound import SoundRepository
|
||||||
|
from app.repositories.user import UserRepository
|
||||||
from app.services.auth import AuthService
|
from app.services.auth import AuthService
|
||||||
from app.services.dashboard import DashboardService
|
from app.services.dashboard import DashboardService
|
||||||
from app.services.oauth import OAuthService
|
from app.services.oauth import OAuthService
|
||||||
@@ -193,4 +194,5 @@ async def get_dashboard_service(
|
|||||||
) -> DashboardService:
|
) -> DashboardService:
|
||||||
"""Get the dashboard service."""
|
"""Get the dashboard service."""
|
||||||
sound_repository = SoundRepository(session)
|
sound_repository = SoundRepository(session)
|
||||||
return DashboardService(sound_repository)
|
user_repository = UserRepository(session)
|
||||||
|
return DashboardService(sound_repository, user_repository)
|
||||||
|
|||||||
17
app/main.py
17
app/main.py
@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.api import api_router
|
from app.api import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_session_factory, init_db
|
from app.core.database import get_session_factory
|
||||||
from app.core.logging import get_logger, setup_logging
|
from app.core.logging import get_logger, setup_logging
|
||||||
from app.core.services import app_services
|
from app.core.services import app_services
|
||||||
from app.middleware.logging import LoggingMiddleware
|
from app.middleware.logging import LoggingMiddleware
|
||||||
@@ -19,6 +19,7 @@ from app.services.player import (
|
|||||||
)
|
)
|
||||||
from app.services.scheduler import SchedulerService
|
from app.services.scheduler import SchedulerService
|
||||||
from app.services.socket import socket_manager
|
from app.services.socket import socket_manager
|
||||||
|
from app.services.tts_processor import tts_processor
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -28,13 +29,14 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
logger.info("Starting application")
|
logger.info("Starting application")
|
||||||
|
|
||||||
await init_db()
|
|
||||||
logger.info("Database initialized")
|
|
||||||
|
|
||||||
# Start the extraction processor
|
# Start the extraction processor
|
||||||
await extraction_processor.start()
|
await extraction_processor.start()
|
||||||
logger.info("Extraction processor started")
|
logger.info("Extraction processor started")
|
||||||
|
|
||||||
|
# Start the TTS processor
|
||||||
|
await tts_processor.start()
|
||||||
|
logger.info("TTS processor started")
|
||||||
|
|
||||||
# Start the player service
|
# Start the player service
|
||||||
await initialize_player_service(get_session_factory())
|
await initialize_player_service(get_session_factory())
|
||||||
logger.info("Player service started")
|
logger.info("Player service started")
|
||||||
@@ -43,7 +45,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
|
|||||||
try:
|
try:
|
||||||
player_service = get_player_service() # Get the initialized player service
|
player_service = get_player_service() # Get the initialized player service
|
||||||
app_services.scheduler_service = SchedulerService(
|
app_services.scheduler_service = SchedulerService(
|
||||||
get_session_factory(), player_service,
|
get_session_factory(),
|
||||||
|
player_service,
|
||||||
)
|
)
|
||||||
await app_services.scheduler_service.start()
|
await app_services.scheduler_service.start()
|
||||||
logger.info("Enhanced scheduler service started")
|
logger.info("Enhanced scheduler service started")
|
||||||
@@ -64,6 +67,10 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
|
|||||||
await shutdown_player_service()
|
await shutdown_player_service()
|
||||||
logger.info("Player service stopped")
|
logger.info("Player service stopped")
|
||||||
|
|
||||||
|
# Stop the TTS processor
|
||||||
|
await tts_processor.stop()
|
||||||
|
logger.info("TTS processor stopped")
|
||||||
|
|
||||||
# Stop the extraction processor
|
# Stop the extraction processor
|
||||||
await extraction_processor.stop()
|
await extraction_processor.stop()
|
||||||
logger.info("Extraction processor stopped")
|
logger.info("Extraction processor stopped")
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ from .playlist_sound import PlaylistSound
|
|||||||
from .scheduled_task import ScheduledTask
|
from .scheduled_task import ScheduledTask
|
||||||
from .sound import Sound
|
from .sound import Sound
|
||||||
from .sound_played import SoundPlayed
|
from .sound_played import SoundPlayed
|
||||||
|
from .tts import TTS
|
||||||
from .user import User
|
from .user import User
|
||||||
from .user_oauth import UserOauth
|
from .user_oauth import UserOauth
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"TTS",
|
||||||
"BaseModel",
|
"BaseModel",
|
||||||
"CreditAction",
|
"CreditAction",
|
||||||
"CreditTransaction",
|
"CreditTransaction",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class RecurrenceType(str, Enum):
|
|||||||
"""Recurrence patterns."""
|
"""Recurrence patterns."""
|
||||||
|
|
||||||
NONE = "none" # One-shot task
|
NONE = "none" # One-shot task
|
||||||
|
MINUTELY = "minutely"
|
||||||
HOURLY = "hourly"
|
HOURLY = "hourly"
|
||||||
DAILY = "daily"
|
DAILY = "daily"
|
||||||
WEEKLY = "weekly"
|
WEEKLY = "weekly"
|
||||||
|
|||||||
30
app/models/tts.py
Normal file
30
app/models/tts.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""TTS model."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Column
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class TTS(SQLModel, table=True):
|
||||||
|
"""Text-to-Speech generation record."""
|
||||||
|
|
||||||
|
__tablename__ = "tts"
|
||||||
|
|
||||||
|
id: int | None = Field(primary_key=True)
|
||||||
|
text: str = Field(max_length=1000, description="Text that was converted to speech")
|
||||||
|
provider: str = Field(max_length=50, description="TTS provider used")
|
||||||
|
options: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
sa_column=Column(JSON),
|
||||||
|
description="Provider-specific options used",
|
||||||
|
)
|
||||||
|
status: str = Field(default="pending", description="Processing status")
|
||||||
|
error: str | None = Field(default=None, description="Error message if failed")
|
||||||
|
sound_id: int | None = Field(
|
||||||
|
foreign_key="sound.id", description="Associated sound ID",
|
||||||
|
)
|
||||||
|
user_id: int = Field(foreign_key="user.id", description="User who created the TTS")
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
@@ -201,8 +201,11 @@ class SoundRepository(BaseRepository[Sound]):
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_soundboard_statistics(self) -> dict[str, int | float]:
|
async def get_soundboard_statistics(
|
||||||
"""Get statistics for SDB type sounds."""
|
self,
|
||||||
|
sound_type: str = "SDB",
|
||||||
|
) -> dict[str, int | float]:
|
||||||
|
"""Get statistics for sounds of a specific type."""
|
||||||
try:
|
try:
|
||||||
statement = select(
|
statement = select(
|
||||||
func.count(Sound.id).label("count"),
|
func.count(Sound.id).label("count"),
|
||||||
@@ -211,7 +214,7 @@ class SoundRepository(BaseRepository[Sound]):
|
|||||||
func.sum(
|
func.sum(
|
||||||
Sound.size + func.coalesce(Sound.normalized_size, 0),
|
Sound.size + func.coalesce(Sound.normalized_size, 0),
|
||||||
).label("total_size"),
|
).label("total_size"),
|
||||||
).where(Sound.type == "SDB")
|
).where(Sound.type == sound_type)
|
||||||
|
|
||||||
result = await self.session.exec(statement)
|
result = await self.session.exec(statement)
|
||||||
row = result.first()
|
row = result.first()
|
||||||
|
|||||||
74
app/repositories/tts.py
Normal file
74
app/repositories/tts.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""TTS repository for database operations."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.models.tts import TTS
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class TTSRepository(BaseRepository[TTS]):
|
||||||
|
"""Repository for TTS operations."""
|
||||||
|
|
||||||
|
def __init__(self, session: "AsyncSession") -> None:
|
||||||
|
"""Initialize TTS repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session for operations
|
||||||
|
|
||||||
|
"""
|
||||||
|
super().__init__(TTS, session)
|
||||||
|
|
||||||
|
async def get_by_user_id(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> Sequence[TTS]:
|
||||||
|
"""Get TTS records by user ID with pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to filter by
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
offset: Number of records to skip
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of TTS records
|
||||||
|
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(self.model.user_id == user_id)
|
||||||
|
.order_by(self.model.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
async def get_by_user_and_id(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
tts_id: int,
|
||||||
|
) -> TTS | None:
|
||||||
|
"""Get a specific TTS record by user ID and TTS ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to filter by
|
||||||
|
tts_id: TTS ID to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TTS record if found and belongs to user, None otherwise
|
||||||
|
|
||||||
|
"""
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.id == tts_id,
|
||||||
|
self.model.user_id == user_id,
|
||||||
|
)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
return result.first()
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
"""User repository."""
|
"""User repository."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import Select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
|
from app.models.credit_transaction import CreditTransaction
|
||||||
|
from app.models.extraction import Extraction
|
||||||
from app.models.plan import Plan
|
from app.models.plan import Plan
|
||||||
|
from app.models.playlist import Playlist
|
||||||
|
from app.models.sound_played import SoundPlayed
|
||||||
|
from app.models.tts import TTS
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.base import BaseRepository
|
from app.repositories.base import BaseRepository
|
||||||
|
|
||||||
@@ -217,3 +223,146 @@ class UserRepository(BaseRepository[User]):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to check if email exists: %s", email)
|
logger.exception("Failed to check if email exists: %s", email)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def get_top_users(
|
||||||
|
self,
|
||||||
|
metric_type: str,
|
||||||
|
date_filter: datetime | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get top users by different metrics."""
|
||||||
|
try:
|
||||||
|
query = self._build_top_users_query(metric_type, date_filter)
|
||||||
|
|
||||||
|
# Add ordering and limit
|
||||||
|
query = query.order_by(func.count().desc()).limit(limit)
|
||||||
|
|
||||||
|
result = await self.session.exec(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"name": row[1],
|
||||||
|
"count": int(row[2]),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to get top users for metric=%s, date_filter=%s",
|
||||||
|
metric_type,
|
||||||
|
date_filter,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _build_top_users_query(
|
||||||
|
self,
|
||||||
|
metric_type: str,
|
||||||
|
date_filter: datetime | None,
|
||||||
|
) -> Select:
|
||||||
|
"""Build query for top users based on metric type."""
|
||||||
|
match metric_type:
|
||||||
|
case "sounds_played":
|
||||||
|
query = self._build_sounds_played_query()
|
||||||
|
case "credits_used":
|
||||||
|
query = self._build_credits_used_query()
|
||||||
|
case "tracks_added":
|
||||||
|
query = self._build_tracks_added_query()
|
||||||
|
case "tts_added":
|
||||||
|
query = self._build_tts_added_query()
|
||||||
|
case "playlists_created":
|
||||||
|
query = self._build_playlists_created_query()
|
||||||
|
case _:
|
||||||
|
msg = f"Unknown metric type: {metric_type}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
# Apply date filter if provided
|
||||||
|
if date_filter:
|
||||||
|
query = self._apply_date_filter(query, metric_type, date_filter)
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
def _build_sounds_played_query(self) -> Select:
|
||||||
|
"""Build query for sounds played metric."""
|
||||||
|
return (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.name,
|
||||||
|
func.count(SoundPlayed.id).label("count"),
|
||||||
|
)
|
||||||
|
.join(SoundPlayed, User.id == SoundPlayed.user_id)
|
||||||
|
.group_by(User.id, User.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_credits_used_query(self) -> Select:
|
||||||
|
"""Build query for credits used metric."""
|
||||||
|
return (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.name,
|
||||||
|
func.sum(func.abs(CreditTransaction.amount)).label("count"),
|
||||||
|
)
|
||||||
|
.join(CreditTransaction, User.id == CreditTransaction.user_id)
|
||||||
|
.where(CreditTransaction.amount < 0)
|
||||||
|
.group_by(User.id, User.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_tracks_added_query(self) -> Select:
|
||||||
|
"""Build query for tracks added metric."""
|
||||||
|
return (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.name,
|
||||||
|
func.count(Extraction.id).label("count"),
|
||||||
|
)
|
||||||
|
.join(Extraction, User.id == Extraction.user_id)
|
||||||
|
.where(Extraction.sound_id.is_not(None))
|
||||||
|
.group_by(User.id, User.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_tts_added_query(self) -> Select:
|
||||||
|
"""Build query for TTS added metric."""
|
||||||
|
return (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.name,
|
||||||
|
func.count(TTS.id).label("count"),
|
||||||
|
)
|
||||||
|
.join(TTS, User.id == TTS.user_id)
|
||||||
|
.group_by(User.id, User.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_playlists_created_query(self) -> Select:
|
||||||
|
"""Build query for playlists created metric."""
|
||||||
|
return (
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.name,
|
||||||
|
func.count(Playlist.id).label("count"),
|
||||||
|
)
|
||||||
|
.join(Playlist, User.id == Playlist.user_id)
|
||||||
|
.group_by(User.id, User.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_date_filter(
|
||||||
|
self,
|
||||||
|
query: Select,
|
||||||
|
metric_type: str,
|
||||||
|
date_filter: datetime,
|
||||||
|
) -> Select:
|
||||||
|
"""Apply date filter to query based on metric type."""
|
||||||
|
match metric_type:
|
||||||
|
case "sounds_played":
|
||||||
|
return query.where(SoundPlayed.created_at >= date_filter)
|
||||||
|
case "credits_used":
|
||||||
|
return query.where(CreditTransaction.created_at >= date_filter)
|
||||||
|
case "tracks_added":
|
||||||
|
return query.where(Extraction.created_at >= date_filter)
|
||||||
|
case "tts_added":
|
||||||
|
return query.where(TTS.created_at >= date_filter)
|
||||||
|
case "playlists_created":
|
||||||
|
return query.where(Playlist.created_at >= date_filter)
|
||||||
|
case _:
|
||||||
|
return query
|
||||||
|
|||||||
@@ -49,3 +49,7 @@ class PlayerStateResponse(BaseModel):
|
|||||||
None,
|
None,
|
||||||
description="Current track index in playlist",
|
description="Current track index in playlist",
|
||||||
)
|
)
|
||||||
|
play_next_queue: list[dict[str, Any]] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Play next queue",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""User schemas."""
|
"""User schemas."""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
@@ -15,3 +15,13 @@ class UserUpdate(BaseModel):
|
|||||||
plan_id: int | None = Field(None, description="User plan ID")
|
plan_id: int | None = Field(None, description="User plan ID")
|
||||||
credits: int | None = Field(None, ge=0, description="User credits")
|
credits: int | None = Field(None, ge=0, description="User credits")
|
||||||
is_active: bool | None = Field(None, description="Whether user is active")
|
is_active: bool | None = Field(None, description="Whether user is active")
|
||||||
|
role: str | None = Field(None, description="User role (admin or user)")
|
||||||
|
|
||||||
|
@field_validator("role")
|
||||||
|
@classmethod
|
||||||
|
def validate_role(cls, v: str | None) -> str | None:
|
||||||
|
"""Validate that role is either 'user' or 'admin'."""
|
||||||
|
if v is not None and v not in {"user", "admin"}:
|
||||||
|
msg = "Role must be either 'user' or 'admin'"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|||||||
@@ -403,6 +403,44 @@ class CreditService:
|
|||||||
finally:
|
finally:
|
||||||
await session.close()
|
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(
|
async def recharge_user_credits(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -556,7 +594,15 @@ class CreditService:
|
|||||||
|
|
||||||
if transaction:
|
if transaction:
|
||||||
stats["recharged_users"] += 1
|
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:
|
else:
|
||||||
stats["skipped_users"] += 1
|
stats["skipped_users"] += 1
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.repositories.sound import SoundRepository
|
from app.repositories.sound import SoundRepository
|
||||||
|
from app.repositories.user import UserRepository
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -12,9 +13,14 @@ logger = get_logger(__name__)
|
|||||||
class DashboardService:
|
class DashboardService:
|
||||||
"""Service for dashboard statistics and analytics."""
|
"""Service for dashboard statistics and analytics."""
|
||||||
|
|
||||||
def __init__(self, sound_repository: SoundRepository) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
sound_repository: SoundRepository,
|
||||||
|
user_repository: UserRepository,
|
||||||
|
) -> None:
|
||||||
"""Initialize the dashboard service."""
|
"""Initialize the dashboard service."""
|
||||||
self.sound_repository = sound_repository
|
self.sound_repository = sound_repository
|
||||||
|
self.user_repository = user_repository
|
||||||
|
|
||||||
async def get_soundboard_statistics(self) -> dict[str, Any]:
|
async def get_soundboard_statistics(self) -> dict[str, Any]:
|
||||||
"""Get comprehensive soundboard statistics."""
|
"""Get comprehensive soundboard statistics."""
|
||||||
@@ -85,6 +91,55 @@ class DashboardService:
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def get_tts_statistics(self) -> dict[str, Any]:
|
||||||
|
"""Get comprehensive TTS statistics."""
|
||||||
|
try:
|
||||||
|
stats = await self.sound_repository.get_soundboard_statistics("TTS")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sound_count": stats["count"],
|
||||||
|
"total_play_count": stats["total_plays"],
|
||||||
|
"total_duration": stats["total_duration"],
|
||||||
|
"total_size": stats["total_size"],
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get TTS statistics")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_top_users(
|
||||||
|
self,
|
||||||
|
metric_type: str,
|
||||||
|
period: str = "all_time",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get top users by different metrics for a specific period."""
|
||||||
|
try:
|
||||||
|
# Calculate the date filter based on period
|
||||||
|
date_filter = self._get_date_filter(period)
|
||||||
|
|
||||||
|
# Get top users from repository
|
||||||
|
top_users = await self.user_repository.get_top_users(
|
||||||
|
metric_type=metric_type,
|
||||||
|
date_filter=date_filter,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": user["id"],
|
||||||
|
"name": user["name"],
|
||||||
|
"count": user["count"],
|
||||||
|
}
|
||||||
|
for user in top_users
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to get top users for metric=%s, period=%s",
|
||||||
|
metric_type,
|
||||||
|
period,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
def _get_date_filter(self, period: str) -> datetime | None: # noqa: PLR0911
|
def _get_date_filter(self, period: str) -> datetime | None: # noqa: PLR0911
|
||||||
"""Calculate the date filter based on the period."""
|
"""Calculate the date filter based on the period."""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from enum import Enum
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import vlc # type: ignore[import-untyped]
|
import vlc # type: ignore[import-untyped]
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.models.playlist import Playlist
|
from app.models.playlist import Playlist
|
||||||
@@ -16,6 +18,7 @@ from app.models.sound_played import SoundPlayed
|
|||||||
from app.repositories.playlist import PlaylistRepository
|
from app.repositories.playlist import PlaylistRepository
|
||||||
from app.repositories.sound import SoundRepository
|
from app.repositories.sound import SoundRepository
|
||||||
from app.services.socket import socket_manager
|
from app.services.socket import socket_manager
|
||||||
|
from app.services.volume import volume_service
|
||||||
from app.utils.audio import get_sound_file_path
|
from app.utils.audio import get_sound_file_path
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -46,8 +49,11 @@ class PlayerState:
|
|||||||
"""Initialize player state."""
|
"""Initialize player state."""
|
||||||
self.status: PlayerStatus = PlayerStatus.STOPPED
|
self.status: PlayerStatus = PlayerStatus.STOPPED
|
||||||
self.mode: PlayerMode = PlayerMode.CONTINUOUS
|
self.mode: PlayerMode = PlayerMode.CONTINUOUS
|
||||||
self.volume: int = 80
|
|
||||||
self.previous_volume: int = 80
|
# Initialize volume from host system or default to 80
|
||||||
|
host_volume = volume_service.get_volume()
|
||||||
|
self.volume: int = host_volume if host_volume is not None else 80
|
||||||
|
self.previous_volume: int = self.volume
|
||||||
self.current_sound_id: int | None = None
|
self.current_sound_id: int | None = None
|
||||||
self.current_sound_index: int | None = None
|
self.current_sound_index: int | None = None
|
||||||
self.current_sound_position: int = 0
|
self.current_sound_position: int = 0
|
||||||
@@ -58,6 +64,8 @@ class PlayerState:
|
|||||||
self.playlist_length: int = 0
|
self.playlist_length: int = 0
|
||||||
self.playlist_duration: int = 0
|
self.playlist_duration: int = 0
|
||||||
self.playlist_sounds: list[Sound] = []
|
self.playlist_sounds: list[Sound] = []
|
||||||
|
self.play_next_queue: list[Sound] = []
|
||||||
|
self.playlist_index_before_play_next: int | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Convert player state to dictionary for serialization."""
|
"""Convert player state to dictionary for serialization."""
|
||||||
@@ -83,6 +91,9 @@ class PlayerState:
|
|||||||
if self.playlist_id
|
if self.playlist_id
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"play_next_queue": [
|
||||||
|
self._serialize_sound(sound) for sound in self.play_next_queue
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None:
|
def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None:
|
||||||
@@ -153,8 +164,8 @@ class PlayerService:
|
|||||||
)
|
)
|
||||||
self._position_thread.start()
|
self._position_thread.start()
|
||||||
|
|
||||||
# Set initial volume
|
# Set VLC to 100% volume - host volume is controlled separately
|
||||||
self._player.audio_set_volume(self.state.volume)
|
self._player.audio_set_volume(100)
|
||||||
|
|
||||||
logger.info("Player service started")
|
logger.info("Player service started")
|
||||||
|
|
||||||
@@ -338,6 +349,31 @@ class PlayerService:
|
|||||||
|
|
||||||
async def next(self) -> None:
|
async def next(self) -> None:
|
||||||
"""Skip to next track."""
|
"""Skip to next track."""
|
||||||
|
# Check if there's a track in the play_next queue
|
||||||
|
if self.state.play_next_queue:
|
||||||
|
await self._play_next_from_queue()
|
||||||
|
return
|
||||||
|
|
||||||
|
# If currently playing from play_next queue (no index but have stored index)
|
||||||
|
if (
|
||||||
|
self.state.current_sound_index is None
|
||||||
|
and self.state.playlist_index_before_play_next is not None
|
||||||
|
and self.state.playlist_sounds
|
||||||
|
):
|
||||||
|
# Skipped the last play_next track, go to next in playlist
|
||||||
|
restored_index = self.state.playlist_index_before_play_next
|
||||||
|
next_index = self._get_next_index(restored_index)
|
||||||
|
|
||||||
|
# Clear the stored index
|
||||||
|
self.state.playlist_index_before_play_next = None
|
||||||
|
|
||||||
|
if next_index is not None:
|
||||||
|
await self.play(next_index)
|
||||||
|
else:
|
||||||
|
await self._stop_playback()
|
||||||
|
await self._broadcast_state()
|
||||||
|
return
|
||||||
|
|
||||||
if not self.state.playlist_sounds:
|
if not self.state.playlist_sounds:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -378,7 +414,7 @@ class PlayerService:
|
|||||||
logger.debug("Seeked to position: %sms", position_ms)
|
logger.debug("Seeked to position: %sms", position_ms)
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> None:
|
async def set_volume(self, volume: int) -> None:
|
||||||
"""Set playback volume (0-100)."""
|
"""Set playback volume (0-100) by controlling host system volume."""
|
||||||
volume = max(0, min(100, volume)) # Clamp to valid range
|
volume = max(0, min(100, volume)) # Clamp to valid range
|
||||||
|
|
||||||
# Store previous volume when muting (going from >0 to 0)
|
# Store previous volume when muting (going from >0 to 0)
|
||||||
@@ -386,18 +422,30 @@ class PlayerService:
|
|||||||
self.state.previous_volume = self.state.volume
|
self.state.previous_volume = self.state.volume
|
||||||
|
|
||||||
self.state.volume = volume
|
self.state.volume = volume
|
||||||
self._player.audio_set_volume(volume)
|
|
||||||
|
# Control host system volume instead of VLC volume
|
||||||
|
if volume == 0:
|
||||||
|
# Mute the host system
|
||||||
|
volume_service.set_mute(muted=True)
|
||||||
|
else:
|
||||||
|
# Unmute and set host volume
|
||||||
|
if volume_service.is_muted():
|
||||||
|
volume_service.set_mute(muted=False)
|
||||||
|
volume_service.set_volume(volume)
|
||||||
|
|
||||||
|
# Keep VLC at 100% volume
|
||||||
|
self._player.audio_set_volume(100)
|
||||||
|
|
||||||
await self._broadcast_state()
|
await self._broadcast_state()
|
||||||
logger.debug("Volume set to: %s", volume)
|
logger.debug("Host volume set to: %s", volume)
|
||||||
|
|
||||||
async def mute(self) -> None:
|
async def mute(self) -> None:
|
||||||
"""Mute the player (stores current volume as previous_volume)."""
|
"""Mute the host system (stores current volume as previous_volume)."""
|
||||||
if self.state.volume > 0:
|
if self.state.volume > 0:
|
||||||
await self.set_volume(0)
|
await self.set_volume(0)
|
||||||
|
|
||||||
async def unmute(self) -> None:
|
async def unmute(self) -> None:
|
||||||
"""Unmute the player (restores previous_volume)."""
|
"""Unmute the host system (restores previous_volume)."""
|
||||||
if self.state.volume == 0 and self.state.previous_volume > 0:
|
if self.state.volume == 0 and self.state.previous_volume > 0:
|
||||||
await self.set_volume(self.state.previous_volume)
|
await self.set_volume(self.state.previous_volume)
|
||||||
|
|
||||||
@@ -415,6 +463,66 @@ class PlayerService:
|
|||||||
await self._broadcast_state()
|
await self._broadcast_state()
|
||||||
logger.info("Playback mode set to: %s", mode.value)
|
logger.info("Playback mode set to: %s", mode.value)
|
||||||
|
|
||||||
|
async def add_to_play_next(self, sound_id: int) -> None:
|
||||||
|
"""Add a sound to the play_next queue."""
|
||||||
|
session = self.db_session_factory()
|
||||||
|
try:
|
||||||
|
# Eagerly load extractions to avoid lazy loading issues
|
||||||
|
statement = select(Sound).where(Sound.id == sound_id)
|
||||||
|
statement = statement.options(selectinload(Sound.extractions)) # type: ignore[arg-type]
|
||||||
|
result = await session.exec(statement)
|
||||||
|
sound = result.first()
|
||||||
|
|
||||||
|
if not sound:
|
||||||
|
logger.warning("Sound %s not found for play_next", sound_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.state.play_next_queue.append(sound)
|
||||||
|
await self._broadcast_state()
|
||||||
|
logger.info("Added sound %s to play_next queue", sound.name)
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
async def _play_next_from_queue(self) -> None:
|
||||||
|
"""Play the first track from the play_next queue."""
|
||||||
|
if not self.state.play_next_queue:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store current playlist index before switching to play_next track
|
||||||
|
# Only store if we're currently playing from the playlist
|
||||||
|
if (
|
||||||
|
self.state.current_sound_index is not None
|
||||||
|
and self.state.playlist_index_before_play_next is None
|
||||||
|
):
|
||||||
|
self.state.playlist_index_before_play_next = (
|
||||||
|
self.state.current_sound_index
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Stored playlist index %s before playing from play_next queue",
|
||||||
|
self.state.playlist_index_before_play_next,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the first sound from the queue
|
||||||
|
next_sound = self.state.play_next_queue.pop(0)
|
||||||
|
|
||||||
|
# Stop current playback and process play count
|
||||||
|
if self.state.status != PlayerStatus.STOPPED:
|
||||||
|
await self._stop_playback()
|
||||||
|
|
||||||
|
# Set the sound as current (without index since it's from play_next)
|
||||||
|
self.state.current_sound = next_sound
|
||||||
|
self.state.current_sound_id = next_sound.id
|
||||||
|
self.state.current_sound_index = None # No index for play_next tracks
|
||||||
|
|
||||||
|
# Play the sound
|
||||||
|
if not self._validate_sound_file():
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._load_and_play_media():
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._handle_successful_playback()
|
||||||
|
|
||||||
async def reload_playlist(self) -> None:
|
async def reload_playlist(self) -> None:
|
||||||
"""Reload current playlist from database."""
|
"""Reload current playlist from database."""
|
||||||
session = self.db_session_factory()
|
session = self.db_session_factory()
|
||||||
@@ -503,6 +611,16 @@ class PlayerService:
|
|||||||
current_id,
|
current_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clear play_next queue when playlist changes
|
||||||
|
if self.state.play_next_queue:
|
||||||
|
logger.info("Clearing play_next queue due to playlist change")
|
||||||
|
self.state.play_next_queue.clear()
|
||||||
|
|
||||||
|
# Clear stored playlist index
|
||||||
|
if self.state.playlist_index_before_play_next is not None:
|
||||||
|
logger.info("Clearing stored playlist index due to playlist change")
|
||||||
|
self.state.playlist_index_before_play_next = None
|
||||||
|
|
||||||
if self.state.status != PlayerStatus.STOPPED:
|
if self.state.status != PlayerStatus.STOPPED:
|
||||||
await self._stop_playback()
|
await self._stop_playback()
|
||||||
|
|
||||||
@@ -518,6 +636,9 @@ class PlayerService:
|
|||||||
sounds: list[Sound],
|
sounds: list[Sound],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle track checking when playlist ID is the same."""
|
"""Handle track checking when playlist ID is the same."""
|
||||||
|
# Remove tracks from play_next queue that are no longer in the playlist
|
||||||
|
self._clean_play_next_queue(sounds)
|
||||||
|
|
||||||
# Find the current track in the new playlist
|
# Find the current track in the new playlist
|
||||||
new_index = self._find_sound_index(previous_sound_id, sounds)
|
new_index = self._find_sound_index(previous_sound_id, sounds)
|
||||||
|
|
||||||
@@ -575,6 +696,29 @@ class PlayerService:
|
|||||||
return i
|
return i
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _clean_play_next_queue(self, playlist_sounds: list[Sound]) -> None:
|
||||||
|
"""Remove tracks from play_next queue that are no longer in the playlist."""
|
||||||
|
if not self.state.play_next_queue:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get IDs of all sounds in the current playlist
|
||||||
|
playlist_sound_ids = {sound.id for sound in playlist_sounds}
|
||||||
|
|
||||||
|
# Filter out tracks that are no longer in the playlist
|
||||||
|
original_length = len(self.state.play_next_queue)
|
||||||
|
self.state.play_next_queue = [
|
||||||
|
sound
|
||||||
|
for sound in self.state.play_next_queue
|
||||||
|
if sound.id in playlist_sound_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
removed_count = original_length - len(self.state.play_next_queue)
|
||||||
|
if removed_count > 0:
|
||||||
|
logger.info(
|
||||||
|
"Removed %s track(s) from play_next queue (no longer in playlist)",
|
||||||
|
removed_count,
|
||||||
|
)
|
||||||
|
|
||||||
def _set_first_track_as_current(self, sounds: list[Sound]) -> None:
|
def _set_first_track_as_current(self, sounds: list[Sound]) -> None:
|
||||||
"""Set the first track as the current track."""
|
"""Set the first track as the current track."""
|
||||||
self.state.current_sound_index = 0
|
self.state.current_sound_index = 0
|
||||||
@@ -764,7 +908,12 @@ class PlayerService:
|
|||||||
"""Handle when a track finishes playing."""
|
"""Handle when a track finishes playing."""
|
||||||
await self._process_play_count()
|
await self._process_play_count()
|
||||||
|
|
||||||
# Auto-advance to next track
|
# Check if there's a track in the play_next queue
|
||||||
|
if self.state.play_next_queue:
|
||||||
|
await self._play_next_from_queue()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Auto-advance to next track in playlist
|
||||||
if self.state.current_sound_index is not None:
|
if self.state.current_sound_index is not None:
|
||||||
next_index = self._get_next_index(self.state.current_sound_index)
|
next_index = self._get_next_index(self.state.current_sound_index)
|
||||||
if next_index is not None:
|
if next_index is not None:
|
||||||
@@ -772,6 +921,32 @@ class PlayerService:
|
|||||||
else:
|
else:
|
||||||
await self._stop_playback()
|
await self._stop_playback()
|
||||||
await self._broadcast_state()
|
await self._broadcast_state()
|
||||||
|
elif (
|
||||||
|
self.state.playlist_sounds
|
||||||
|
and self.state.playlist_index_before_play_next is not None
|
||||||
|
):
|
||||||
|
# Current track was from play_next queue, restore to next track in playlist
|
||||||
|
restored_index = self.state.playlist_index_before_play_next
|
||||||
|
logger.info(
|
||||||
|
"Play next queue finished, continuing from playlist index %s",
|
||||||
|
restored_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the next index based on the stored position
|
||||||
|
next_index = self._get_next_index(restored_index)
|
||||||
|
|
||||||
|
# Clear the stored index since we're done with play_next queue
|
||||||
|
self.state.playlist_index_before_play_next = None
|
||||||
|
|
||||||
|
if next_index is not None:
|
||||||
|
await self.play(next_index)
|
||||||
|
else:
|
||||||
|
# No next track (end of playlist in non-loop mode)
|
||||||
|
await self._stop_playback()
|
||||||
|
await self._broadcast_state()
|
||||||
|
else:
|
||||||
|
await self._stop_playback()
|
||||||
|
await self._broadcast_state()
|
||||||
|
|
||||||
async def _broadcast_state(self) -> None:
|
async def _broadcast_state(self) -> None:
|
||||||
"""Broadcast current player state via WebSocket."""
|
"""Broadcast current player state via WebSocket."""
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
from typing import Any, TypedDict
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.models.playlist import Playlist
|
from app.models.playlist import Playlist
|
||||||
|
from app.models.playlist_sound import PlaylistSound
|
||||||
from app.models.sound import Sound
|
from app.models.sound import Sound
|
||||||
from app.repositories.playlist import PlaylistRepository, PlaylistSortField, SortOrder
|
from app.repositories.playlist import PlaylistRepository, PlaylistSortField, SortOrder
|
||||||
from app.repositories.sound import SoundRepository
|
from app.repositories.sound import SoundRepository
|
||||||
@@ -231,11 +233,23 @@ class PlaylistService:
|
|||||||
# Check if this was the current playlist before deleting
|
# Check if this was the current playlist before deleting
|
||||||
was_current = playlist.is_current
|
was_current = playlist.is_current
|
||||||
|
|
||||||
|
# First, delete all playlist_sound relationships
|
||||||
|
await self._delete_playlist_sounds(playlist_id)
|
||||||
|
|
||||||
|
# Then delete the playlist itself
|
||||||
await self.playlist_repo.delete(playlist)
|
await self.playlist_repo.delete(playlist)
|
||||||
logger.info("Deleted playlist %s for user %s", playlist_id, user_id)
|
logger.info("Deleted playlist %s for user %s", playlist_id, user_id)
|
||||||
|
|
||||||
# If the deleted playlist was current, reload player to use main fallback
|
# If the deleted playlist was current, set main playlist as current
|
||||||
if was_current:
|
if was_current:
|
||||||
|
main_playlist = await self.get_main_playlist()
|
||||||
|
await self.playlist_repo.update(main_playlist, {"is_current": True})
|
||||||
|
logger.info(
|
||||||
|
"Set main playlist as current after deleting current playlist %s",
|
||||||
|
playlist_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reload player to reflect the change
|
||||||
await _reload_player_playlist()
|
await _reload_player_playlist()
|
||||||
|
|
||||||
async def search_playlists(self, query: str, user_id: int) -> list[Playlist]:
|
async def search_playlists(self, query: str, user_id: int) -> list[Playlist]:
|
||||||
@@ -539,6 +553,24 @@ class PlaylistService:
|
|||||||
# Reload player playlist to reflect the change (will fallback to main)
|
# Reload player playlist to reflect the change (will fallback to main)
|
||||||
await _reload_player_playlist()
|
await _reload_player_playlist()
|
||||||
|
|
||||||
|
async def _delete_playlist_sounds(self, playlist_id: int) -> None:
|
||||||
|
"""Delete all playlist_sound records for a given playlist."""
|
||||||
|
# Get all playlist_sound records for this playlist
|
||||||
|
stmt = select(PlaylistSound).where(PlaylistSound.playlist_id == playlist_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
playlist_sounds = result.all()
|
||||||
|
|
||||||
|
# Delete each playlist_sound record
|
||||||
|
for playlist_sound in playlist_sounds:
|
||||||
|
await self.session.delete(playlist_sound)
|
||||||
|
|
||||||
|
await self.session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Deleted %d playlist_sound records for playlist %s",
|
||||||
|
len(playlist_sounds),
|
||||||
|
playlist_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def _unset_current_playlist(self) -> None:
|
async def _unset_current_playlist(self) -> None:
|
||||||
"""Unset any current playlist globally."""
|
"""Unset any current playlist globally."""
|
||||||
current_playlist = await self.playlist_repo.get_current_playlist()
|
current_playlist = await self.playlist_repo.get_current_playlist()
|
||||||
|
|||||||
@@ -144,6 +144,25 @@ class SchedulerService:
|
|||||||
logger.info("Cancelled task: %s (%s)", task.name, task_id)
|
logger.info("Cancelled task: %s (%s)", task.name, task_id)
|
||||||
return True
|
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(
|
async def get_user_tasks(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -267,6 +286,7 @@ class SchedulerService:
|
|||||||
|
|
||||||
# Handle interval-based recurrence types
|
# Handle interval-based recurrence types
|
||||||
interval_configs = {
|
interval_configs = {
|
||||||
|
RecurrenceType.MINUTELY: {"minutes": 1},
|
||||||
RecurrenceType.HOURLY: {"hours": 1},
|
RecurrenceType.HOURLY: {"hours": 1},
|
||||||
RecurrenceType.DAILY: {"days": 1},
|
RecurrenceType.DAILY: {"days": 1},
|
||||||
RecurrenceType.WEEKLY: {"weeks": 1},
|
RecurrenceType.WEEKLY: {"weeks": 1},
|
||||||
@@ -301,6 +321,8 @@ class SchedulerService:
|
|||||||
"""Execute a scheduled task."""
|
"""Execute a scheduled task."""
|
||||||
task_id_str = str(task_id)
|
task_id_str = str(task_id)
|
||||||
|
|
||||||
|
logger.info("APScheduler triggered task %s execution", task_id)
|
||||||
|
|
||||||
# Prevent concurrent execution of the same task
|
# Prevent concurrent execution of the same task
|
||||||
if task_id_str in self._running_tasks:
|
if task_id_str in self._running_tasks:
|
||||||
logger.warning("Task %s is already running, skipping execution", task_id)
|
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)
|
logger.warning("Task %s not found", task_id)
|
||||||
return
|
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
|
# Check if task is still active and pending
|
||||||
if not task.is_active or task.status != TaskStatus.PENDING:
|
if not task.is_active or task.status != TaskStatus.PENDING:
|
||||||
logger.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
|
return
|
||||||
|
|
||||||
# Check if task has expired
|
# Check if task has expired
|
||||||
@@ -333,6 +367,11 @@ class SchedulerService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Mark task as running
|
# Mark task as running
|
||||||
|
logger.info(
|
||||||
|
"Task %s starting execution (type: %s)",
|
||||||
|
task_id,
|
||||||
|
task.recurrence_type,
|
||||||
|
)
|
||||||
await repo.mark_as_running(task)
|
await repo.mark_as_running(task)
|
||||||
|
|
||||||
# Execute the task
|
# Execute the task
|
||||||
@@ -345,19 +384,41 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
await handler_registry.execute_task(task)
|
await handler_registry.execute_task(task)
|
||||||
|
|
||||||
# Calculate next execution time for recurring tasks
|
# Handle completion based on task type
|
||||||
next_execution_at = None
|
if task.recurrence_type == RecurrenceType.CRON:
|
||||||
if task.should_repeat():
|
# For CRON tasks, update execution metadata but keep PENDING
|
||||||
next_execution_at = self._calculate_next_execution(task)
|
# 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
|
# Mark as completed
|
||||||
await repo.mark_as_completed(task, next_execution_at)
|
await repo.mark_as_completed(task, next_execution_at)
|
||||||
|
|
||||||
# Reschedule if recurring
|
# Reschedule if recurring
|
||||||
if next_execution_at and task.should_repeat():
|
if next_execution_at and task.should_repeat():
|
||||||
# Refresh task to get updated data
|
# Refresh task to get updated data
|
||||||
await session.refresh(task)
|
await session.refresh(task)
|
||||||
await self._schedule_apscheduler_job(task)
|
await self._schedule_apscheduler_job(task)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await repo.mark_as_failed(task, str(e))
|
await repo.mark_as_failed(task, str(e))
|
||||||
@@ -370,17 +431,21 @@ class SchedulerService:
|
|||||||
"""Calculate the next execution time for a recurring task."""
|
"""Calculate the next execution time for a recurring task."""
|
||||||
now = datetime.now(tz=UTC)
|
now = datetime.now(tz=UTC)
|
||||||
|
|
||||||
if task.recurrence_type == RecurrenceType.HOURLY:
|
recurrence_deltas = {
|
||||||
return now + timedelta(hours=1)
|
RecurrenceType.MINUTELY: timedelta(minutes=1),
|
||||||
if task.recurrence_type == RecurrenceType.DAILY:
|
RecurrenceType.HOURLY: timedelta(hours=1),
|
||||||
return now + timedelta(days=1)
|
RecurrenceType.DAILY: timedelta(days=1),
|
||||||
if task.recurrence_type == RecurrenceType.WEEKLY:
|
RecurrenceType.WEEKLY: timedelta(weeks=1),
|
||||||
return now + timedelta(weeks=1)
|
RecurrenceType.MONTHLY: timedelta(days=30), # Approximate
|
||||||
if task.recurrence_type == RecurrenceType.MONTHLY:
|
RecurrenceType.YEARLY: timedelta(days=365), # Approximate
|
||||||
# Add approximately one month
|
}
|
||||||
return now + timedelta(days=30)
|
|
||||||
if task.recurrence_type == RecurrenceType.YEARLY:
|
if task.recurrence_type in recurrence_deltas:
|
||||||
return now + timedelta(days=365)
|
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
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,19 @@ class TaskHandlerRegistry:
|
|||||||
msg = f"Invalid user_id format: {user_id}"
|
msg = f"Invalid user_id format: {user_id}"
|
||||||
raise TaskExecutionError(msg) from e
|
raise TaskExecutionError(msg) from e
|
||||||
|
|
||||||
stats = await self.credit_service.recharge_user_credits(user_id_int)
|
transaction = await self.credit_service.recharge_user_credits_auto(
|
||||||
logger.info("Recharged credits for user %s: %s", user_id, stats)
|
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:
|
else:
|
||||||
# Recharge all users (system task)
|
# Recharge all users (system task)
|
||||||
stats = await self.credit_service.recharge_all_users_credits()
|
stats = await self.credit_service.recharge_all_users_credits()
|
||||||
|
|||||||
6
app/services/tts/__init__.py
Normal file
6
app/services/tts/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Text-to-Speech services package."""
|
||||||
|
|
||||||
|
from .base import TTSProvider
|
||||||
|
from .service import TTSService
|
||||||
|
|
||||||
|
__all__ = ["TTSProvider", "TTSService"]
|
||||||
41
app/services/tts/base.py
Normal file
41
app/services/tts/base.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Base TTS provider interface."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
# Type alias for TTS options
|
||||||
|
TTSOptions = dict[str, str | bool | int | float]
|
||||||
|
|
||||||
|
|
||||||
|
class TTSProvider(ABC):
|
||||||
|
"""Abstract base class for TTS providers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def generate_speech(self, text: str, **options: str | bool | float) -> bytes:
|
||||||
|
"""Generate speech from text with provider-specific options.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to convert to speech
|
||||||
|
**options: Provider-specific options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Audio data as bytes
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_supported_languages(self) -> list[str]:
|
||||||
|
"""Return list of supported language codes."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_option_schema(self) -> dict[str, dict[str, str | list[str] | bool]]:
|
||||||
|
"""Return schema for provider-specific options."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the provider name."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
"""Return the default file extension for this provider."""
|
||||||
5
app/services/tts/providers/__init__.py
Normal file
5
app/services/tts/providers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""TTS providers package."""
|
||||||
|
|
||||||
|
from .gtts import GTTSProvider
|
||||||
|
|
||||||
|
__all__ = ["GTTSProvider"]
|
||||||
80
app/services/tts/providers/gtts.py
Normal file
80
app/services/tts/providers/gtts.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Google Text-to-Speech provider."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
|
||||||
|
from gtts import gTTS
|
||||||
|
|
||||||
|
from app.services.tts.base import TTSProvider
|
||||||
|
|
||||||
|
|
||||||
|
class GTTSProvider(TTSProvider):
|
||||||
|
"""Google Text-to-Speech provider implementation."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the provider name."""
|
||||||
|
return "gtts"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
"""Return the default file extension for this provider."""
|
||||||
|
return "mp3"
|
||||||
|
|
||||||
|
async def generate_speech(self, text: str, **options: str | bool | float) -> bytes:
|
||||||
|
"""Generate speech from text using Google TTS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to convert to speech
|
||||||
|
**options: GTTS-specific options (lang, tld, slow)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MP3 audio data as bytes
|
||||||
|
|
||||||
|
"""
|
||||||
|
lang = options.get("lang", "en")
|
||||||
|
tld = options.get("tld", "com")
|
||||||
|
slow = options.get("slow", False)
|
||||||
|
|
||||||
|
# Run TTS generation in thread pool since gTTS is synchronous
|
||||||
|
def _generate() -> bytes:
|
||||||
|
tts = gTTS(text=text, lang=lang, tld=tld, slow=slow)
|
||||||
|
fp = io.BytesIO()
|
||||||
|
tts.write_to_fp(fp)
|
||||||
|
fp.seek(0)
|
||||||
|
return fp.read()
|
||||||
|
|
||||||
|
# Use asyncio.to_thread which is more reliable than run_in_executor
|
||||||
|
return await asyncio.to_thread(_generate)
|
||||||
|
|
||||||
|
def get_supported_languages(self) -> list[str]:
|
||||||
|
"""Return list of supported language codes."""
|
||||||
|
# Complete list of GTTS supported languages including regional variants
|
||||||
|
return [
|
||||||
|
"af", "ar", "bg", "bn", "bs", "ca", "cs", "cy", "da", "de", "el",
|
||||||
|
"en", "en-au", "en-ca", "en-gb", "en-ie", "en-in", "en-ng", "en-nz",
|
||||||
|
"en-ph", "en-za", "en-tz", "en-uk", "en-us",
|
||||||
|
"eo", "es", "es-es", "es-mx", "es-us", "et", "eu", "fa", "fi",
|
||||||
|
"fr", "fr-ca", "fr-fr", "ga", "gu", "he", "hi", "hr", "hu", "hy",
|
||||||
|
"id", "is", "it", "ja", "jw", "ka", "kk", "km", "kn", "ko", "la",
|
||||||
|
"lv", "mk", "ml", "mr", "ms", "mt", "my", "ne", "nl", "no", "pa",
|
||||||
|
"pl", "pt", "pt-br", "pt-pt", "ro", "ru", "si", "sk", "sl", "sq",
|
||||||
|
"sr", "su", "sv", "sw", "ta", "te", "th", "tl", "tr", "uk", "ur",
|
||||||
|
"vi", "yo", "zh", "zh-cn", "zh-tw", "zu",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_option_schema(self) -> dict[str, dict[str, str | list[str] | bool]]:
|
||||||
|
"""Return schema for GTTS-specific options."""
|
||||||
|
return {
|
||||||
|
"lang": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "en",
|
||||||
|
"description": "Language code",
|
||||||
|
"enum": self.get_supported_languages(),
|
||||||
|
},
|
||||||
|
"slow": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Speak slowly",
|
||||||
|
},
|
||||||
|
}
|
||||||
555
app/services/tts/service.py
Normal file
555
app/services/tts/service.py
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
"""TTS service implementation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gtts import gTTS
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_session_factory
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.sound import Sound
|
||||||
|
from app.models.tts import TTS
|
||||||
|
from app.repositories.sound import SoundRepository
|
||||||
|
from app.repositories.tts import TTSRepository
|
||||||
|
from app.services.socket import socket_manager
|
||||||
|
from app.services.sound_normalizer import SoundNormalizerService
|
||||||
|
from app.utils.audio import get_audio_duration, get_file_hash, get_file_size
|
||||||
|
|
||||||
|
from .base import TTSProvider
|
||||||
|
from .providers import GTTSProvider
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
MAX_TEXT_LENGTH = 1000
|
||||||
|
MAX_NAME_LENGTH = 50
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_tts_processor() -> object:
|
||||||
|
"""Get TTS processor instance, avoiding circular import."""
|
||||||
|
from app.services.tts_processor import tts_processor # noqa: PLC0415
|
||||||
|
return tts_processor
|
||||||
|
|
||||||
|
|
||||||
|
class TTSService:
|
||||||
|
"""Text-to-Speech service with provider management."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
"""Initialize TTS service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
self.sound_repo = SoundRepository(session)
|
||||||
|
self.tts_repo = TTSRepository(session)
|
||||||
|
self.providers: dict[str, TTSProvider] = {}
|
||||||
|
|
||||||
|
# Register default providers
|
||||||
|
self._register_default_providers()
|
||||||
|
|
||||||
|
def _register_default_providers(self) -> None:
|
||||||
|
"""Register default TTS providers."""
|
||||||
|
self.register_provider(GTTSProvider())
|
||||||
|
|
||||||
|
def register_provider(self, provider: TTSProvider) -> None:
|
||||||
|
"""Register a TTS provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: TTS provider instance
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.providers[provider.name] = provider
|
||||||
|
|
||||||
|
def get_providers(self) -> dict[str, TTSProvider]:
|
||||||
|
"""Get all registered providers."""
|
||||||
|
return self.providers.copy()
|
||||||
|
|
||||||
|
def get_provider(self, name: str) -> TTSProvider | None:
|
||||||
|
"""Get a specific provider by name."""
|
||||||
|
return self.providers.get(name)
|
||||||
|
|
||||||
|
async def create_tts_request(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
user_id: int,
|
||||||
|
provider: str = "gtts",
|
||||||
|
**options: str | bool | float,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a TTS request that will be processed in the background.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to convert to speech
|
||||||
|
user_id: ID of user creating the sound
|
||||||
|
provider: TTS provider name
|
||||||
|
**options: Provider-specific options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with TTS record information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If provider not found or text too long
|
||||||
|
Exception: If request creation fails
|
||||||
|
|
||||||
|
"""
|
||||||
|
provider_not_found_msg = f"Provider '{provider}' not found"
|
||||||
|
if provider not in self.providers:
|
||||||
|
raise ValueError(provider_not_found_msg)
|
||||||
|
|
||||||
|
text_too_long_msg = f"Text too long (max {MAX_TEXT_LENGTH} characters)"
|
||||||
|
if len(text) > MAX_TEXT_LENGTH:
|
||||||
|
raise ValueError(text_too_long_msg)
|
||||||
|
|
||||||
|
empty_text_msg = "Text cannot be empty"
|
||||||
|
if not text.strip():
|
||||||
|
raise ValueError(empty_text_msg)
|
||||||
|
|
||||||
|
# Create TTS record with pending status
|
||||||
|
tts = TTS(
|
||||||
|
text=text,
|
||||||
|
provider=provider,
|
||||||
|
options=options,
|
||||||
|
status="pending",
|
||||||
|
sound_id=None, # Will be set when processing completes
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
self.session.add(tts)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(tts)
|
||||||
|
|
||||||
|
# Queue for background processing using the TTS processor
|
||||||
|
if tts.id is not None:
|
||||||
|
tts_processor = await _get_tts_processor()
|
||||||
|
await tts_processor.queue_tts(tts.id)
|
||||||
|
|
||||||
|
return {"tts": tts, "message": "TTS generation queued successfully"}
|
||||||
|
|
||||||
|
async def _queue_tts_processing(self, tts_id: int) -> None:
|
||||||
|
"""Queue TTS for background processing."""
|
||||||
|
# For now, process immediately in a different way
|
||||||
|
# This could be moved to a proper background queue later
|
||||||
|
task = asyncio.create_task(self._process_tts_in_background(tts_id))
|
||||||
|
# Store reference to prevent garbage collection
|
||||||
|
self._background_tasks = getattr(self, "_background_tasks", set())
|
||||||
|
self._background_tasks.add(task)
|
||||||
|
task.add_done_callback(self._background_tasks.discard)
|
||||||
|
|
||||||
|
async def _process_tts_in_background(self, tts_id: int) -> None:
|
||||||
|
"""Process TTS generation in background."""
|
||||||
|
try:
|
||||||
|
# Create a new session for background processing
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
async with session_factory() as background_session:
|
||||||
|
tts_service = TTSService(background_session)
|
||||||
|
|
||||||
|
# Get the TTS record
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await background_session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
|
||||||
|
if not tts:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use a synchronous approach for the actual generation
|
||||||
|
sound = await tts_service._generate_tts_sync(
|
||||||
|
tts.text,
|
||||||
|
tts.provider,
|
||||||
|
tts.user_id,
|
||||||
|
tts.options,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the TTS record with the sound ID
|
||||||
|
if sound.id is not None:
|
||||||
|
tts.sound_id = sound.id
|
||||||
|
background_session.add(tts)
|
||||||
|
await background_session.commit()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Log error but don't fail - avoiding print for production
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Error processing TTS generation %s", tts_id)
|
||||||
|
|
||||||
|
async def _generate_tts_sync(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
provider: str,
|
||||||
|
user_id: int,
|
||||||
|
options: dict[str, Any],
|
||||||
|
) -> Sound:
|
||||||
|
"""Generate TTS using a synchronous approach."""
|
||||||
|
# Generate the audio using the provider
|
||||||
|
# (avoid async issues by doing it directly)
|
||||||
|
tts_provider = self.providers[provider]
|
||||||
|
|
||||||
|
# Create directories if they don't exist
|
||||||
|
original_dir = Path("sounds/originals/text_to_speech")
|
||||||
|
original_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create UUID filename
|
||||||
|
sound_uuid = str(uuid.uuid4())
|
||||||
|
original_filename = f"{sound_uuid}.{tts_provider.file_extension}"
|
||||||
|
original_path = original_dir / original_filename
|
||||||
|
|
||||||
|
# Generate audio synchronously
|
||||||
|
try:
|
||||||
|
# Generate TTS audio
|
||||||
|
lang = options.get("lang", "en")
|
||||||
|
tld = options.get("tld", "com")
|
||||||
|
slow = options.get("slow", False)
|
||||||
|
|
||||||
|
tts_instance = gTTS(text=text, lang=lang, tld=tld, slow=slow)
|
||||||
|
fp = io.BytesIO()
|
||||||
|
tts_instance.write_to_fp(fp)
|
||||||
|
fp.seek(0)
|
||||||
|
audio_bytes = fp.read()
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
original_path.write_bytes(audio_bytes)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Error generating TTS audio")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Create Sound record with proper metadata
|
||||||
|
sound = await self._create_sound_record_complete(
|
||||||
|
original_path,
|
||||||
|
text,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize the sound
|
||||||
|
if sound.id is not None:
|
||||||
|
await self._normalize_sound_safe(sound.id)
|
||||||
|
|
||||||
|
return sound
|
||||||
|
|
||||||
|
async def get_user_tts_history(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[TTS]:
|
||||||
|
"""Get TTS history for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
limit: Maximum number of records
|
||||||
|
offset: Offset for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of TTS records
|
||||||
|
|
||||||
|
"""
|
||||||
|
result = await self.tts_repo.get_by_user_id(user_id, limit, offset)
|
||||||
|
return list(result)
|
||||||
|
|
||||||
|
async def _create_sound_record(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
text: str,
|
||||||
|
user_id: int,
|
||||||
|
file_hash: str,
|
||||||
|
) -> Sound:
|
||||||
|
"""Create a Sound record for the TTS audio."""
|
||||||
|
# Get audio metadata
|
||||||
|
duration = get_audio_duration(audio_path)
|
||||||
|
size = get_file_size(audio_path)
|
||||||
|
name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "")
|
||||||
|
name = " ".join(word.capitalize() for word in name.split())
|
||||||
|
|
||||||
|
# Create sound data
|
||||||
|
sound_data = {
|
||||||
|
"type": "TTS",
|
||||||
|
"name": name,
|
||||||
|
"filename": audio_path.name,
|
||||||
|
"duration": duration,
|
||||||
|
"size": size,
|
||||||
|
"hash": file_hash,
|
||||||
|
"user_id": user_id,
|
||||||
|
"is_deletable": True,
|
||||||
|
"is_music": False, # TTS is speech, not music
|
||||||
|
"is_normalized": False,
|
||||||
|
"play_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.sound_repo.create(sound_data)
|
||||||
|
|
||||||
|
async def _create_sound_record_simple(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
text: str,
|
||||||
|
user_id: int,
|
||||||
|
) -> Sound:
|
||||||
|
"""Create a Sound record for the TTS audio with minimal processing."""
|
||||||
|
# Create sound data with basic info
|
||||||
|
name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "")
|
||||||
|
name = " ".join(word.capitalize() for word in name.split())
|
||||||
|
|
||||||
|
sound_data = {
|
||||||
|
"type": "TTS",
|
||||||
|
"name": name,
|
||||||
|
"filename": audio_path.name,
|
||||||
|
"duration": 0, # Skip duration calculation for now
|
||||||
|
"size": 0, # Skip size calculation for now
|
||||||
|
"hash": str(uuid.uuid4()), # Use UUID as temporary hash
|
||||||
|
"user_id": user_id,
|
||||||
|
"is_deletable": True,
|
||||||
|
"is_music": False, # TTS is speech, not music
|
||||||
|
"is_normalized": False,
|
||||||
|
"play_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.sound_repo.create(sound_data)
|
||||||
|
|
||||||
|
async def _create_sound_record_complete(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
text: str,
|
||||||
|
user_id: int,
|
||||||
|
) -> Sound:
|
||||||
|
"""Create a Sound record for the TTS audio with complete metadata."""
|
||||||
|
# Get audio metadata
|
||||||
|
duration = get_audio_duration(audio_path)
|
||||||
|
size = get_file_size(audio_path)
|
||||||
|
file_hash = get_file_hash(audio_path)
|
||||||
|
name = text[:MAX_NAME_LENGTH] + ("..." if len(text) > MAX_NAME_LENGTH else "")
|
||||||
|
name = " ".join(word.capitalize() for word in name.split())
|
||||||
|
|
||||||
|
# Check if a sound with this hash already exists
|
||||||
|
existing_sound = await self.sound_repo.get_by_hash(file_hash)
|
||||||
|
|
||||||
|
if existing_sound:
|
||||||
|
# Clean up the temporary file since we have a duplicate
|
||||||
|
if audio_path.exists():
|
||||||
|
audio_path.unlink()
|
||||||
|
return existing_sound
|
||||||
|
|
||||||
|
# Create sound data with complete metadata
|
||||||
|
sound_data = {
|
||||||
|
"type": "TTS",
|
||||||
|
"name": name,
|
||||||
|
"filename": audio_path.name,
|
||||||
|
"duration": duration,
|
||||||
|
"size": size,
|
||||||
|
"hash": file_hash,
|
||||||
|
"user_id": user_id,
|
||||||
|
"is_deletable": True,
|
||||||
|
"is_music": False, # TTS is speech, not music
|
||||||
|
"is_normalized": False,
|
||||||
|
"play_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.sound_repo.create(sound_data)
|
||||||
|
|
||||||
|
async def _normalize_sound_safe(self, sound_id: int) -> None:
|
||||||
|
"""Normalize the TTS sound with error handling."""
|
||||||
|
try:
|
||||||
|
# Get fresh sound object from database for normalization
|
||||||
|
sound = await self.sound_repo.get_by_id(sound_id)
|
||||||
|
if not sound:
|
||||||
|
return
|
||||||
|
|
||||||
|
normalizer_service = SoundNormalizerService(self.session)
|
||||||
|
result = await normalizer_service.normalize_sound(sound)
|
||||||
|
|
||||||
|
if result["status"] == "error":
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.warning(
|
||||||
|
"Warning: Failed to normalize TTS sound %s: %s",
|
||||||
|
sound_id,
|
||||||
|
result.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Exception during TTS sound normalization %s", sound_id)
|
||||||
|
# Don't fail the TTS generation if normalization fails
|
||||||
|
|
||||||
|
async def _normalize_sound(self, sound_id: int) -> None:
|
||||||
|
"""Normalize the TTS sound."""
|
||||||
|
try:
|
||||||
|
# Get fresh sound object from database for normalization
|
||||||
|
sound = await self.sound_repo.get_by_id(sound_id)
|
||||||
|
if not sound:
|
||||||
|
return
|
||||||
|
|
||||||
|
normalizer_service = SoundNormalizerService(self.session)
|
||||||
|
result = await normalizer_service.normalize_sound(sound)
|
||||||
|
|
||||||
|
if result["status"] == "error":
|
||||||
|
# Log warning but don't fail the TTS generation
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Don't fail the TTS generation if normalization fails
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Error normalizing sound %s", sound_id)
|
||||||
|
|
||||||
|
async def delete_tts(self, tts_id: int, user_id: int) -> None:
|
||||||
|
"""Delete a TTS generation and its associated sound and files."""
|
||||||
|
# Get the TTS record
|
||||||
|
tts = await self.tts_repo.get_by_id(tts_id)
|
||||||
|
if not tts:
|
||||||
|
tts_not_found_msg = f"TTS with ID {tts_id} not found"
|
||||||
|
raise ValueError(tts_not_found_msg)
|
||||||
|
|
||||||
|
# Check ownership
|
||||||
|
if tts.user_id != user_id:
|
||||||
|
permission_error_msg = (
|
||||||
|
"You don't have permission to delete this TTS generation"
|
||||||
|
)
|
||||||
|
raise PermissionError(permission_error_msg)
|
||||||
|
|
||||||
|
# If there's an associated sound, delete it and its files
|
||||||
|
if tts.sound_id:
|
||||||
|
sound = await self.sound_repo.get_by_id(tts.sound_id)
|
||||||
|
if sound:
|
||||||
|
# Delete the sound files
|
||||||
|
await self._delete_sound_files(sound)
|
||||||
|
# Delete the sound record
|
||||||
|
await self.sound_repo.delete(sound)
|
||||||
|
|
||||||
|
# Delete the TTS record
|
||||||
|
await self.tts_repo.delete(tts)
|
||||||
|
|
||||||
|
async def _delete_sound_files(self, sound: Sound) -> None:
|
||||||
|
"""Delete all files associated with a sound."""
|
||||||
|
# Delete original file
|
||||||
|
original_path = Path("sounds/originals/text_to_speech") / sound.filename
|
||||||
|
if original_path.exists():
|
||||||
|
original_path.unlink()
|
||||||
|
|
||||||
|
# Delete normalized file if it exists
|
||||||
|
if sound.normalized_filename:
|
||||||
|
normalized_path = (
|
||||||
|
Path("sounds/normalized/text_to_speech") / sound.normalized_filename
|
||||||
|
)
|
||||||
|
if normalized_path.exists():
|
||||||
|
normalized_path.unlink()
|
||||||
|
|
||||||
|
async def get_pending_tts(self) -> list[TTS]:
|
||||||
|
"""Get all pending TTS generations."""
|
||||||
|
stmt = select(TTS).where(TTS.status == "pending").order_by(TTS.created_at)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def mark_tts_processing(self, tts_id: int) -> None:
|
||||||
|
"""Mark a TTS generation as processing."""
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
if tts:
|
||||||
|
tts.status = "processing"
|
||||||
|
self.session.add(tts)
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def mark_tts_completed(self, tts_id: int, sound_id: int) -> None:
|
||||||
|
"""Mark a TTS generation as completed."""
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
if tts:
|
||||||
|
tts.status = "completed"
|
||||||
|
tts.sound_id = sound_id
|
||||||
|
tts.error = None
|
||||||
|
self.session.add(tts)
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def mark_tts_failed(self, tts_id: int, error_message: str) -> None:
|
||||||
|
"""Mark a TTS generation as failed."""
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
if tts:
|
||||||
|
tts.status = "failed"
|
||||||
|
tts.error = error_message
|
||||||
|
self.session.add(tts)
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def reset_stuck_tts(self) -> int:
|
||||||
|
"""Reset stuck TTS generations from processing back to pending."""
|
||||||
|
stmt = select(TTS).where(TTS.status == "processing")
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
stuck_tts = list(result.all())
|
||||||
|
|
||||||
|
for tts in stuck_tts:
|
||||||
|
tts.status = "pending"
|
||||||
|
tts.error = None
|
||||||
|
self.session.add(tts)
|
||||||
|
|
||||||
|
await self.session.commit()
|
||||||
|
return len(stuck_tts)
|
||||||
|
|
||||||
|
async def process_tts_generation(self, tts_id: int) -> None:
|
||||||
|
"""Process a TTS generation (used by the processor)."""
|
||||||
|
# Mark as processing
|
||||||
|
await self.mark_tts_processing(tts_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the TTS record
|
||||||
|
stmt = select(TTS).where(TTS.id == tts_id)
|
||||||
|
result = await self.session.exec(stmt)
|
||||||
|
tts = result.first()
|
||||||
|
|
||||||
|
if not tts:
|
||||||
|
tts_not_found_msg = f"TTS with ID {tts_id} not found"
|
||||||
|
raise ValueError(tts_not_found_msg)
|
||||||
|
|
||||||
|
# Generate the TTS
|
||||||
|
sound = await self._generate_tts_sync(
|
||||||
|
tts.text,
|
||||||
|
tts.provider,
|
||||||
|
tts.user_id,
|
||||||
|
tts.options,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture sound ID before session issues
|
||||||
|
sound_id = sound.id
|
||||||
|
if sound_id is None:
|
||||||
|
sound_creation_error = "Sound creation failed - no ID assigned"
|
||||||
|
raise ValueError(sound_creation_error)
|
||||||
|
|
||||||
|
# Mark as completed
|
||||||
|
await self.mark_tts_completed(tts_id, sound_id)
|
||||||
|
|
||||||
|
# Emit socket event for completion
|
||||||
|
await self._emit_tts_event("tts_completed", tts_id, sound_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Mark as failed
|
||||||
|
await self.mark_tts_failed(tts_id, str(e))
|
||||||
|
|
||||||
|
# Emit socket event for failure
|
||||||
|
await self._emit_tts_event("tts_failed", tts_id, None, str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _emit_tts_event(
|
||||||
|
self,
|
||||||
|
event: str,
|
||||||
|
tts_id: int,
|
||||||
|
sound_id: int | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Emit a socket event for TTS status change."""
|
||||||
|
try:
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"tts_id": tts_id,
|
||||||
|
"sound_id": sound_id,
|
||||||
|
}
|
||||||
|
if error:
|
||||||
|
data["error"] = error
|
||||||
|
|
||||||
|
logger.info("Emitting TTS socket event: %s with data: %s", event, data)
|
||||||
|
await socket_manager.broadcast_to_all(event, data)
|
||||||
|
logger.info("Successfully emitted TTS socket event: %s", event)
|
||||||
|
except Exception:
|
||||||
|
# Don't fail TTS processing if socket emission fails
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.exception("Failed to emit TTS socket event %s", event)
|
||||||
193
app/services/tts_processor.py
Normal file
193
app/services/tts_processor.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Background TTS processor for handling TTS generation queue."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import engine
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.services.tts import TTSService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TTSProcessor:
|
||||||
|
"""Background processor for handling TTS generation queue."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the TTS processor."""
|
||||||
|
self.max_concurrent = getattr(settings, "TTS_MAX_CONCURRENT", 3)
|
||||||
|
self.running_tts: set[int] = set()
|
||||||
|
self.processing_lock = asyncio.Lock()
|
||||||
|
self.shutdown_event = asyncio.Event()
|
||||||
|
self.processor_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Initialized TTS processor with max concurrent: %d",
|
||||||
|
self.max_concurrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the background TTS processor."""
|
||||||
|
if self.processor_task and not self.processor_task.done():
|
||||||
|
logger.warning("TTS processor is already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reset any stuck TTS generations from previous runs
|
||||||
|
await self._reset_stuck_tts()
|
||||||
|
|
||||||
|
self.shutdown_event.clear()
|
||||||
|
self.processor_task = asyncio.create_task(self._process_queue())
|
||||||
|
logger.info("Started TTS processor")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the background TTS processor."""
|
||||||
|
logger.info("Stopping TTS processor...")
|
||||||
|
self.shutdown_event.set()
|
||||||
|
|
||||||
|
if self.processor_task and not self.processor_task.done():
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.processor_task, timeout=30.0)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"TTS processor did not stop gracefully, cancelling...",
|
||||||
|
)
|
||||||
|
self.processor_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self.processor_task
|
||||||
|
|
||||||
|
logger.info("TTS processor stopped")
|
||||||
|
|
||||||
|
async def queue_tts(self, tts_id: int) -> None:
|
||||||
|
"""Queue a TTS generation for processing."""
|
||||||
|
async with self.processing_lock:
|
||||||
|
if tts_id not in self.running_tts:
|
||||||
|
logger.info("Queued TTS %d for processing", tts_id)
|
||||||
|
# The processor will pick it up on the next cycle
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"TTS %d is already being processed",
|
||||||
|
tts_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _process_queue(self) -> None:
|
||||||
|
"""Process the TTS queue in the main processing loop."""
|
||||||
|
logger.info("Starting TTS queue processor")
|
||||||
|
|
||||||
|
while not self.shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
await self._process_pending_tts()
|
||||||
|
|
||||||
|
# Wait before checking for new TTS generations
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.shutdown_event.wait(), timeout=5.0)
|
||||||
|
break # Shutdown requested
|
||||||
|
except TimeoutError:
|
||||||
|
continue # Continue processing
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error in TTS queue processor")
|
||||||
|
# Wait a bit before retrying to avoid tight error loops
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.shutdown_event.wait(), timeout=10.0)
|
||||||
|
break # Shutdown requested
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("TTS queue processor stopped")
|
||||||
|
|
||||||
|
async def _process_pending_tts(self) -> None:
|
||||||
|
"""Process pending TTS generations up to the concurrency limit."""
|
||||||
|
async with self.processing_lock:
|
||||||
|
# Check how many slots are available
|
||||||
|
available_slots = self.max_concurrent - len(self.running_tts)
|
||||||
|
|
||||||
|
if available_slots <= 0:
|
||||||
|
return # No available slots
|
||||||
|
|
||||||
|
# Get pending TTS generations from database
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
tts_service = TTSService(session)
|
||||||
|
pending_tts = await tts_service.get_pending_tts()
|
||||||
|
|
||||||
|
# Filter out TTS that are already being processed
|
||||||
|
available_tts = [
|
||||||
|
tts
|
||||||
|
for tts in pending_tts
|
||||||
|
if tts.id not in self.running_tts
|
||||||
|
]
|
||||||
|
|
||||||
|
# Start processing up to available slots
|
||||||
|
tts_to_start = available_tts[:available_slots]
|
||||||
|
|
||||||
|
for tts in tts_to_start:
|
||||||
|
tts_id = tts.id
|
||||||
|
self.running_tts.add(tts_id)
|
||||||
|
|
||||||
|
# Start processing this TTS in the background
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._process_single_tts(tts_id),
|
||||||
|
)
|
||||||
|
task.add_done_callback(
|
||||||
|
lambda t, tid=tts_id: self._on_tts_completed(
|
||||||
|
tid,
|
||||||
|
t,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Started processing TTS %d (%d/%d slots used)",
|
||||||
|
tts_id,
|
||||||
|
len(self.running_tts),
|
||||||
|
self.max_concurrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _process_single_tts(self, tts_id: int) -> None:
|
||||||
|
"""Process a single TTS generation."""
|
||||||
|
try:
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
tts_service = TTSService(session)
|
||||||
|
await tts_service.process_tts_generation(tts_id)
|
||||||
|
logger.info("Successfully processed TTS %d", tts_id)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to process TTS %d", tts_id)
|
||||||
|
# Mark TTS as failed in database
|
||||||
|
try:
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
tts_service = TTSService(session)
|
||||||
|
await tts_service.mark_tts_failed(tts_id, "Processing failed")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to mark TTS %d as failed", tts_id)
|
||||||
|
|
||||||
|
def _on_tts_completed(self, tts_id: int, task: asyncio.Task) -> None:
|
||||||
|
"""Handle completion of a TTS processing task."""
|
||||||
|
self.running_tts.discard(tts_id)
|
||||||
|
|
||||||
|
if task.exception():
|
||||||
|
logger.error(
|
||||||
|
"TTS processing task %d failed: %s",
|
||||||
|
tts_id,
|
||||||
|
task.exception(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("TTS processing task %d completed successfully", tts_id)
|
||||||
|
|
||||||
|
async def _reset_stuck_tts(self) -> None:
|
||||||
|
"""Reset any TTS generations that were stuck in 'processing' state."""
|
||||||
|
try:
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
tts_service = TTSService(session)
|
||||||
|
reset_count = await tts_service.reset_stuck_tts()
|
||||||
|
if reset_count > 0:
|
||||||
|
logger.info("Reset %d stuck TTS generations", reset_count)
|
||||||
|
else:
|
||||||
|
logger.info("No stuck TTS generations found to reset")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to reset stuck TTS generations")
|
||||||
|
|
||||||
|
|
||||||
|
# Global TTS processor instance
|
||||||
|
tts_processor = TTSProcessor()
|
||||||
@@ -73,6 +73,9 @@ class VLCPlayerService:
|
|||||||
async def play_sound(self, sound: Sound) -> bool:
|
async def play_sound(self, sound: Sound) -> bool:
|
||||||
"""Play a sound using a new VLC subprocess instance.
|
"""Play a sound using a new VLC subprocess instance.
|
||||||
|
|
||||||
|
VLC always plays at 100% volume. Host system volume is controlled separately
|
||||||
|
by the player service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
sound: The Sound object to play
|
sound: The Sound object to play
|
||||||
|
|
||||||
@@ -97,6 +100,7 @@ class VLCPlayerService:
|
|||||||
"--no-video", # Audio only
|
"--no-video", # Audio only
|
||||||
"--no-repeat", # Don't repeat
|
"--no-repeat", # Don't repeat
|
||||||
"--no-loop", # Don't loop
|
"--no-loop", # Don't loop
|
||||||
|
"--volume=100", # Always use 100% VLC volume
|
||||||
]
|
]
|
||||||
|
|
||||||
# Launch VLC process asynchronously without waiting
|
# Launch VLC process asynchronously without waiting
|
||||||
@@ -144,7 +148,7 @@ class VLCPlayerService:
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout, stderr = await find_process.communicate()
|
stdout, _stderr = await find_process.communicate()
|
||||||
|
|
||||||
if find_process.returncode != 0:
|
if find_process.returncode != 0:
|
||||||
# No VLC processes found
|
# No VLC processes found
|
||||||
@@ -332,7 +336,10 @@ class VLCPlayerService:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException, status # noqa: PLC0415, I001
|
from fastapi import HTTPException, status # noqa: PLC0415, I001
|
||||||
from app.services.credit import CreditService, InsufficientCreditsError # noqa: PLC0415
|
from app.services.credit import ( # noqa: PLC0415
|
||||||
|
CreditService,
|
||||||
|
InsufficientCreditsError,
|
||||||
|
)
|
||||||
|
|
||||||
if not self.db_session_factory:
|
if not self.db_session_factory:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -369,7 +376,7 @@ class VLCPlayerService:
|
|||||||
),
|
),
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
# Play the sound using VLC
|
# Play the sound using VLC (always at 100% VLC volume)
|
||||||
success = await self.play_sound(sound)
|
success = await self.play_sound(sound)
|
||||||
|
|
||||||
# Deduct credits based on success
|
# Deduct credits based on success
|
||||||
@@ -407,4 +414,3 @@ def get_vlc_player_service(
|
|||||||
if vlc_player_service is None:
|
if vlc_player_service is None:
|
||||||
vlc_player_service = VLCPlayerService(db_session_factory)
|
vlc_player_service = VLCPlayerService(db_session_factory)
|
||||||
return vlc_player_service
|
return vlc_player_service
|
||||||
return vlc_player_service
|
|
||||||
|
|||||||
251
app/services/volume.py
Normal file
251
app/services/volume.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""Volume service for host system volume control."""
|
||||||
|
|
||||||
|
import platform
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
MIN_VOLUME = 0
|
||||||
|
MAX_VOLUME = 100
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeService:
|
||||||
|
"""Service for controlling host system volume."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize volume service."""
|
||||||
|
self._system = platform.system().lower()
|
||||||
|
self._pycaw_available = False
|
||||||
|
self._pulsectl_available = False
|
||||||
|
|
||||||
|
# Try to import Windows volume control
|
||||||
|
if self._system == "windows":
|
||||||
|
try:
|
||||||
|
from comtypes import ( # noqa: PLC0415
|
||||||
|
CLSCTX_ALL, # type: ignore[import-untyped]
|
||||||
|
)
|
||||||
|
from pycaw.pycaw import ( # type: ignore[import-untyped] # noqa: PLC0415
|
||||||
|
AudioUtilities,
|
||||||
|
IAudioEndpointVolume,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._AudioUtilities = AudioUtilities
|
||||||
|
self._IAudioEndpointVolume = IAudioEndpointVolume
|
||||||
|
self._CLSCTX_ALL = CLSCTX_ALL
|
||||||
|
self._pycaw_available = True
|
||||||
|
logger.info("Windows volume control (pycaw) initialized")
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("pycaw not available: %s", e)
|
||||||
|
|
||||||
|
# Try to import Linux volume control
|
||||||
|
elif self._system == "linux":
|
||||||
|
try:
|
||||||
|
import pulsectl # type: ignore[import-untyped] # noqa: PLC0415
|
||||||
|
|
||||||
|
self._pulsectl = pulsectl
|
||||||
|
self._pulsectl_available = True
|
||||||
|
logger.info("Linux volume control (pulsectl) initialized")
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("pulsectl not available: %s", e)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning("Volume control not supported on %s", self._system)
|
||||||
|
|
||||||
|
def get_volume(self) -> int | None:
|
||||||
|
"""Get the current system volume as a percentage (0-100).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Volume level as percentage, or None if not available
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._system == "windows" and self._pycaw_available:
|
||||||
|
return self._get_windows_volume()
|
||||||
|
if self._system == "linux" and self._pulsectl_available:
|
||||||
|
return self._get_linux_volume()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get volume")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.warning("Volume control not available for this system")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_volume(self, volume: int) -> bool:
|
||||||
|
"""Set the system volume to a percentage (0-100).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volume: Volume level as percentage (0-100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not (MIN_VOLUME <= volume <= MAX_VOLUME):
|
||||||
|
logger.error(
|
||||||
|
"Volume must be between %s and %s, got %s",
|
||||||
|
MIN_VOLUME,
|
||||||
|
MAX_VOLUME,
|
||||||
|
volume,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._system == "windows" and self._pycaw_available:
|
||||||
|
return self._set_windows_volume(volume)
|
||||||
|
if self._system == "linux" and self._pulsectl_available:
|
||||||
|
return self._set_linux_volume(volume)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to set volume")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning("Volume control not available for this system")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_muted(self) -> bool | None:
|
||||||
|
"""Check if the system is muted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if muted, False if not muted, None if not available
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._system == "windows" and self._pycaw_available:
|
||||||
|
return self._get_windows_mute_status()
|
||||||
|
if self._system == "linux" and self._pulsectl_available:
|
||||||
|
return self._get_linux_mute_status()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get mute status")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.warning("Mute status not available for this system")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_mute(self, *, muted: bool) -> bool:
|
||||||
|
"""Set the system mute status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
muted: True to mute, False to unmute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._system == "windows" and self._pycaw_available:
|
||||||
|
return self._set_windows_mute(muted=muted)
|
||||||
|
if self._system == "linux" and self._pulsectl_available:
|
||||||
|
return self._set_linux_mute(muted=muted)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to set mute status")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning("Mute control not available for this system")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_windows_volume(self) -> int:
|
||||||
|
"""Get Windows volume using pycaw."""
|
||||||
|
devices = self._AudioUtilities.GetSpeakers()
|
||||||
|
interface = devices.Activate(
|
||||||
|
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
|
||||||
|
)
|
||||||
|
volume = interface.QueryInterface(self._IAudioEndpointVolume)
|
||||||
|
current_volume = volume.GetMasterVolume()
|
||||||
|
|
||||||
|
# Convert from scalar (0.0-1.0) to percentage (0-100)
|
||||||
|
return int(current_volume * MAX_VOLUME)
|
||||||
|
|
||||||
|
def _set_windows_volume(self, volume_percent: int) -> bool:
|
||||||
|
"""Set Windows volume using pycaw."""
|
||||||
|
devices = self._AudioUtilities.GetSpeakers()
|
||||||
|
interface = devices.Activate(
|
||||||
|
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
|
||||||
|
)
|
||||||
|
volume = interface.QueryInterface(self._IAudioEndpointVolume)
|
||||||
|
|
||||||
|
# Convert from percentage (0-100) to scalar (0.0-1.0)
|
||||||
|
volume_scalar = volume_percent / MAX_VOLUME
|
||||||
|
volume.SetMasterVolume(volume_scalar, None)
|
||||||
|
logger.info("Windows volume set to %s%%", volume_percent)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_windows_mute_status(self) -> bool:
|
||||||
|
"""Get Windows mute status using pycaw."""
|
||||||
|
devices = self._AudioUtilities.GetSpeakers()
|
||||||
|
interface = devices.Activate(
|
||||||
|
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
|
||||||
|
)
|
||||||
|
volume = interface.QueryInterface(self._IAudioEndpointVolume)
|
||||||
|
return bool(volume.GetMute())
|
||||||
|
|
||||||
|
def _set_windows_mute(self, *, muted: bool) -> bool:
|
||||||
|
"""Set Windows mute status using pycaw."""
|
||||||
|
devices = self._AudioUtilities.GetSpeakers()
|
||||||
|
interface = devices.Activate(
|
||||||
|
self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None,
|
||||||
|
)
|
||||||
|
volume = interface.QueryInterface(self._IAudioEndpointVolume)
|
||||||
|
volume.SetMute(muted, None)
|
||||||
|
logger.info("Windows mute set to %s", muted)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_linux_volume(self) -> int:
|
||||||
|
"""Get Linux volume using pulsectl."""
|
||||||
|
with self._pulsectl.Pulse("volume-service") as pulse:
|
||||||
|
# Get the default sink (output device)
|
||||||
|
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
|
||||||
|
if default_sink is None:
|
||||||
|
logger.error("No default audio sink found")
|
||||||
|
return MIN_VOLUME
|
||||||
|
|
||||||
|
# Get volume as percentage (PulseAudio uses 0.0-1.0, we convert to 0-100)
|
||||||
|
volume = default_sink.volume
|
||||||
|
avg_volume = sum(volume.values) / len(volume.values)
|
||||||
|
return int(avg_volume * MAX_VOLUME)
|
||||||
|
|
||||||
|
def _set_linux_volume(self, volume_percent: int) -> bool:
|
||||||
|
"""Set Linux volume using pulsectl."""
|
||||||
|
with self._pulsectl.Pulse("volume-service") as pulse:
|
||||||
|
# Get the default sink (output device)
|
||||||
|
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
|
||||||
|
if default_sink is None:
|
||||||
|
logger.error("No default audio sink found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Convert percentage to PulseAudio volume (0.0-1.0)
|
||||||
|
volume_scalar = volume_percent / MAX_VOLUME
|
||||||
|
|
||||||
|
# Set volume for all channels
|
||||||
|
pulse.volume_set_all_chans(default_sink, volume_scalar)
|
||||||
|
logger.info("Linux volume set to %s%%", volume_percent)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_linux_mute_status(self) -> bool:
|
||||||
|
"""Get Linux mute status using pulsectl."""
|
||||||
|
with self._pulsectl.Pulse("volume-service") as pulse:
|
||||||
|
# Get the default sink (output device)
|
||||||
|
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
|
||||||
|
if default_sink is None:
|
||||||
|
logger.error("No default audio sink found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(default_sink.mute)
|
||||||
|
|
||||||
|
def _set_linux_mute(self, *, muted: bool) -> bool:
|
||||||
|
"""Set Linux mute status using pulsectl."""
|
||||||
|
with self._pulsectl.Pulse("volume-service") as pulse:
|
||||||
|
# Get the default sink (output device)
|
||||||
|
default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name)
|
||||||
|
if default_sink is None:
|
||||||
|
logger.error("No default audio sink found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Set mute status
|
||||||
|
pulse.mute(default_sink, muted)
|
||||||
|
logger.info("Linux mute set to %s", muted)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Global volume service instance
|
||||||
|
volume_service = VolumeService()
|
||||||
100
migrate.py
Executable file
100
migrate.py
Executable 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()
|
||||||
@@ -6,31 +6,37 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiosqlite==0.21.0",
|
"aiosqlite==0.21.0",
|
||||||
|
"alembic==1.16.5",
|
||||||
"apscheduler==3.11.0",
|
"apscheduler==3.11.0",
|
||||||
"bcrypt==4.3.0",
|
"bcrypt==5.0.0",
|
||||||
"email-validator==2.3.0",
|
"email-validator==2.3.0",
|
||||||
"fastapi[standard]==0.116.1",
|
"fastapi[standard]==0.118.0",
|
||||||
"ffmpeg-python==0.2.0",
|
"ffmpeg-python==0.2.0",
|
||||||
|
"gtts==2.5.4",
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"pydantic-settings==2.10.1",
|
"pydantic-settings==2.11.0",
|
||||||
"pyjwt==2.10.1",
|
"pyjwt==2.10.1",
|
||||||
"python-socketio==5.13.0",
|
"python-socketio==5.14.1",
|
||||||
"pytz==2025.2",
|
"pytz==2025.2",
|
||||||
"python-vlc==3.0.21203",
|
"python-vlc==3.0.21203",
|
||||||
"sqlmodel==0.0.24",
|
"sqlmodel==0.0.25",
|
||||||
"uvicorn[standard]==0.35.0",
|
"uvicorn[standard]==0.37.0",
|
||||||
"yt-dlp==2025.8.27",
|
"yt-dlp==2025.9.26",
|
||||||
|
"asyncpg==0.30.0",
|
||||||
|
"psycopg[binary]==3.2.10",
|
||||||
|
"pycaw>=20240210",
|
||||||
|
"pulsectl>=24.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
dev-dependencies = [
|
dev-dependencies = [
|
||||||
"coverage==7.10.5",
|
"coverage==7.10.7",
|
||||||
"faker==37.6.0",
|
"faker==37.8.0",
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"mypy==1.17.1",
|
"mypy==1.18.2",
|
||||||
"pytest==8.4.1",
|
"pytest==8.4.2",
|
||||||
"pytest-asyncio==1.1.0",
|
"pytest-asyncio==1.2.0",
|
||||||
"ruff==0.12.11",
|
"ruff==0.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
|
|||||||
55
reset.sh
Executable file
55
reset.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Reset script for SDB2 soundboard application
|
||||||
|
# This script removes the database and cleans extracted sounds
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "🔄 Resetting SDB2 application..."
|
||||||
|
|
||||||
|
# Change to backend directory
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Remove database file if it exists
|
||||||
|
if [ -f "data/soundboard.db" ]; then
|
||||||
|
echo "🗑️ Removing database: data/soundboard.db"
|
||||||
|
rm "data/soundboard.db"
|
||||||
|
else
|
||||||
|
echo "ℹ️ Database file not found, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List of folders to clean (only files will be deleted, preserving .gitignore)
|
||||||
|
FOLDERS_TO_CLEAN=(
|
||||||
|
"sounds/originals/extracted"
|
||||||
|
"sounds/originals/extracted/thumbnails"
|
||||||
|
"sounds/originals/text_to_speech"
|
||||||
|
"sounds/normalized/extracted"
|
||||||
|
"sounds/normalized/soundboard"
|
||||||
|
"sounds/normalized/text_to_speech"
|
||||||
|
"sounds/temp"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Function to clean files in a directory
|
||||||
|
clean_folder() {
|
||||||
|
local folder="$1"
|
||||||
|
|
||||||
|
if [ -d "$folder" ]; then
|
||||||
|
echo "🧹 Cleaning folder: $folder"
|
||||||
|
|
||||||
|
# Find and delete all files except .gitignore (preserving subdirectories)
|
||||||
|
find "$folder" -maxdepth 1 -type f -not -name '.gitignore' -delete
|
||||||
|
|
||||||
|
echo "✅ Folder cleaned: $folder"
|
||||||
|
else
|
||||||
|
echo "ℹ️ Folder not found, skipping: $folder"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean all specified folders
|
||||||
|
echo "🧹 Cleaning specified folders..."
|
||||||
|
for folder in "${FOLDERS_TO_CLEAN[@]}"; do
|
||||||
|
clean_folder "$folder"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✅ Application reset complete!"
|
||||||
|
echo "💡 Run 'uv run python run.py' to start fresh"
|
||||||
@@ -537,6 +537,7 @@ class TestPlayerEndpoints:
|
|||||||
"duration": 30000,
|
"duration": 30000,
|
||||||
"sounds": [],
|
"sounds": [],
|
||||||
},
|
},
|
||||||
|
"play_next_queue": [],
|
||||||
}
|
}
|
||||||
mock_player_service.get_state.return_value = mock_state
|
mock_player_service.get_state.return_value = mock_state
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class TestApiTokenDependencies:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test flexible authentication falls back to JWT when no API token."""
|
"""Test flexible authentication falls back to JWT when no API token."""
|
||||||
# Mock the get_current_user function (normally imported)
|
# Mock the get_current_user function (normally imported)
|
||||||
with pytest.raises(Exception, match="Database error|Could not validate"):
|
with pytest.raises(Exception, match=r"Database error|Could not validate"):
|
||||||
# This will fail because we can't easily mock the get_current_user import
|
# This will fail because we can't easily mock the get_current_user import
|
||||||
# In a real test, you'd mock the import or use dependency injection
|
# In a real test, you'd mock the import or use dependency injection
|
||||||
await get_current_user_flexible(mock_auth_service, "jwt_token", None)
|
await get_current_user_flexible(mock_auth_service, "jwt_token", None)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class TestUserRepository:
|
|||||||
test_session: AsyncSession,
|
test_session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test creating a new user."""
|
"""Test creating a new user."""
|
||||||
free_plan, pro_plan = ensure_plans
|
free_plan, _pro_plan = ensure_plans
|
||||||
plan_id = free_plan.id
|
plan_id = free_plan.id
|
||||||
plan_credits = free_plan.credits
|
plan_credits = free_plan.credits
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class TestAuthService:
|
|||||||
assert response.user.role == "admin" # First user gets admin role
|
assert response.user.role == "admin" # First user gets admin role
|
||||||
assert response.user.is_active is True
|
assert response.user.is_active is True
|
||||||
# First user gets pro plan
|
# First user gets pro plan
|
||||||
free_plan, pro_plan = ensure_plans
|
_free_plan, pro_plan = ensure_plans
|
||||||
assert response.user.credits == pro_plan.credits
|
assert response.user.credits == pro_plan.credits
|
||||||
assert response.user.plan["code"] == pro_plan.code
|
assert response.user.plan["code"] == pro_plan.code
|
||||||
|
|
||||||
|
|||||||
@@ -15,19 +15,32 @@ def mock_sound_repository():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def dashboard_service(mock_sound_repository):
|
def mock_user_repository():
|
||||||
|
"""Mock user repository."""
|
||||||
|
return Mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dashboard_service(mock_sound_repository, mock_user_repository):
|
||||||
"""Dashboard service with mocked dependencies."""
|
"""Dashboard service with mocked dependencies."""
|
||||||
return DashboardService(sound_repository=mock_sound_repository)
|
return DashboardService(
|
||||||
|
sound_repository=mock_sound_repository,
|
||||||
|
user_repository=mock_user_repository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDashboardService:
|
class TestDashboardService:
|
||||||
"""Test dashboard service."""
|
"""Test dashboard service."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_init(self, mock_sound_repository):
|
async def test_init(self, mock_sound_repository, mock_user_repository):
|
||||||
"""Test dashboard service initialization."""
|
"""Test dashboard service initialization."""
|
||||||
service = DashboardService(sound_repository=mock_sound_repository)
|
service = DashboardService(
|
||||||
|
sound_repository=mock_sound_repository,
|
||||||
|
user_repository=mock_user_repository,
|
||||||
|
)
|
||||||
assert service.sound_repository == mock_sound_repository
|
assert service.sound_repository == mock_sound_repository
|
||||||
|
assert service.user_repository == mock_user_repository
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_soundboard_statistics_success(
|
async def test_get_soundboard_statistics_success(
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ class TestPlayerState:
|
|||||||
|
|
||||||
def test_init_creates_default_state(self) -> None:
|
def test_init_creates_default_state(self) -> None:
|
||||||
"""Test that player state initializes with default values."""
|
"""Test that player state initializes with default values."""
|
||||||
state = PlayerState()
|
# Mock volume service to return a specific volume
|
||||||
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
|
mock_volume_service.get_volume.return_value = 80
|
||||||
|
|
||||||
assert state.status == PlayerStatus.STOPPED
|
state = PlayerState()
|
||||||
assert state.mode == PlayerMode.CONTINUOUS
|
|
||||||
assert state.volume == 80
|
assert state.status == PlayerStatus.STOPPED
|
||||||
assert state.previous_volume == 80
|
assert state.mode == PlayerMode.CONTINUOUS
|
||||||
|
assert state.volume == 80
|
||||||
|
assert state.previous_volume == 80
|
||||||
|
mock_volume_service.get_volume.assert_called_once()
|
||||||
assert state.current_sound_id is None
|
assert state.current_sound_id is None
|
||||||
assert state.current_sound_index is None
|
assert state.current_sound_index is None
|
||||||
assert state.current_sound_position == 0
|
assert state.current_sound_position == 0
|
||||||
@@ -181,7 +186,9 @@ class TestPlayerService:
|
|||||||
mock_socket_manager,
|
mock_socket_manager,
|
||||||
):
|
):
|
||||||
"""Create a player service instance for testing."""
|
"""Create a player service instance for testing."""
|
||||||
return PlayerService(mock_db_session_factory)
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
|
mock_volume_service.get_volume.return_value = 80
|
||||||
|
return PlayerService(mock_db_session_factory)
|
||||||
|
|
||||||
def test_init_creates_player_service(
|
def test_init_creates_player_service(
|
||||||
self,
|
self,
|
||||||
@@ -217,7 +224,8 @@ class TestPlayerService:
|
|||||||
assert player_service._loop is not None
|
assert player_service._loop is not None
|
||||||
assert player_service._position_thread is not None
|
assert player_service._position_thread is not None
|
||||||
assert player_service._position_thread.daemon is True
|
assert player_service._position_thread.daemon is True
|
||||||
player_service._player.audio_set_volume.assert_called_once_with(80)
|
# VLC is now always set to 100% volume
|
||||||
|
player_service._player.audio_set_volume.assert_called_once_with(100)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_cleans_up_service(self, player_service) -> None:
|
async def test_stop_cleans_up_service(self, player_service) -> None:
|
||||||
@@ -399,23 +407,32 @@ class TestPlayerService:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_volume(self, player_service) -> None:
|
async def test_set_volume(self, player_service) -> None:
|
||||||
"""Test setting volume."""
|
"""Test setting volume."""
|
||||||
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
await player_service.set_volume(75)
|
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
||||||
|
mock_volume_service.is_muted.return_value = False
|
||||||
|
|
||||||
assert player_service.state.volume == 75
|
await player_service.set_volume(75)
|
||||||
player_service._player.audio_set_volume.assert_called_once_with(75)
|
|
||||||
|
assert player_service.state.volume == 75
|
||||||
|
# VLC volume is always set to 100%, host volume is controlled separately
|
||||||
|
player_service._player.audio_set_volume.assert_called_once_with(100)
|
||||||
|
# Verify host volume was set
|
||||||
|
mock_volume_service.set_volume.assert_called_once_with(75)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_volume_clamping(self, player_service) -> None:
|
async def test_set_volume_clamping(self, player_service) -> None:
|
||||||
"""Test volume clamping to valid range."""
|
"""Test volume clamping to valid range."""
|
||||||
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
with patch("app.services.player.volume_service") as mock_volume_service:
|
||||||
# Test upper bound
|
with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock):
|
||||||
await player_service.set_volume(150)
|
mock_volume_service.is_muted.return_value = False
|
||||||
assert player_service.state.volume == 100
|
|
||||||
|
|
||||||
# Test lower bound
|
# Test upper bound
|
||||||
await player_service.set_volume(-10)
|
await player_service.set_volume(150)
|
||||||
assert player_service.state.volume == 0
|
assert player_service.state.volume == 100
|
||||||
|
|
||||||
|
# Test lower bound
|
||||||
|
await player_service.set_volume(-10)
|
||||||
|
assert player_service.state.volume == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_mode(self, player_service) -> None:
|
async def test_set_mode(self, player_service) -> None:
|
||||||
|
|||||||
@@ -174,6 +174,39 @@ class TestSchedulerService:
|
|||||||
result = await scheduler_service.cancel_task(uuid.uuid4())
|
result = await scheduler_service.cancel_task(uuid.uuid4())
|
||||||
assert result is False
|
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(
|
async def test_get_user_tasks(
|
||||||
self,
|
self,
|
||||||
scheduler_service: SchedulerService,
|
scheduler_service: SchedulerService,
|
||||||
|
|||||||
@@ -92,14 +92,14 @@ class TestTaskHandlerRegistry:
|
|||||||
parameters={"user_id": str(test_user_id)},
|
parameters={"user_id": str(test_user_id)},
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_credit_service.recharge_user_credits.return_value = {
|
# Mock transaction object
|
||||||
"user_id": str(test_user_id),
|
mock_transaction = MagicMock()
|
||||||
"credits_added": 100,
|
mock_transaction.amount = 100
|
||||||
}
|
mock_credit_service.recharge_user_credits_auto.return_value = mock_transaction
|
||||||
|
|
||||||
await task_registry.execute_task(task)
|
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(
|
async def test_handle_credit_recharge_uuid_user_id(
|
||||||
self,
|
self,
|
||||||
@@ -117,7 +117,7 @@ class TestTaskHandlerRegistry:
|
|||||||
|
|
||||||
await task_registry.execute_task(task)
|
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(
|
async def test_handle_play_sound_success(
|
||||||
self,
|
self,
|
||||||
|
|||||||
634
uv.lock
generated
634
uv.lock
generated
@@ -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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -50,53 +64,93 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "asyncpg"
|
||||||
version = "4.3.0"
|
version = "0.30.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697 }
|
sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719 },
|
{ url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162 },
|
||||||
{ 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/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025 },
|
||||||
{ 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/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243 },
|
||||||
{ 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/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059 },
|
||||||
{ 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/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596 },
|
||||||
{ 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/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632 },
|
||||||
{ 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/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186 },
|
||||||
{ 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/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064 },
|
||||||
{ 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/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373 },
|
||||||
{ 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/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745 },
|
||||||
{ 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/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103 },
|
||||||
{ 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/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471 },
|
||||||
{ 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/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253 },
|
||||||
{ 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/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720 },
|
||||||
{ 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/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404 },
|
||||||
{ 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/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623 },
|
||||||
{ 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 },
|
[[package]]
|
||||||
{ 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 },
|
name = "bcrypt"
|
||||||
{ 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 },
|
version = "5.0.0"
|
||||||
{ 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 },
|
source = { registry = "https://pypi.org/simple" }
|
||||||
{ 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 },
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386 }
|
||||||
{ 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 },
|
wheels = [
|
||||||
{ 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/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/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/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/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589 },
|
{ 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/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794 },
|
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498 },
|
||||||
{ 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/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853 },
|
||||||
{ 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/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626 },
|
||||||
{ 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/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/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598 },
|
{ 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/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799 },
|
{ 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]]
|
[[package]]
|
||||||
@@ -117,16 +171,58 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722 },
|
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.2.1"
|
version = "8.1.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -139,67 +235,86 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "comtypes"
|
||||||
version = "7.10.5"
|
version = "1.4.12"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b6/b8/3af03195b9de515448292169c6d6d7a630de02bedf891a47b809638c186f/comtypes-1.4.12.zip", hash = "sha256:3ff06c442c2de8a2b25785407f244eb5b6f809d21cf068a855071ba80a76876f", size = 280541 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/8e/40d75c7128f871ea0fd829d3e7e4a14460cad7c3826e3b472e6471ad05bd/coverage-7.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2d05c7e73c60a4cecc7d9b60dbfd603b4ebc0adafaef371445b47d0f805c8a9", size = 217077 },
|
{ url = "https://files.pythonhosted.org/packages/2d/01/89285549c5138009db68f26c80f2174d0ec82a858547df0cc40a8b0a47d6/comtypes-1.4.12-py3-none-any.whl", hash = "sha256:e0fa9cc19c489fa7feea4c1710f4575c717e2673edef5b99bf99efd507908e44", size = 253704 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/a8/f333f4cf3fb5477a7f727b4d603a2eb5c3c5611c7fe01329c2e13b23b678/coverage-7.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:32ddaa3b2c509778ed5373b177eb2bf5662405493baeff52278a0b4f9415188b", size = 217310 },
|
]
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/2c/fbecd8381e0a07d1547922be819b4543a901402f63930313a519b937c668/coverage-7.10.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd382410039fe062097aa0292ab6335a3f1e7af7bba2ef8d27dcda484918f20c", size = 248802 },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/bc/1011da599b414fb6c9c0f34086736126f9ff71f841755786a6b87601b088/coverage-7.10.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7fa22800f3908df31cea6fb230f20ac49e343515d968cc3a42b30d5c3ebf9b5a", size = 251550 },
|
[[package]]
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/6f/b5c03c0c721c067d21bc697accc3642f3cef9f087dac429c918c37a37437/coverage-7.10.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f366a57ac81f5e12797136552f5b7502fa053c861a009b91b80ed51f2ce651c6", size = 252684 },
|
name = "coverage"
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/50/d474bc300ebcb6a38a1047d5c465a227605d6473e49b4e0d793102312bc5/coverage-7.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1dc8f1980a272ad4a6c84cba7981792344dad33bf5869361576b7aef42733a", size = 250602 },
|
version = "7.10.7"
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/2d/548c8e04249cbba3aba6bd799efdd11eee3941b70253733f5d355d689559/coverage-7.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2285c04ee8676f7938b02b4936d9b9b672064daab3187c20f73a55f3d70e6b4a", size = 248724 },
|
source = { registry = "https://pypi.org/simple" }
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/96/a7c3c0562266ac39dcad271d0eec8fc20ab576e3e2f64130a845ad2a557b/coverage-7.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2492e4dd9daab63f5f56286f8a04c51323d237631eb98505d87e4c4ff19ec34", size = 250158 },
|
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704 }
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/75/74d4be58c70c42ef0b352d597b022baf12dbe2b43e7cb1525f56a0fb1d4b/coverage-7.10.5-cp312-cp312-win32.whl", hash = "sha256:38a9109c4ee8135d5df5505384fc2f20287a47ccbe0b3f04c53c9a1989c2bbaf", size = 219493 },
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/08/364e6012d1d4d09d1e27437382967efed971d7613f94bca9add25f0c1f2b/coverage-7.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:6b87f1ad60b30bc3c43c66afa7db6b22a3109902e28c5094957626a0143a001f", size = 220302 },
|
{ 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/db/d5/7c8a365e1f7355c58af4fe5faf3f90cc8e587590f5854808d17ccb4e7077/coverage-7.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:672a6c1da5aea6c629819a0e1461e89d244f78d7b60c424ecf4f1f2556c041d8", size = 218936 },
|
{ 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/9f/08/4166ecfb60ba011444f38a5a6107814b80c34c717bc7a23be0d22e92ca09/coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c", size = 217106 },
|
{ 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/25/d7/b71022408adbf040a680b8c64bf6ead3be37b553e5844f7465643979f7ca/coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44", size = 217353 },
|
{ 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/74/68/21e0d254dbf8972bb8dd95e3fe7038f4be037ff04ba47d6d1b12b37510ba/coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc", size = 248350 },
|
{ 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/90/65/28752c3a896566ec93e0219fc4f47ff71bd2b745f51554c93e8dcb659796/coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869", size = 250955 },
|
{ 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/a5/eb/ca6b7967f57f6fef31da8749ea20417790bb6723593c8cd98a987be20423/coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f", size = 252230 },
|
{ 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/bc/29/17a411b2a2a18f8b8c952aa01c00f9284a1fbc677c68a0003b772ea89104/coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5", size = 250387 },
|
{ 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/c7/89/97a9e271188c2fbb3db82235c33980bcbc733da7da6065afbaa1d685a169/coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c", size = 248280 },
|
{ 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/d1/c6/0ad7d0137257553eb4706b4ad6180bec0a1b6a648b092c5bbda48d0e5b2c/coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2", size = 249894 },
|
{ 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/84/56/fb3aba936addb4c9e5ea14f5979393f1c2466b4c89d10591fd05f2d6b2aa/coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4", size = 219536 },
|
{ 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/fc/54/baacb8f2f74431e3b175a9a2881feaa8feb6e2f187a0e7e3046f3c7742b2/coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b", size = 220330 },
|
{ 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/64/8a/82a3788f8e31dee51d350835b23d480548ea8621f3effd7c3ba3f7e5c006/coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84", size = 218961 },
|
{ 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/d8/a1/590154e6eae07beee3b111cc1f907c30da6fc8ce0a83ef756c72f3c7c748/coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7", size = 217819 },
|
{ 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/0d/ff/436ffa3cfc7741f0973c5c89405307fe39b78dcf201565b934e6616fc4ad/coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b", size = 218040 },
|
{ 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/a0/ca/5787fb3d7820e66273913affe8209c534ca11241eb34ee8c4fd2aaa9dd87/coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae", size = 259374 },
|
{ 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/b5/89/21af956843896adc2e64fc075eae3c1cadb97ee0a6960733e65e696f32dd/coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760", size = 261551 },
|
{ 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/e1/96/390a69244ab837e0ac137989277879a084c786cf036c3c4a3b9637d43a89/coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235", size = 263776 },
|
{ 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/00/32/cfd6ae1da0a521723349f3129b2455832fc27d3f8882c07e5b6fefdd0da2/coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5", size = 261326 },
|
{ 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/4c/c4/bf8d459fb4ce2201e9243ce6c015936ad283a668774430a3755f467b39d1/coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db", size = 259090 },
|
{ 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/f4/5d/a234f7409896468e5539d42234016045e4015e857488b0b5b5f3f3fa5f2b/coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e", size = 260217 },
|
{ 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/f3/ad/87560f036099f46c2ddd235be6476dd5c1d6be6bb57569a9348d43eeecea/coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee", size = 220194 },
|
{ 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/36/a8/04a482594fdd83dc677d4a6c7e2d62135fff5a1573059806b8383fad9071/coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14", size = 221258 },
|
{ 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/eb/ad/7da28594ab66fe2bc720f1bc9b131e62e9b4c6e39f044d9a48d18429cc21/coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff", size = 219521 },
|
{ url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/7f/c8b6e4e664b8a95254c35a6c8dd0bf4db201ec681c169aae2f1256e05c85/coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031", size = 217090 },
|
{ 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/44/74/3ee14ede30a6e10a94a104d1d0522d5fb909a7c7cac2643d2a79891ff3b9/coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3", size = 217365 },
|
{ 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/41/5f/06ac21bf87dfb7620d1f870dfa3c2cae1186ccbcdc50b8b36e27a0d52f50/coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031", size = 248413 },
|
{ 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/21/bc/cc5bed6e985d3a14228539631573f3863be6a2587381e8bc5fdf786377a1/coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2", size = 250943 },
|
{ 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/8d/43/6a9fc323c2c75cd80b18d58db4a25dc8487f86dd9070f9592e43e3967363/coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762", size = 252301 },
|
{ 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/69/7c/3e791b8845f4cd515275743e3775adb86273576596dc9f02dca37357b4f2/coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae", size = 250302 },
|
{ 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/5c/bc/5099c1e1cb0c9ac6491b281babea6ebbf999d949bf4aa8cdf4f2b53505e8/coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262", size = 248237 },
|
{ 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/7e/51/d346eb750a0b2f1e77f391498b753ea906fde69cc11e4b38dca28c10c88c/coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99", size = 249726 },
|
{ 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/a3/85/eebcaa0edafe427e93286b94f56ea7e1280f2c49da0a776a6f37e04481f9/coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde", size = 219825 },
|
{ 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/3c/f7/6d43e037820742603f1e855feb23463979bf40bd27d0cde1f761dcc66a3e/coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13", size = 220618 },
|
{ 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/4a/b0/ed9432e41424c51509d1da603b0393404b828906236fb87e2c8482a93468/coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9", size = 219199 },
|
{ 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/2f/54/5a7ecfa77910f22b659c820f67c16fc1e149ed132ad7117f0364679a8fa9/coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508", size = 217833 },
|
{ 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/4e/0e/25672d917cc57857d40edf38f0b867fb9627115294e4f92c8fcbbc18598d/coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357", size = 218048 },
|
{ 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/cb/7c/0b2b4f1c6f71885d4d4b2b8608dcfc79057adb7da4143eb17d6260389e42/coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b", size = 259549 },
|
{ 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/94/73/abb8dab1609abec7308d83c6aec547944070526578ee6c833d2da9a0ad42/coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4", size = 261715 },
|
{ 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/0b/d1/abf31de21ec92731445606b8d5e6fa5144653c2788758fcf1f47adb7159a/coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba", size = 263969 },
|
{ 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/9c/b3/ef274927f4ebede96056173b620db649cc9cb746c61ffc467946b9d0bc67/coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842", size = 261408 },
|
{ 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/20/fc/83ca2812be616d69b4cdd4e0c62a7bc526d56875e68fd0f79d47c7923584/coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874", size = 259168 },
|
{ 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/fc/4f/e0779e5716f72d5c9962e709d09815d02b3b54724e38567308304c3fc9df/coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732", size = 260317 },
|
{ 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/2b/fe/4247e732f2234bb5eb9984a0888a70980d681f03cbf433ba7b48f08ca5d5/coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df", size = 220600 },
|
{ 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/a7/a0/f294cff6d1034b87839987e5b6ac7385bec599c44d08e0857ac7f164ad0c/coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f", size = 221714 },
|
{ 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/23/18/fa1afdc60b5528d17416df440bcbd8fd12da12bfea9da5b6ae0f7a37d0f7/coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2", size = 219735 },
|
{ 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/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736 },
|
{ url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -226,28 +341,28 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "faker"
|
name = "faker"
|
||||||
version = "37.6.0"
|
version = "37.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "tzdata" },
|
{ name = "tzdata" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/24/cd/f7679c20f07d9e2013123b7f7e13809a3450a18d938d58e86081a486ea15/faker-37.6.0.tar.gz", hash = "sha256:0f8cc34f30095184adf87c3c24c45b38b33ad81c35ef6eb0a3118f301143012c", size = 1907960 }
|
sdist = { url = "https://files.pythonhosted.org/packages/3a/da/1336008d39e5d4076dddb4e0f3a52ada41429274bf558a3cc28030d324a3/faker-37.8.0.tar.gz", hash = "sha256:090bb5abbec2b30949a95ce1ba6b20d1d0ed222883d63483a0d4be4a970d6fb8", size = 1912113 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/7d/8b50e4ac772719777be33661f4bde320793400a706f5eb214e4de46f093c/faker-37.6.0-py3-none-any.whl", hash = "sha256:3c5209b23d7049d596a51db5d76403a0ccfea6fc294ffa2ecfef6a8843b1e6a7", size = 1949837 },
|
{ url = "https://files.pythonhosted.org/packages/f5/11/02ebebb09ff2104b690457cb7bc6ed700c9e0ce88cf581486bb0a5d3c88b/faker-37.8.0-py3-none-any.whl", hash = "sha256:b08233118824423b5fc239f7dd51f145e7018082b4164f8da6a9994e1f1ae793", size = 1953940 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.116.1"
|
version = "0.118.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485 }
|
sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631 },
|
{ url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -352,6 +467,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
|
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtts"
|
||||||
|
version = "2.5.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/79/5ddb1dfcd663581d0d3fca34ccb1d8d841b47c22a24dc8dce416e3d87dfa/gtts-2.5.4.tar.gz", hash = "sha256:f5737b585f6442f677dbe8773424fd50697c75bdf3e36443585e30a8d48c1884", size = 24018 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/6c/8b8b1fdcaee7e268536f1bb00183a5894627726b54a9ddc6fc9909888447/gTTS-2.5.4-py3-none-any.whl", hash = "sha256:5dd579377f9f5546893bc26315ab1f846933dc27a054764b168f141065ca8436", size = 29184 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -441,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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
@@ -502,34 +642,34 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.17.1"
|
version = "1.18.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mypy-extensions" },
|
{ name = "mypy-extensions" },
|
||||||
{ name = "pathspec" },
|
{ name = "pathspec" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570 }
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295 },
|
{ 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/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355 },
|
{ 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/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285 },
|
{ 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/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895 },
|
{ 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/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025 },
|
{ 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/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664 },
|
{ 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/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338 },
|
{ 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/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066 },
|
{ 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/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473 },
|
{ 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/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296 },
|
{ 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/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657 },
|
{ 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/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320 },
|
{ 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/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037 },
|
{ 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/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550 },
|
{ 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/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963 },
|
{ 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/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189 },
|
{ 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/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322 },
|
{ 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/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879 },
|
{ 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/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411 },
|
{ url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -568,6 +708,96 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg"
|
||||||
|
version = "3.2.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/f1/0258a123c045afaf3c3b60c22ccff077bceeb24b8dc2c593270899353bd0/psycopg-3.2.10.tar.gz", hash = "sha256:0bce99269d16ed18401683a8569b2c5abd94f72f8364856d56c0389bcd50972a", size = 160380 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/90/422ffbbeeb9418c795dae2a768db860401446af0c6768bc061ce22325f58/psycopg-3.2.10-py3-none-any.whl", hash = "sha256:ab5caf09a9ec42e314a21f5216dbcceac528e0e05142e42eea83a3b28b320ac3", size = 206586 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
binary = [
|
||||||
|
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg-binary"
|
||||||
|
version = "3.2.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/34/91c127fdedf8b270b1e3acc9f849d07ee8b80194379590c6f48dcc842924/psycopg_binary-3.2.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1dee2f4d2adc9adacbfecf8254bd82f6ac95cff707e1b9b99aa721cd1ef16b47", size = 3983963 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/03/1d10ce2bf70cf549a8019639dc0c49be03e41092901d4324371a968b8c01/psycopg_binary-3.2.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8b45e65383da9c4a42a56f817973e521e893f4faae897fe9f1a971f9fe799742", size = 4069171 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/5e/39cb924d6e119145aa5fc5532f48e79c67e13a76675e9366c327098db7b5/psycopg_binary-3.2.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:484d2b1659afe0f8f1cef5ea960bb640e96fa864faf917086f9f833f5c7a8034", size = 4610780 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/05/5a1282ebc4e39f5890abdd4bb7edfe9d19e4667497a1793ad288a8b81826/psycopg_binary-3.2.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3bb4046973264ebc8cb7e20a83882d68577c1f26a6f8ad4fe52e4468cd9a8eee", size = 4700479 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/7a/e1c06e558ca3f37b7e6b002e555ebcfce0bf4dee6f3ae589a7444e16ce17/psycopg_binary-3.2.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14bcbcac0cab465d88b2581e43ec01af4b01c9833e663f1352e05cb41be19e44", size = 4391772 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/d6/56f449c86988c9a97dc6c5f31d3689cfe8aedb37f2a02bd3e3882465d385/psycopg_binary-3.2.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bb7f665587dfd79e69f48b34efe226149454d7aab138ed22d5431d703de2f6", size = 3858214 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/56/f9eed67c9a1701b1e315f3687ff85f2f22a0a7d0eae4505cff65ef2f2679/psycopg_binary-3.2.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d2fe9eaa367f6171ab1a21a7dcb335eb2398be7f8bb7e04a20e2260aedc6f782", size = 3528051 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/cc/636709c72540cb859566537c0a03e46c3d2c4c4c2e13f78df46b6c4082b3/psycopg_binary-3.2.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:299834cce3eec0c48aae5a5207fc8f0c558fd65f2ceab1a36693329847da956b", size = 3580117 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/a8/a2c822fa06b0dbbb8ad4b0221da2534f77bac54332d2971dbf930f64be5a/psycopg_binary-3.2.10-cp312-cp312-win_amd64.whl", hash = "sha256:e037aac8dc894d147ef33056fc826ee5072977107a3fdf06122224353a057598", size = 2878872 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/80/db840f7ebf948ab05b4793ad34d4da6ad251829d6c02714445ae8b5f1403/psycopg_binary-3.2.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55b14f2402be027fe1568bc6c4d75ac34628ff5442a70f74137dadf99f738e3b", size = 3982057 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/53/39308328bb8388b1ec3501a16128c5ada405f217c6d91b3d921b9f3c5604/psycopg_binary-3.2.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43d803fb4e108a67c78ba58f3e6855437ca25d56504cae7ebbfbd8fce9b59247", size = 4066830 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/5a/18e6f41b40c71197479468cb18703b2999c6e4ab06f9c05df3bf416a55d7/psycopg_binary-3.2.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:470594d303928ab72a1ffd179c9c7bde9d00f76711d6b0c28f8a46ddf56d9807", size = 4610747 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/ab/9198fed279aca238c245553ec16504179d21aad049958a2865d0aa797db4/psycopg_binary-3.2.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a1d4e4d309049e3cb61269652a3ca56cb598da30ecd7eb8cea561e0d18bc1a43", size = 4700301 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/0d/59024313b5e6c5da3e2a016103494c609d73a95157a86317e0f600c8acb3/psycopg_binary-3.2.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a92ff1c2cd79b3966d6a87e26ceb222ecd5581b5ae4b58961f126af806a861ed", size = 4392679 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/47/21ef15d8a66e3a7a76a177f885173d27f0c5cbe39f5dd6eda9832d6b4e19/psycopg_binary-3.2.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac0365398947879c9827b319217096be727da16c94422e0eb3cf98c930643162", size = 3857881 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/35/c5e5402ccd40016f15d708bbf343b8cf107a58f8ae34d14dc178fdea4fd4/psycopg_binary-3.2.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:42ee399c2613b470a87084ed79b06d9d277f19b0457c10e03a4aef7059097abc", size = 3531135 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/e2/9b82946859001fe5e546c8749991b8b3b283f40d51bdc897d7a8e13e0a5e/psycopg_binary-3.2.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2028073fc12cd70ba003309d1439c0c4afab4a7eee7653b8c91213064fffe12b", size = 3581813 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/91/c10cfccb75464adb4781486e0014ecd7c2ad6decf6cbe0afd8db65ac2bc9/psycopg_binary-3.2.10-cp313-cp313-win_amd64.whl", hash = "sha256:8390db6d2010ffcaf7f2b42339a2da620a7125d37029c1f9b72dfb04a8e7be6f", size = 2881466 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/89/b0702ba0d007cc787dd7a205212c8c8cae229d1e7214c8e27bdd3b13d33e/psycopg_binary-3.2.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b34c278a58aa79562afe7f45e0455b1f4cad5974fc3d5674cc5f1f9f57e97fc5", size = 3981253 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/c9/e51ac72ac34d1d8ea7fd861008ad8de60e56997f5bd3fbae7536570f6f58/psycopg_binary-3.2.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810f65b9ef1fe9dddb5c05937884ea9563aaf4e1a2c3d138205231ed5f439511", size = 4067542 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/27/49625c79ae89959a070c1fb63ebb5c6eed426fa09e15086b6f5b626fcdc2/psycopg_binary-3.2.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8923487c3898c65e1450847e15d734bb2e6adbd2e79d2d1dd5ad829a1306bdc0", size = 4615338 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/0d/9fdb5482f50f56303770ea8a3b1c1f32105762da731c7e2a4f425e0b3887/psycopg_binary-3.2.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7950ff79df7a453ac8a7d7a74694055b6c15905b0a2b6e3c99eb59c51a3f9bf7", size = 4703401 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f3/eb2f75ca2c090bf1d0c90d6da29ef340876fe4533bcfc072a9fd94dd52b4/psycopg_binary-3.2.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c2b95e83fda70ed2b0b4fadd8538572e4a4d987b721823981862d1ab56cc760", size = 4393458 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/2e/887abe0591b2f1c1af31164b9efb46c5763e4418f403503bc9fbddaa02ef/psycopg_binary-3.2.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20384985fbc650c09a547a13c6d7f91bb42020d38ceafd2b68b7fc4a48a1f160", size = 3863733 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/8c/9446e3a84187220a98657ef778518f9b44eba55b1f6c3e8300d229ec9930/psycopg_binary-3.2.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1f6982609b8ff8fcd67299b67cd5787da1876f3bb28fedd547262cfa8ddedf94", size = 3535121 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/e1/f0382c956bfaa951a0dbd4d5a354acf093ef7e5219996958143dfd2bf37d/psycopg_binary-3.2.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf30dcf6aaaa8d4779a20d2158bdf81cc8e84ce8eee595d748a7671c70c7b890", size = 3584235 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/dd/464bd739bacb3b745a1c93bc15f20f0b1e27f0a64ec693367794b398673b/psycopg_binary-3.2.10-cp314-cp314-win_amd64.whl", hash = "sha256:d5c6a66a76022af41970bf19f51bc6bf87bd10165783dd1d40484bfd87d6b382", size = 2973554 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulsectl"
|
||||||
|
version = "24.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/c5/f070a8c5f0a5742f7aebb5d90869ee1805174c03928dfafd3833de58bd57/pulsectl-24.12.0.tar.gz", hash = "sha256:288d6715232ac6f3dcdb123fbecaa2c0b9a50ea4087e6e87c3f841ab0a8a07fc", size = 41200 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a9/5b119f86dd1a053c55da7d0355fca2ad215bae6f7f4777d46b307a8cc3e9/pulsectl-24.12.0-py2.py3-none-any.whl", hash = "sha256:13a60be940594f03ead3245b3dfe3aff4a3f9a792af347674bde5e716d4f76d2", size = 35133 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycaw"
|
||||||
|
version = "20240210"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "comtypes" },
|
||||||
|
{ name = "psutil" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/1a/f1fa3ceca06eceb5184b907413306b99dd790855ffdf2aee8210fa0fc192/pycaw-20240210.tar.gz", hash = "sha256:55e49359e9f227053f4fa15817a02d4a7bc52fc3db32e123894719a123c41d06", size = 22417 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/e2/89e3e096d8926f19cbcf2991ae86d19e6705ea75ad0212862461cb4b83d8/pycaw-20240210-py3-none-any.whl", hash = "sha256:fbbe0ee67a7d32714240e26913266a386ae4375778c417a7e8ad6076eca62f1e", size = 24760 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.11.7"
|
version = "2.11.7"
|
||||||
@@ -632,16 +862,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-settings"
|
name = "pydantic-settings"
|
||||||
version = "2.10.1"
|
version = "2.11.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583 }
|
sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235 },
|
{ url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -664,7 +894,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "8.4.1"
|
version = "8.4.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
@@ -673,21 +903,22 @@ dependencies = [
|
|||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714 }
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 },
|
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-asyncio"
|
name = "pytest-asyncio"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652 }
|
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157 },
|
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -722,15 +953,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-socketio"
|
name = "python-socketio"
|
||||||
version = "5.13.0"
|
version = "5.14.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bidict" },
|
{ name = "bidict" },
|
||||||
{ name = "python-engineio" },
|
{ name = "python-engineio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125 }
|
sdist = { url = "https://files.pythonhosted.org/packages/05/c2/a9ae3d0eb4488748a2d9c15defddb7277a852234e29e50c73136834dff1b/python_socketio-5.14.1.tar.gz", hash = "sha256:bf49657073b90ee09e4cbd6651044b46bb526694276621e807a1b8fcc0c1b25b", size = 123068 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800 },
|
{ url = "https://files.pythonhosted.org/packages/d5/5e/302c3499a134a52b68e4e6fb345cea52ab1c41460949bcdb09f8bd0e3594/python_socketio-5.14.1-py3-none-any.whl", hash = "sha256:3419f5917f0e3942317836a77146cb4caa23ad804c8fd1a7e3f44a6657a8406e", size = 78496 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -777,6 +1008,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
|
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "14.0.0"
|
version = "14.0.0"
|
||||||
@@ -852,28 +1098,28 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.12.11"
|
version = "0.13.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103 }
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885 },
|
{ 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/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364 },
|
{ 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/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111 },
|
{ 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/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060 },
|
{ 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/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848 },
|
{ 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/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288 },
|
{ 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/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633 },
|
{ 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/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430 },
|
{ 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/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133 },
|
{ 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/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082 },
|
{ 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/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490 },
|
{ 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/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928 },
|
{ 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/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513 },
|
{ 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/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154 },
|
{ 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/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653 },
|
{ 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/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270 },
|
{ 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/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600 },
|
{ 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/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290 },
|
{ url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -882,12 +1128,18 @@ version = "2.0.0"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "alembic" },
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
{ name = "asyncpg" },
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "email-validator" },
|
{ name = "email-validator" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "ffmpeg-python" },
|
{ name = "ffmpeg-python" },
|
||||||
|
{ name = "gtts" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
|
{ name = "pulsectl" },
|
||||||
|
{ name = "pycaw" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "python-socketio" },
|
{ name = "python-socketio" },
|
||||||
@@ -912,31 +1164,37 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", specifier = "==0.21.0" },
|
{ name = "aiosqlite", specifier = "==0.21.0" },
|
||||||
|
{ name = "alembic", specifier = "==1.16.5" },
|
||||||
{ name = "apscheduler", specifier = "==3.11.0" },
|
{ name = "apscheduler", specifier = "==3.11.0" },
|
||||||
{ name = "bcrypt", specifier = "==4.3.0" },
|
{ name = "asyncpg", specifier = "==0.30.0" },
|
||||||
|
{ name = "bcrypt", specifier = "==5.0.0" },
|
||||||
{ name = "email-validator", specifier = "==2.3.0" },
|
{ name = "email-validator", specifier = "==2.3.0" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = "==0.116.1" },
|
{ name = "fastapi", extras = ["standard"], specifier = "==0.118.0" },
|
||||||
{ name = "ffmpeg-python", specifier = "==0.2.0" },
|
{ name = "ffmpeg-python", specifier = "==0.2.0" },
|
||||||
|
{ name = "gtts", specifier = "==2.5.4" },
|
||||||
{ name = "httpx", specifier = "==0.28.1" },
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
{ name = "pydantic-settings", specifier = "==2.10.1" },
|
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.10" },
|
||||||
|
{ name = "pulsectl", specifier = ">=24.12.0" },
|
||||||
|
{ name = "pycaw", specifier = ">=20240210" },
|
||||||
|
{ name = "pydantic-settings", specifier = "==2.11.0" },
|
||||||
{ name = "pyjwt", specifier = "==2.10.1" },
|
{ name = "pyjwt", specifier = "==2.10.1" },
|
||||||
{ name = "python-socketio", specifier = "==5.13.0" },
|
{ name = "python-socketio", specifier = "==5.14.1" },
|
||||||
{ name = "python-vlc", specifier = "==3.0.21203" },
|
{ name = "python-vlc", specifier = "==3.0.21203" },
|
||||||
{ name = "pytz", specifier = "==2025.2" },
|
{ name = "pytz", specifier = "==2025.2" },
|
||||||
{ name = "sqlmodel", specifier = "==0.0.24" },
|
{ name = "sqlmodel", specifier = "==0.0.25" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = "==0.37.0" },
|
||||||
{ name = "yt-dlp", specifier = "==2025.8.27" },
|
{ name = "yt-dlp", specifier = "==2025.9.26" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "coverage", specifier = "==7.10.5" },
|
{ name = "coverage", specifier = "==7.10.7" },
|
||||||
{ name = "faker", specifier = "==37.6.0" },
|
{ name = "faker", specifier = "==37.8.0" },
|
||||||
{ name = "httpx", specifier = "==0.28.1" },
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
{ name = "mypy", specifier = "==1.17.1" },
|
{ name = "mypy", specifier = "==1.18.2" },
|
||||||
{ name = "pytest", specifier = "==8.4.1" },
|
{ name = "pytest", specifier = "==8.4.2" },
|
||||||
{ name = "pytest-asyncio", specifier = "==1.1.0" },
|
{ name = "pytest-asyncio", specifier = "==1.2.0" },
|
||||||
{ name = "ruff", specifier = "==0.12.11" },
|
{ name = "ruff", specifier = "==0.13.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1013,15 +1271,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlmodel"
|
name = "sqlmodel"
|
||||||
version = "0.0.24"
|
version = "0.0.25"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780 }
|
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/d9c098a88724ee4554907939cf39590cf67e10c6683723216e228d3315f7/sqlmodel-0.0.25.tar.gz", hash = "sha256:56548c2e645975b1ed94d6c53f0d13c85593f57926a575e2bf566650b2243fa4", size = 117075 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622 },
|
{ url = "https://files.pythonhosted.org/packages/57/cf/5d175ce8de07fe694ec4e3d4d65c2dd06cc30f6c79599b31f9d2f6dd2830/sqlmodel-0.0.25-py3-none-any.whl", hash = "sha256:c98234cda701fb77e9dcbd81688c23bb251c13bb98ce1dd8d4adc467374d45b7", size = 28893 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1105,15 +1363,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.35.0"
|
version = "0.37.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "h11" },
|
{ name = "h11" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473 }
|
sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406 },
|
{ url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -1259,9 +1517,9 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2025.8.27"
|
version = "2025.9.26"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/d4/d9dd231b03f09fdfb5f0fe70f30de0b5f59454aa54fa6b2b2aea49404988/yt_dlp-2025.8.27.tar.gz", hash = "sha256:ed74768d2a93b29933ab14099da19497ef571637f7aa375140dd3d882b9c1854", size = 3038374 }
|
sdist = { url = "https://files.pythonhosted.org/packages/58/8f/0daea0feec1ab85e7df85b98ec7cc8c85d706362e80efc5375c7007dc3dc/yt_dlp-2025.9.26.tar.gz", hash = "sha256:c148ae8233ac4ce6c5fbf6f70fcc390f13a00f59da3776d373cf88c5370bda86", size = 3037475 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/9c/b69fc0c800f80b94ea2f8eff1d1f473fecee6aa337681d297ba7c7c5d3fd/yt_dlp-2025.8.27-py3-none-any.whl", hash = "sha256:0b8fd3bb7c54bc2e7ecb5cdac7d64c30e2503ea4d3dd9ae24d4f09e22aaa95f4", size = 3267059 },
|
{ url = "https://files.pythonhosted.org/packages/35/94/18210c5e6a9d7e622a3b3f4a73dde205f7adf0c46b42b27d0da8c6e5c872/yt_dlp-2025.9.26-py3-none-any.whl", hash = "sha256:36f5fbc153600f759abd48d257231f0e0a547a115ac7ffb05d5b64e5c7fdf8a2", size = 3241906 },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user