Compare commits
6 Commits
95e166eefb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4c72f3b19 | ||
|
|
17eafa4872 | ||
|
|
c9f6bff723 | ||
|
|
12243b1424 | ||
|
|
f7197a89a7 | ||
|
|
b66b8e36bb |
@@ -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.
|
||||
@@ -63,7 +63,10 @@ async def get_top_users(
|
||||
metric_type: Annotated[
|
||||
str,
|
||||
Query(
|
||||
description="Metric type: sounds_played, credits_used, tracks_added, tts_added, playlists_created",
|
||||
description=(
|
||||
"Metric type: sounds_played, credits_used, tracks_added, "
|
||||
"tts_added, playlists_created"
|
||||
),
|
||||
),
|
||||
],
|
||||
period: Annotated[
|
||||
|
||||
@@ -249,3 +249,21 @@ async def get_state(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get player state",
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/play-next/{sound_id}")
|
||||
async def add_to_play_next(
|
||||
sound_id: int,
|
||||
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
|
||||
) -> MessageResponse:
|
||||
"""Add a sound to the play next queue."""
|
||||
try:
|
||||
player = get_player_service()
|
||||
await player.add_to_play_next(sound_id)
|
||||
return MessageResponse(message=f"Added sound {sound_id} to play next queue")
|
||||
except Exception as e:
|
||||
logger.exception("Error adding sound to play next queue: %s", sound_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to add sound to play next queue",
|
||||
) from e
|
||||
|
||||
@@ -201,7 +201,10 @@ class SoundRepository(BaseRepository[Sound]):
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_soundboard_statistics(self, sound_type: str = "SDB") -> dict[str, int | float]:
|
||||
async def get_soundboard_statistics(
|
||||
self,
|
||||
sound_type: str = "SDB",
|
||||
) -> dict[str, int | float]:
|
||||
"""Get statistics for sounds of a specific type."""
|
||||
try:
|
||||
statement = select(
|
||||
|
||||
@@ -4,19 +4,19 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import Select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.plan import Plan
|
||||
from app.models.user import User
|
||||
from app.models.sound_played import SoundPlayed
|
||||
from app.models.credit_transaction import CreditTransaction
|
||||
from app.models.extraction import Extraction
|
||||
from app.models.plan import Plan
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.sound import Sound
|
||||
from app.models.sound_played import SoundPlayed
|
||||
from app.models.tts import TTS
|
||||
from app.models.user import User
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -232,81 +232,7 @@ class UserRepository(BaseRepository[User]):
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get top users by different metrics."""
|
||||
try:
|
||||
if metric_type == "sounds_played":
|
||||
# Get users with most sounds played
|
||||
query = (
|
||||
select(
|
||||
User.id,
|
||||
User.name,
|
||||
func.count(SoundPlayed.id).label("count")
|
||||
)
|
||||
.join(SoundPlayed, User.id == SoundPlayed.user_id)
|
||||
.group_by(User.id, User.name)
|
||||
)
|
||||
if date_filter:
|
||||
query = query.where(SoundPlayed.created_at >= date_filter)
|
||||
|
||||
elif metric_type == "credits_used":
|
||||
# Get users with most credits used (negative transactions)
|
||||
query = (
|
||||
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)
|
||||
)
|
||||
if date_filter:
|
||||
query = query.where(CreditTransaction.created_at >= date_filter)
|
||||
|
||||
elif metric_type == "tracks_added":
|
||||
# Get users with most EXT sounds added
|
||||
query = (
|
||||
select(
|
||||
User.id,
|
||||
User.name,
|
||||
func.count(Sound.id).label("count")
|
||||
)
|
||||
.join(Sound, User.id == Sound.user_id)
|
||||
.where(Sound.type == "EXT")
|
||||
.group_by(User.id, User.name)
|
||||
)
|
||||
if date_filter:
|
||||
query = query.where(Sound.created_at >= date_filter)
|
||||
|
||||
elif metric_type == "tts_added":
|
||||
# Get users with most TTS sounds added
|
||||
query = (
|
||||
select(
|
||||
User.id,
|
||||
User.name,
|
||||
func.count(TTS.id).label("count")
|
||||
)
|
||||
.join(TTS, User.id == TTS.user_id)
|
||||
.group_by(User.id, User.name)
|
||||
)
|
||||
if date_filter:
|
||||
query = query.where(TTS.created_at >= date_filter)
|
||||
|
||||
elif metric_type == "playlists_created":
|
||||
# Get users with most playlists created
|
||||
query = (
|
||||
select(
|
||||
User.id,
|
||||
User.name,
|
||||
func.count(Playlist.id).label("count")
|
||||
)
|
||||
.join(Playlist, User.id == Playlist.user_id)
|
||||
.group_by(User.id, User.name)
|
||||
)
|
||||
if date_filter:
|
||||
query = query.where(Playlist.created_at >= date_filter)
|
||||
|
||||
else:
|
||||
msg = f"Unknown metric type: {metric_type}"
|
||||
raise ValueError(msg)
|
||||
query = self._build_top_users_query(metric_type, date_filter)
|
||||
|
||||
# Add ordering and limit
|
||||
query = query.order_by(func.count().desc()).limit(limit)
|
||||
@@ -330,3 +256,113 @@ class UserRepository(BaseRepository[User]):
|
||||
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,
|
||||
description="Current track index in playlist",
|
||||
)
|
||||
play_next_queue: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Play next queue",
|
||||
)
|
||||
|
||||
@@ -13,7 +13,11 @@ logger = get_logger(__name__)
|
||||
class DashboardService:
|
||||
"""Service for dashboard statistics and analytics."""
|
||||
|
||||
def __init__(self, sound_repository: SoundRepository, user_repository: UserRepository) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
sound_repository: SoundRepository,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
"""Initialize the dashboard service."""
|
||||
self.sound_repository = sound_repository
|
||||
self.user_repository = user_repository
|
||||
|
||||
@@ -8,6 +8,8 @@ from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import vlc # type: ignore[import-untyped]
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.playlist import Playlist
|
||||
@@ -62,6 +64,8 @@ class PlayerState:
|
||||
self.playlist_length: int = 0
|
||||
self.playlist_duration: int = 0
|
||||
self.playlist_sounds: list[Sound] = []
|
||||
self.play_next_queue: list[Sound] = []
|
||||
self.playlist_index_before_play_next: int | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert player state to dictionary for serialization."""
|
||||
@@ -87,6 +91,9 @@ class PlayerState:
|
||||
if self.playlist_id
|
||||
else None
|
||||
),
|
||||
"play_next_queue": [
|
||||
self._serialize_sound(sound) for sound in self.play_next_queue
|
||||
],
|
||||
}
|
||||
|
||||
def _serialize_sound(self, sound: Sound | None) -> dict[str, Any] | None:
|
||||
@@ -342,6 +349,31 @@ class PlayerService:
|
||||
|
||||
async def next(self) -> None:
|
||||
"""Skip to next track."""
|
||||
# Check if there's a track in the play_next queue
|
||||
if self.state.play_next_queue:
|
||||
await self._play_next_from_queue()
|
||||
return
|
||||
|
||||
# If currently playing from play_next queue (no index but have stored index)
|
||||
if (
|
||||
self.state.current_sound_index is None
|
||||
and self.state.playlist_index_before_play_next is not None
|
||||
and self.state.playlist_sounds
|
||||
):
|
||||
# Skipped the last play_next track, go to next in playlist
|
||||
restored_index = self.state.playlist_index_before_play_next
|
||||
next_index = self._get_next_index(restored_index)
|
||||
|
||||
# Clear the stored index
|
||||
self.state.playlist_index_before_play_next = None
|
||||
|
||||
if next_index is not None:
|
||||
await self.play(next_index)
|
||||
else:
|
||||
await self._stop_playback()
|
||||
await self._broadcast_state()
|
||||
return
|
||||
|
||||
if not self.state.playlist_sounds:
|
||||
return
|
||||
|
||||
@@ -431,6 +463,66 @@ class PlayerService:
|
||||
await self._broadcast_state()
|
||||
logger.info("Playback mode set to: %s", mode.value)
|
||||
|
||||
async def add_to_play_next(self, sound_id: int) -> None:
|
||||
"""Add a sound to the play_next queue."""
|
||||
session = self.db_session_factory()
|
||||
try:
|
||||
# Eagerly load extractions to avoid lazy loading issues
|
||||
statement = select(Sound).where(Sound.id == sound_id)
|
||||
statement = statement.options(selectinload(Sound.extractions)) # type: ignore[arg-type]
|
||||
result = await session.exec(statement)
|
||||
sound = result.first()
|
||||
|
||||
if not sound:
|
||||
logger.warning("Sound %s not found for play_next", sound_id)
|
||||
return
|
||||
|
||||
self.state.play_next_queue.append(sound)
|
||||
await self._broadcast_state()
|
||||
logger.info("Added sound %s to play_next queue", sound.name)
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def _play_next_from_queue(self) -> None:
|
||||
"""Play the first track from the play_next queue."""
|
||||
if not self.state.play_next_queue:
|
||||
return
|
||||
|
||||
# Store current playlist index before switching to play_next track
|
||||
# Only store if we're currently playing from the playlist
|
||||
if (
|
||||
self.state.current_sound_index is not None
|
||||
and self.state.playlist_index_before_play_next is None
|
||||
):
|
||||
self.state.playlist_index_before_play_next = (
|
||||
self.state.current_sound_index
|
||||
)
|
||||
logger.info(
|
||||
"Stored playlist index %s before playing from play_next queue",
|
||||
self.state.playlist_index_before_play_next,
|
||||
)
|
||||
|
||||
# Get the first sound from the queue
|
||||
next_sound = self.state.play_next_queue.pop(0)
|
||||
|
||||
# Stop current playback and process play count
|
||||
if self.state.status != PlayerStatus.STOPPED:
|
||||
await self._stop_playback()
|
||||
|
||||
# Set the sound as current (without index since it's from play_next)
|
||||
self.state.current_sound = next_sound
|
||||
self.state.current_sound_id = next_sound.id
|
||||
self.state.current_sound_index = None # No index for play_next tracks
|
||||
|
||||
# Play the sound
|
||||
if not self._validate_sound_file():
|
||||
return
|
||||
|
||||
if not self._load_and_play_media():
|
||||
return
|
||||
|
||||
await self._handle_successful_playback()
|
||||
|
||||
async def reload_playlist(self) -> None:
|
||||
"""Reload current playlist from database."""
|
||||
session = self.db_session_factory()
|
||||
@@ -519,6 +611,16 @@ class PlayerService:
|
||||
current_id,
|
||||
)
|
||||
|
||||
# Clear play_next queue when playlist changes
|
||||
if self.state.play_next_queue:
|
||||
logger.info("Clearing play_next queue due to playlist change")
|
||||
self.state.play_next_queue.clear()
|
||||
|
||||
# Clear stored playlist index
|
||||
if self.state.playlist_index_before_play_next is not None:
|
||||
logger.info("Clearing stored playlist index due to playlist change")
|
||||
self.state.playlist_index_before_play_next = None
|
||||
|
||||
if self.state.status != PlayerStatus.STOPPED:
|
||||
await self._stop_playback()
|
||||
|
||||
@@ -534,6 +636,9 @@ class PlayerService:
|
||||
sounds: list[Sound],
|
||||
) -> None:
|
||||
"""Handle track checking when playlist ID is the same."""
|
||||
# Remove tracks from play_next queue that are no longer in the playlist
|
||||
self._clean_play_next_queue(sounds)
|
||||
|
||||
# Find the current track in the new playlist
|
||||
new_index = self._find_sound_index(previous_sound_id, sounds)
|
||||
|
||||
@@ -591,6 +696,29 @@ class PlayerService:
|
||||
return i
|
||||
return None
|
||||
|
||||
def _clean_play_next_queue(self, playlist_sounds: list[Sound]) -> None:
|
||||
"""Remove tracks from play_next queue that are no longer in the playlist."""
|
||||
if not self.state.play_next_queue:
|
||||
return
|
||||
|
||||
# Get IDs of all sounds in the current playlist
|
||||
playlist_sound_ids = {sound.id for sound in playlist_sounds}
|
||||
|
||||
# Filter out tracks that are no longer in the playlist
|
||||
original_length = len(self.state.play_next_queue)
|
||||
self.state.play_next_queue = [
|
||||
sound
|
||||
for sound in self.state.play_next_queue
|
||||
if sound.id in playlist_sound_ids
|
||||
]
|
||||
|
||||
removed_count = original_length - len(self.state.play_next_queue)
|
||||
if removed_count > 0:
|
||||
logger.info(
|
||||
"Removed %s track(s) from play_next queue (no longer in playlist)",
|
||||
removed_count,
|
||||
)
|
||||
|
||||
def _set_first_track_as_current(self, sounds: list[Sound]) -> None:
|
||||
"""Set the first track as the current track."""
|
||||
self.state.current_sound_index = 0
|
||||
@@ -780,7 +908,12 @@ class PlayerService:
|
||||
"""Handle when a track finishes playing."""
|
||||
await self._process_play_count()
|
||||
|
||||
# Auto-advance to next track
|
||||
# Check if there's a track in the play_next queue
|
||||
if self.state.play_next_queue:
|
||||
await self._play_next_from_queue()
|
||||
return
|
||||
|
||||
# Auto-advance to next track in playlist
|
||||
if self.state.current_sound_index is not None:
|
||||
next_index = self._get_next_index(self.state.current_sound_index)
|
||||
if next_index is not None:
|
||||
@@ -788,6 +921,32 @@ class PlayerService:
|
||||
else:
|
||||
await self._stop_playback()
|
||||
await self._broadcast_state()
|
||||
elif (
|
||||
self.state.playlist_sounds
|
||||
and self.state.playlist_index_before_play_next is not None
|
||||
):
|
||||
# Current track was from play_next queue, restore to next track in playlist
|
||||
restored_index = self.state.playlist_index_before_play_next
|
||||
logger.info(
|
||||
"Play next queue finished, continuing from playlist index %s",
|
||||
restored_index,
|
||||
)
|
||||
|
||||
# Get the next index based on the stored position
|
||||
next_index = self._get_next_index(restored_index)
|
||||
|
||||
# Clear the stored index since we're done with play_next queue
|
||||
self.state.playlist_index_before_play_next = None
|
||||
|
||||
if next_index is not None:
|
||||
await self.play(next_index)
|
||||
else:
|
||||
# No next track (end of playlist in non-loop mode)
|
||||
await self._stop_playback()
|
||||
await self._broadcast_state()
|
||||
else:
|
||||
await self._stop_playback()
|
||||
await self._broadcast_state()
|
||||
|
||||
async def _broadcast_state(self) -> None:
|
||||
"""Broadcast current player state via WebSocket."""
|
||||
|
||||
@@ -10,13 +10,13 @@ dependencies = [
|
||||
"apscheduler==3.11.0",
|
||||
"bcrypt==5.0.0",
|
||||
"email-validator==2.3.0",
|
||||
"fastapi[standard]==0.117.1",
|
||||
"fastapi[standard]==0.118.0",
|
||||
"ffmpeg-python==0.2.0",
|
||||
"gtts==2.5.4",
|
||||
"httpx==0.28.1",
|
||||
"pydantic-settings==2.11.0",
|
||||
"pyjwt==2.10.1",
|
||||
"python-socketio==5.13.0",
|
||||
"python-socketio==5.14.1",
|
||||
"pytz==2025.2",
|
||||
"python-vlc==3.0.21203",
|
||||
"sqlmodel==0.0.25",
|
||||
@@ -36,7 +36,7 @@ dev-dependencies = [
|
||||
"mypy==1.18.2",
|
||||
"pytest==8.4.2",
|
||||
"pytest-asyncio==1.2.0",
|
||||
"ruff==0.13.2",
|
||||
"ruff==0.13.3",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
|
||||
@@ -537,6 +537,7 @@ class TestPlayerEndpoints:
|
||||
"duration": 30000,
|
||||
"sounds": [],
|
||||
},
|
||||
"play_next_queue": [],
|
||||
}
|
||||
mock_player_service.get_state.return_value = mock_state
|
||||
|
||||
|
||||
@@ -15,19 +15,32 @@ def mock_sound_repository():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_service(mock_sound_repository):
|
||||
def mock_user_repository():
|
||||
"""Mock user repository."""
|
||||
return Mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_service(mock_sound_repository, mock_user_repository):
|
||||
"""Dashboard service with mocked dependencies."""
|
||||
return DashboardService(sound_repository=mock_sound_repository)
|
||||
return DashboardService(
|
||||
sound_repository=mock_sound_repository,
|
||||
user_repository=mock_user_repository,
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardService:
|
||||
"""Test dashboard service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init(self, mock_sound_repository):
|
||||
async def test_init(self, mock_sound_repository, mock_user_repository):
|
||||
"""Test dashboard service initialization."""
|
||||
service = DashboardService(sound_repository=mock_sound_repository)
|
||||
service = DashboardService(
|
||||
sound_repository=mock_sound_repository,
|
||||
user_repository=mock_user_repository,
|
||||
)
|
||||
assert service.sound_repository == mock_sound_repository
|
||||
assert service.user_repository == mock_user_repository
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_soundboard_statistics_success(
|
||||
|
||||
58
uv.lock
generated
58
uv.lock
generated
@@ -353,16 +353,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.117.1"
|
||||
version = "0.118.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/7e/d9788300deaf416178f61fb3c2ceb16b7d0dc9f82a08fdb87a5e64ee3cc7/fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a", size = 307155 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/45/d9d3e8eeefbe93be1c50060a9d9a9f366dba66f288bb518a9566a23a8631/fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552", size = 95959 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -953,15 +953,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.13.0"
|
||||
version = "5.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bidict" },
|
||||
{ name = "python-engineio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/c2/a9ae3d0eb4488748a2d9c15defddb7277a852234e29e50c73136834dff1b/python_socketio-5.14.1.tar.gz", hash = "sha256:bf49657073b90ee09e4cbd6651044b46bb526694276621e807a1b8fcc0c1b25b", size = 123068 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/5e/302c3499a134a52b68e4e6fb345cea52ab1c41460949bcdb09f8bd0e3594/python_socketio-5.14.1-py3-none-any.whl", hash = "sha256:3419f5917f0e3942317836a77146cb4caa23ad804c8fd1a7e3f44a6657a8406e", size = 78496 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1098,28 +1098,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.13.2"
|
||||
version = "0.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359 },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1169,7 +1169,7 @@ requires-dist = [
|
||||
{ name = "asyncpg", specifier = "==0.30.0" },
|
||||
{ name = "bcrypt", specifier = "==5.0.0" },
|
||||
{ name = "email-validator", specifier = "==2.3.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = "==0.117.1" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = "==0.118.0" },
|
||||
{ name = "ffmpeg-python", specifier = "==0.2.0" },
|
||||
{ name = "gtts", specifier = "==2.5.4" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
@@ -1178,7 +1178,7 @@ requires-dist = [
|
||||
{ name = "pycaw", specifier = ">=20240210" },
|
||||
{ name = "pydantic-settings", specifier = "==2.11.0" },
|
||||
{ name = "pyjwt", specifier = "==2.10.1" },
|
||||
{ name = "python-socketio", specifier = "==5.13.0" },
|
||||
{ name = "python-socketio", specifier = "==5.14.1" },
|
||||
{ name = "python-vlc", specifier = "==3.0.21203" },
|
||||
{ name = "pytz", specifier = "==2025.2" },
|
||||
{ name = "sqlmodel", specifier = "==0.0.25" },
|
||||
@@ -1194,7 +1194,7 @@ dev = [
|
||||
{ name = "mypy", specifier = "==1.18.2" },
|
||||
{ name = "pytest", specifier = "==8.4.2" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.2.0" },
|
||||
{ name = "ruff", specifier = "==0.13.2" },
|
||||
{ name = "ruff", specifier = "==0.13.3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user