Compare commits
5 Commits
b66b8e36bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4c72f3b19 | ||
|
|
17eafa4872 | ||
|
|
c9f6bff723 | ||
|
|
12243b1424 | ||
|
|
f7197a89a7 |
@@ -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[
|
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[
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -4,20 +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.playlist import Playlist
|
|
||||||
from app.models.sound import Sound
|
|
||||||
from app.models.tts import TTS
|
|
||||||
from app.models.extraction import Extraction
|
from app.models.extraction import Extraction
|
||||||
|
from app.models.plan import Plan
|
||||||
|
from app.models.playlist import Playlist
|
||||||
|
from app.models.sound_played import SoundPlayed
|
||||||
|
from app.models.tts import TTS
|
||||||
|
from app.models.user import User
|
||||||
from app.repositories.base import BaseRepository
|
from app.repositories.base import BaseRepository
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -233,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 (via extractions)
|
|
||||||
query = (
|
|
||||||
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)) # Only count successful extractions
|
|
||||||
.group_by(User.id, User.name)
|
|
||||||
)
|
|
||||||
if date_filter:
|
|
||||||
query = query.where(Extraction.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)
|
||||||
@@ -331,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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user