Compare commits

..

6 Commits

Author SHA1 Message Date
JSC
e4c72f3b19 chore: Remove unused .env.template and SCHEDULER_EXAMPLE.md files
Some checks failed
Backend CI / lint (push) Failing after 10s
Backend CI / test (push) Failing after 1m39s
2025-10-05 16:33:29 +02:00
JSC
17eafa4872 feat: Enhance play_next functionality by storing and restoring playlist index
Some checks failed
Backend CI / test (push) Failing after 2m17s
Backend CI / lint (push) Failing after 14m55s
2025-10-05 04:07:34 +02:00
JSC
c9f6bff723 refactor: Improve code readability by formatting function signatures and descriptions
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m29s
2025-10-04 22:27:12 +02:00
JSC
12243b1424 feat: Clear and manage play_next queue on playlist changes
Some checks failed
Backend CI / lint (push) Failing after 9s
Backend CI / test (push) Failing after 1m36s
2025-10-04 19:39:44 +02:00
JSC
f7197a89a7 feat: Add play next functionality to player service and API 2025-10-04 19:16:37 +02:00
JSC
b66b8e36bb feat: Enhance user metrics retrieval by integrating Extraction model and updating related queries
Some checks failed
Backend CI / lint (push) Failing after 17s
Backend CI / test (push) Failing after 2m32s
2025-10-04 13:45:36 +02:00
13 changed files with 361 additions and 381 deletions

View File

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

View File

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

View File

@@ -63,7 +63,10 @@ async def get_top_users(
metric_type: Annotated[ metric_type: Annotated[
str, str,
Query( 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[ period: Annotated[

View File

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

View File

@@ -201,7 +201,10 @@ class SoundRepository(BaseRepository[Sound]):
) )
raise 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.""" """Get statistics for sounds of a specific type."""
try: try:
statement = select( statement = select(

View File

@@ -4,19 +4,19 @@ 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.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.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.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.tts import TTS
from app.models.user import User
from app.repositories.base import BaseRepository from app.repositories.base import BaseRepository
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -232,81 +232,7 @@ class UserRepository(BaseRepository[User]):
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get top users by different metrics.""" """Get top users by different metrics."""
try: try:
if metric_type == "sounds_played": query = self._build_top_users_query(metric_type, date_filter)
# 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)
# Add ordering and limit # Add ordering and limit
query = query.order_by(func.count().desc()).limit(limit) query = query.order_by(func.count().desc()).limit(limit)
@@ -330,3 +256,113 @@ class UserRepository(BaseRepository[User]):
date_filter, date_filter,
) )
raise raise
def _build_top_users_query(
self,
metric_type: str,
date_filter: datetime | None,
) -> Select:
"""Build query for top users based on metric type."""
match metric_type:
case "sounds_played":
query = self._build_sounds_played_query()
case "credits_used":
query = self._build_credits_used_query()
case "tracks_added":
query = self._build_tracks_added_query()
case "tts_added":
query = self._build_tts_added_query()
case "playlists_created":
query = self._build_playlists_created_query()
case _:
msg = f"Unknown metric type: {metric_type}"
raise ValueError(msg)
# Apply date filter if provided
if date_filter:
query = self._apply_date_filter(query, metric_type, date_filter)
return query
def _build_sounds_played_query(self) -> Select:
"""Build query for sounds played metric."""
return (
select(
User.id,
User.name,
func.count(SoundPlayed.id).label("count"),
)
.join(SoundPlayed, User.id == SoundPlayed.user_id)
.group_by(User.id, User.name)
)
def _build_credits_used_query(self) -> Select:
"""Build query for credits used metric."""
return (
select(
User.id,
User.name,
func.sum(func.abs(CreditTransaction.amount)).label("count"),
)
.join(CreditTransaction, User.id == CreditTransaction.user_id)
.where(CreditTransaction.amount < 0)
.group_by(User.id, User.name)
)
def _build_tracks_added_query(self) -> Select:
"""Build query for tracks added metric."""
return (
select(
User.id,
User.name,
func.count(Extraction.id).label("count"),
)
.join(Extraction, User.id == Extraction.user_id)
.where(Extraction.sound_id.is_not(None))
.group_by(User.id, User.name)
)
def _build_tts_added_query(self) -> Select:
"""Build query for TTS added metric."""
return (
select(
User.id,
User.name,
func.count(TTS.id).label("count"),
)
.join(TTS, User.id == TTS.user_id)
.group_by(User.id, User.name)
)
def _build_playlists_created_query(self) -> Select:
"""Build query for playlists created metric."""
return (
select(
User.id,
User.name,
func.count(Playlist.id).label("count"),
)
.join(Playlist, User.id == Playlist.user_id)
.group_by(User.id, User.name)
)
def _apply_date_filter(
self,
query: Select,
metric_type: str,
date_filter: datetime,
) -> Select:
"""Apply date filter to query based on metric type."""
match metric_type:
case "sounds_played":
return query.where(SoundPlayed.created_at >= date_filter)
case "credits_used":
return query.where(CreditTransaction.created_at >= date_filter)
case "tracks_added":
return query.where(Extraction.created_at >= date_filter)
case "tts_added":
return query.where(TTS.created_at >= date_filter)
case "playlists_created":
return query.where(Playlist.created_at >= date_filter)
case _:
return query

View File

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

View File

@@ -13,7 +13,11 @@ 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, user_repository: UserRepository) -> 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 self.user_repository = user_repository

View File

@@ -8,6 +8,8 @@ from enum import Enum
from typing import Any from typing import Any
import vlc # type: ignore[import-untyped] import vlc # type: ignore[import-untyped]
from sqlalchemy.orm import selectinload
from sqlmodel import select
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.playlist import Playlist from app.models.playlist import Playlist
@@ -62,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."""
@@ -87,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:
@@ -342,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
@@ -431,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()
@@ -519,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()
@@ -534,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)
@@ -591,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
@@ -780,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:
@@ -788,6 +921,32 @@ class PlayerService:
else: else:
await self._stop_playback() await self._stop_playback()
await self._broadcast_state() await self._broadcast_state()
elif (
self.state.playlist_sounds
and self.state.playlist_index_before_play_next is not None
):
# Current track was from play_next queue, restore to next track in playlist
restored_index = self.state.playlist_index_before_play_next
logger.info(
"Play next queue finished, continuing from playlist index %s",
restored_index,
)
# Get the next index based on the stored position
next_index = self._get_next_index(restored_index)
# Clear the stored index since we're done with play_next queue
self.state.playlist_index_before_play_next = None
if next_index is not None:
await self.play(next_index)
else:
# No next track (end of playlist in non-loop mode)
await self._stop_playback()
await self._broadcast_state()
else:
await self._stop_playback()
await self._broadcast_state()
async def _broadcast_state(self) -> None: async def _broadcast_state(self) -> None:
"""Broadcast current player state via WebSocket.""" """Broadcast current player state via WebSocket."""

View File

@@ -10,13 +10,13 @@ dependencies = [
"apscheduler==3.11.0", "apscheduler==3.11.0",
"bcrypt==5.0.0", "bcrypt==5.0.0",
"email-validator==2.3.0", "email-validator==2.3.0",
"fastapi[standard]==0.117.1", "fastapi[standard]==0.118.0",
"ffmpeg-python==0.2.0", "ffmpeg-python==0.2.0",
"gtts==2.5.4", "gtts==2.5.4",
"httpx==0.28.1", "httpx==0.28.1",
"pydantic-settings==2.11.0", "pydantic-settings==2.11.0",
"pyjwt==2.10.1", "pyjwt==2.10.1",
"python-socketio==5.13.0", "python-socketio==5.14.1",
"pytz==2025.2", "pytz==2025.2",
"python-vlc==3.0.21203", "python-vlc==3.0.21203",
"sqlmodel==0.0.25", "sqlmodel==0.0.25",
@@ -36,7 +36,7 @@ dev-dependencies = [
"mypy==1.18.2", "mypy==1.18.2",
"pytest==8.4.2", "pytest==8.4.2",
"pytest-asyncio==1.2.0", "pytest-asyncio==1.2.0",
"ruff==0.13.2", "ruff==0.13.3",
] ]
[tool.mypy] [tool.mypy]

View File

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

View File

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

58
uv.lock generated
View File

@@ -353,16 +353,16 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.117.1" version = "0.118.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "starlette" }, { name = "starlette" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7e/7e/d9788300deaf416178f61fb3c2ceb16b7d0dc9f82a08fdb87a5e64ee3cc7/fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a", size = 307155 } sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/45/d9d3e8eeefbe93be1c50060a9d9a9f366dba66f288bb518a9566a23a8631/fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552", size = 95959 }, { url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694 },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -953,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]]
@@ -1098,28 +1098,28 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.13.2" version = "0.13.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990 }, { 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/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004 }, { 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/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437 }, { 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]]
@@ -1169,7 +1169,7 @@ requires-dist = [
{ name = "asyncpg", specifier = "==0.30.0" }, { name = "asyncpg", specifier = "==0.30.0" },
{ name = "bcrypt", specifier = "==5.0.0" }, { name = "bcrypt", specifier = "==5.0.0" },
{ name = "email-validator", specifier = "==2.3.0" }, { name = "email-validator", specifier = "==2.3.0" },
{ name = "fastapi", extras = ["standard"], specifier = "==0.117.1" }, { name = "fastapi", extras = ["standard"], specifier = "==0.118.0" },
{ name = "ffmpeg-python", specifier = "==0.2.0" }, { name = "ffmpeg-python", specifier = "==0.2.0" },
{ name = "gtts", specifier = "==2.5.4" }, { name = "gtts", specifier = "==2.5.4" },
{ name = "httpx", specifier = "==0.28.1" }, { name = "httpx", specifier = "==0.28.1" },
@@ -1178,7 +1178,7 @@ requires-dist = [
{ name = "pycaw", specifier = ">=20240210" }, { name = "pycaw", specifier = ">=20240210" },
{ name = "pydantic-settings", specifier = "==2.11.0" }, { name = "pydantic-settings", specifier = "==2.11.0" },
{ name = "pyjwt", specifier = "==2.10.1" }, { name = "pyjwt", specifier = "==2.10.1" },
{ name = "python-socketio", specifier = "==5.13.0" }, { name = "python-socketio", specifier = "==5.14.1" },
{ name = "python-vlc", specifier = "==3.0.21203" }, { name = "python-vlc", specifier = "==3.0.21203" },
{ name = "pytz", specifier = "==2025.2" }, { name = "pytz", specifier = "==2025.2" },
{ name = "sqlmodel", specifier = "==0.0.25" }, { name = "sqlmodel", specifier = "==0.0.25" },
@@ -1194,7 +1194,7 @@ dev = [
{ name = "mypy", specifier = "==1.18.2" }, { name = "mypy", specifier = "==1.18.2" },
{ name = "pytest", specifier = "==8.4.2" }, { name = "pytest", specifier = "==8.4.2" },
{ name = "pytest-asyncio", specifier = "==1.2.0" }, { name = "pytest-asyncio", specifier = "==1.2.0" },
{ name = "ruff", specifier = "==0.13.2" }, { name = "ruff", specifier = "==0.13.3" },
] ]
[[package]] [[package]]