Compare commits

..

2 Commits

Author SHA1 Message Date
JSC
a82acfae50 feat: Implement sound playback with credit validation in VLCPlayerService and update WebSocket handling
Some checks failed
Backend CI / lint (push) Failing after 5m0s
Backend CI / test (push) Failing after 2m0s
2025-08-19 22:16:48 +02:00
JSC
560ccd3f7e refactor: Improve code readability by formatting query parameters in user endpoints and enhancing error handling in sound playback 2025-08-19 22:09:50 +02:00
11 changed files with 210 additions and 88 deletions

View File

@@ -51,11 +51,13 @@ async def list_users( # noqa: PLR0913
limit: Annotated[int, Query(description="Items per page", ge=1, le=100)] = 50, limit: Annotated[int, Query(description="Items per page", ge=1, le=100)] = 50,
search: Annotated[str | None, Query(description="Search in name or email")] = None, search: Annotated[str | None, Query(description="Search in name or email")] = None,
sort_by: Annotated[ sort_by: Annotated[
UserSortField, Query(description="Sort by field"), UserSortField,
Query(description="Sort by field"),
] = UserSortField.NAME, ] = UserSortField.NAME,
sort_order: Annotated[SortOrder, Query(description="Sort order")] = SortOrder.ASC, sort_order: Annotated[SortOrder, Query(description="Sort order")] = SortOrder.ASC,
status_filter: Annotated[ status_filter: Annotated[
UserStatus, Query(description="Filter by status"), UserStatus,
Query(description="Filter by status"),
] = UserStatus.ALL, ] = UserStatus.ALL,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Get all users with pagination, search, and filters (admin only).""" """Get all users with pagination, search, and filters (admin only)."""

View File

@@ -2,7 +2,7 @@
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.core.dependencies import get_current_user, get_dashboard_service from app.core.dependencies import get_current_user, get_dashboard_service
from app.models.user import User from app.models.user import User
@@ -17,7 +17,13 @@ async def get_soundboard_statistics(
dashboard_service: Annotated[DashboardService, Depends(get_dashboard_service)], dashboard_service: Annotated[DashboardService, Depends(get_dashboard_service)],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Get soundboard statistics.""" """Get soundboard statistics."""
return await dashboard_service.get_soundboard_statistics() try:
return await dashboard_service.get_soundboard_statistics()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch soundboard statistics: {e!s}",
) from e
@router.get("/track-statistics") @router.get("/track-statistics")
@@ -26,7 +32,13 @@ async def get_track_statistics(
dashboard_service: Annotated[DashboardService, Depends(get_dashboard_service)], dashboard_service: Annotated[DashboardService, Depends(get_dashboard_service)],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Get track statistics.""" """Get track statistics."""
return await dashboard_service.get_track_statistics() try:
return await dashboard_service.get_track_statistics()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch track statistics: {e!s}",
) from e
@router.get("/top-sounds") @router.get("/top-sounds")
@@ -49,8 +61,14 @@ async def get_top_sounds(
] = 10, ] = 10,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get top sounds by play count for a specific period.""" """Get top sounds by play count for a specific period."""
return await dashboard_service.get_top_sounds( try:
sound_type=sound_type, return await dashboard_service.get_top_sounds(
period=period, sound_type=sound_type,
limit=limit, period=period,
) limit=limit,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch top sounds: {e!s}",
) from e

View File

@@ -65,7 +65,8 @@ async def get_user_extractions( # noqa: PLR0913
current_user: Annotated[User, Depends(get_current_active_user_flexible)], current_user: Annotated[User, Depends(get_current_active_user_flexible)],
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)], extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)],
search: Annotated[ search: Annotated[
str | None, Query(description="Search in title, URL, or service"), str | None,
Query(description="Search in title, URL, or service"),
] = None, ] = None,
sort_by: Annotated[str, Query(description="Sort by field")] = "created_at", sort_by: Annotated[str, Query(description="Sort by field")] = "created_at",
sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc", sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc",
@@ -131,7 +132,8 @@ async def get_extraction(
async def get_all_extractions( # noqa: PLR0913 async def get_all_extractions( # noqa: PLR0913
extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)], extraction_service: Annotated[ExtractionService, Depends(get_extraction_service)],
search: Annotated[ search: Annotated[
str | None, Query(description="Search in title, URL, or service"), str | None,
Query(description="Search in title, URL, or service"),
] = None, ] = None,
sort_by: Annotated[str, Query(description="Sort by field")] = "created_at", sort_by: Annotated[str, Query(description="Sort by field")] = "created_at",
sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc", sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc",

View File

@@ -80,7 +80,8 @@ async def get_all_playlists( # noqa: PLR0913
# The playlist service returns dict, need to create playlist object structure # The playlist service returns dict, need to create playlist object structure
playlist_id = playlist_dict["id"] playlist_id = playlist_dict["id"]
is_favorited = await favorite_service.is_playlist_favorited( is_favorited = await favorite_service.is_playlist_favorited(
current_user.id, playlist_id, current_user.id,
playlist_id,
) )
favorite_count = await favorite_service.get_playlist_favorite_count(playlist_id) favorite_count = await favorite_service.get_playlist_favorite_count(playlist_id)
@@ -124,11 +125,14 @@ async def get_user_playlists(
playlist_responses = [] playlist_responses = []
for playlist in playlists: for playlist in playlists:
is_favorited = await favorite_service.is_playlist_favorited( is_favorited = await favorite_service.is_playlist_favorited(
current_user.id, playlist.id, current_user.id,
playlist.id,
) )
favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id) favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id)
playlist_response = PlaylistResponse.from_playlist( playlist_response = PlaylistResponse.from_playlist(
playlist, is_favorited, favorite_count, playlist,
is_favorited,
favorite_count,
) )
playlist_responses.append(playlist_response) playlist_responses.append(playlist_response)
@@ -144,7 +148,8 @@ async def get_main_playlist(
"""Get the global main playlist.""" """Get the global main playlist."""
playlist = await playlist_service.get_main_playlist() playlist = await playlist_service.get_main_playlist()
is_favorited = await favorite_service.is_playlist_favorited( is_favorited = await favorite_service.is_playlist_favorited(
current_user.id, playlist.id, current_user.id,
playlist.id,
) )
favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id) favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id)
return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count) return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count)
@@ -159,7 +164,8 @@ async def get_current_playlist(
"""Get the global current playlist (falls back to main playlist).""" """Get the global current playlist (falls back to main playlist)."""
playlist = await playlist_service.get_current_playlist() playlist = await playlist_service.get_current_playlist()
is_favorited = await favorite_service.is_playlist_favorited( is_favorited = await favorite_service.is_playlist_favorited(
current_user.id, playlist.id, current_user.id,
playlist.id,
) )
favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id) favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id)
return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count) return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count)
@@ -191,7 +197,8 @@ async def get_playlist(
"""Get a specific playlist.""" """Get a specific playlist."""
playlist = await playlist_service.get_playlist_by_id(playlist_id) playlist = await playlist_service.get_playlist_by_id(playlist_id)
is_favorited = await favorite_service.is_playlist_favorited( is_favorited = await favorite_service.is_playlist_favorited(
current_user.id, playlist.id, current_user.id,
playlist.id,
) )
favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id) favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id)
return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count) return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count)

View File

@@ -7,11 +7,9 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_db, get_session_factory from app.core.database import get_db, get_session_factory
from app.core.dependencies import get_current_active_user_flexible from app.core.dependencies import get_current_active_user_flexible
from app.models.credit_action import CreditActionType
from app.models.user import User from app.models.user import User
from app.repositories.sound import SortOrder, SoundRepository, SoundSortField from app.repositories.sound import SortOrder, SoundRepository, SoundSortField
from app.schemas.sound import SoundResponse, SoundsListResponse from app.schemas.sound import SoundResponse, SoundsListResponse
from app.services.credit import CreditService, InsufficientCreditsError
from app.services.favorite import FavoriteService from app.services.favorite import FavoriteService
from app.services.vlc_player import VLCPlayerService, get_vlc_player_service from app.services.vlc_player import VLCPlayerService, get_vlc_player_service
@@ -23,11 +21,6 @@ def get_vlc_player() -> VLCPlayerService:
return get_vlc_player_service(get_session_factory()) return get_vlc_player_service(get_session_factory())
def get_credit_service() -> CreditService:
"""Get the credit service."""
return CreditService(get_session_factory())
def get_favorite_service() -> FavoriteService: def get_favorite_service() -> FavoriteService:
"""Get the favorite service.""" """Get the favorite service."""
return FavoriteService(get_session_factory()) return FavoriteService(get_session_factory())
@@ -91,11 +84,14 @@ async def get_sounds( # noqa: PLR0913
sound_responses = [] sound_responses = []
for sound in sounds: for sound in sounds:
is_favorited = await favorite_service.is_sound_favorited( is_favorited = await favorite_service.is_sound_favorited(
current_user.id, sound.id, current_user.id,
sound.id,
) )
favorite_count = await favorite_service.get_sound_favorite_count(sound.id) favorite_count = await favorite_service.get_sound_favorite_count(sound.id)
sound_response = SoundResponse.from_sound( sound_response = SoundResponse.from_sound(
sound, is_favorited, favorite_count, sound,
is_favorited,
favorite_count,
) )
sound_responses.append(sound_response) sound_responses.append(sound_response)
@@ -114,51 +110,15 @@ async def play_sound_with_vlc(
sound_id: int, sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)], current_user: Annotated[User, Depends(get_current_active_user_flexible)],
vlc_player: Annotated[VLCPlayerService, Depends(get_vlc_player)], vlc_player: Annotated[VLCPlayerService, Depends(get_vlc_player)],
sound_repo: Annotated[SoundRepository, Depends(get_sound_repository)],
credit_service: Annotated[CreditService, Depends(get_credit_service)],
) -> dict[str, str | int | bool]: ) -> dict[str, str | int | bool]:
"""Play a sound using VLC subprocess (requires 1 credit).""" """Play a sound using VLC subprocess (requires 1 credit)."""
try: try:
# Get the sound if not current_user.id:
sound = await sound_repo.get_by_id(sound_id)
if not sound:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Sound with ID {sound_id} not found", detail="User ID is required",
) )
return await vlc_player.play_sound_with_credits(sound_id, current_user.id)
# Check and validate credits before playing
try:
await credit_service.validate_and_reserve_credits(
current_user.id,
CreditActionType.VLC_PLAY_SOUND,
)
except InsufficientCreditsError as e:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=(
f"Insufficient credits: {e.required} required, "
f"{e.available} available"
),
) from e
# Play the sound using VLC
success = await vlc_player.play_sound(sound)
# Deduct credits based on success
await credit_service.deduct_credits(
current_user.id,
CreditActionType.VLC_PLAY_SOUND,
success=success,
metadata={"sound_id": sound_id, "sound_name": sound.name},
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to launch VLC for sound playback",
)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -166,14 +126,6 @@ async def play_sound_with_vlc(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to play sound: {e!s}", detail=f"Failed to play sound: {e!s}",
) from e ) from e
else:
return {
"message": f"Sound '{sound.name}' is now playing via VLC",
"sound_id": sound_id,
"sound_name": sound.name,
"success": True,
"credits_deducted": 1,
}
@router.post("/stop") @router.post("/stop")

View File

@@ -99,6 +99,48 @@ class SocketManager:
else: else:
logger.info("Unknown client %s disconnected", sid) logger.info("Unknown client %s disconnected", sid)
@self.sio.event
async def play_sound(sid: str, data: dict) -> None:
"""Handle play sound event from client."""
user_id = self.socket_users.get(sid)
if not user_id:
logger.warning("Play sound request from unknown client %s", sid)
return
sound_id = data.get("sound_id")
if not sound_id:
logger.warning(
"Play sound request missing sound_id from user %s",
user_id,
)
return
try:
# Import here to avoid circular imports
from app.services.vlc_player import get_vlc_player_service
from app.core.database import get_session_factory
# Get VLC player service with database factory
vlc_player = get_vlc_player_service(get_session_factory())
# Call the service method
await vlc_player.play_sound_with_credits(int(sound_id), int(user_id))
logger.info("User %s played sound %s via WebSocket", user_id, sound_id)
except Exception as e:
logger.exception(
"Error playing sound %s for user %s: %s",
sound_id,
user_id,
e,
)
# Emit error back to user
await self.sio.emit(
"sound_play_error",
{"sound_id": sound_id, "error": str(e)},
room=sid,
)
async def send_to_user(self, user_id: str, event: str, data: dict) -> bool: async def send_to_user(self, user_id: str, event: str, data: dict) -> bool:
"""Send a message to a specific user's room.""" """Send a message to a specific user's room."""
room_id = self.user_rooms.get(user_id) room_id = self.user_rooms.get(user_id)

View File

@@ -9,6 +9,7 @@ from typing import Any
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.credit_action import CreditActionType
from app.models.sound import Sound from app.models.sound import Sound
from app.models.sound_played import SoundPlayed from app.models.sound_played import SoundPlayed
from app.repositories.sound import SoundRepository from app.repositories.sound import SoundRepository
@@ -307,6 +308,89 @@ class VLCPlayerService:
finally: finally:
await session.close() await session.close()
async def play_sound_with_credits(
self, sound_id: int, user_id: int
) -> dict[str, str | int | bool]:
"""Play sound with VLC with credit validation and deduction.
This method combines credit checking, sound playing, and credit deduction
in a single operation. Used by both HTTP and WebSocket endpoints.
Args:
sound_id: ID of the sound to play
user_id: ID of the user playing the sound
Returns:
dict: Result information including success status and message
Raises:
HTTPException: For various error conditions (sound not found,
insufficient credits, VLC failure)
"""
from fastapi import HTTPException, status
from app.services.credit import CreditService, InsufficientCreditsError
if not self.db_session_factory:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database session factory not configured",
)
async with self.db_session_factory() as session:
sound_repo = SoundRepository(session)
# Get the sound
sound = await sound_repo.get_by_id(sound_id)
if not sound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Sound with ID {sound_id} not found",
)
# Get credit service
credit_service = CreditService(self.db_session_factory)
# Check and validate credits before playing
try:
await credit_service.validate_and_reserve_credits(
user_id,
CreditActionType.VLC_PLAY_SOUND,
)
except InsufficientCreditsError as e:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=(
f"Insufficient credits: {e.required} required, "
f"{e.available} available"
),
) from e
# Play the sound using VLC
success = await self.play_sound(sound)
# Deduct credits based on success
await credit_service.deduct_credits(
user_id,
CreditActionType.VLC_PLAY_SOUND,
success=success,
metadata={"sound_id": sound_id, "sound_name": sound.name},
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to launch VLC for sound playback",
)
return {
"message": f"Sound '{sound.name}' is now playing via VLC",
"sound_id": sound_id,
"sound_name": sound.name,
"success": True,
"credits_deducted": 1,
}
# Global VLC player service instance # Global VLC player service instance
vlc_player_service: VLCPlayerService | None = None vlc_player_service: VLCPlayerService | None = None

View File

@@ -204,7 +204,8 @@ class TestAdminUserEndpoints:
) -> None: ) -> None:
"""Test listing users as non-admin user.""" """Test listing users as non-admin user."""
with patch( with patch(
"app.core.dependencies.get_current_active_user", return_value=regular_user, "app.core.dependencies.get_current_active_user",
return_value=regular_user,
): ):
response = await client.get("/api/v1/admin/users/") response = await client.get("/api/v1/admin/users/")
@@ -296,7 +297,8 @@ class TestAdminUserEndpoints:
) as mock_get_by_id, ) as mock_get_by_id,
patch("app.repositories.user.UserRepository.update") as mock_update, patch("app.repositories.user.UserRepository.update") as mock_update,
patch( patch(
"app.repositories.plan.PlanRepository.get_by_id", return_value=test_plan, "app.repositories.plan.PlanRepository.get_by_id",
return_value=test_plan,
), ),
): ):
mock_user = type( mock_user = type(

View File

@@ -609,7 +609,8 @@ class TestAuthEndpoints:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_profile_unauthenticated( async def test_update_profile_unauthenticated(
self, test_client: AsyncClient, self,
test_client: AsyncClient,
) -> None: ) -> None:
"""Test update profile without authentication.""" """Test update profile without authentication."""
response = await test_client.patch( response = await test_client.patch(
@@ -645,7 +646,8 @@ class TestAuthEndpoints:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_change_password_unauthenticated( async def test_change_password_unauthenticated(
self, test_client: AsyncClient, self,
test_client: AsyncClient,
) -> None: ) -> None:
"""Test change password without authentication.""" """Test change password without authentication."""
response = await test_client.post( response = await test_client.post(
@@ -716,7 +718,8 @@ class TestAuthEndpoints:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_providers_unauthenticated( async def test_get_user_providers_unauthenticated(
self, test_client: AsyncClient, self,
test_client: AsyncClient,
) -> None: ) -> None:
"""Test get user OAuth providers without authentication.""" """Test get user OAuth providers without authentication."""
response = await test_client.get("/api/v1/auth/user-providers") response = await test_client.get("/api/v1/auth/user-providers")

View File

@@ -540,15 +540,19 @@ class TestPlaylistRepository:
# Add first two sounds sequentially (positions 0, 1) # Add first two sounds sequentially (positions 0, 1)
await playlist_repository.add_sound_to_playlist( await playlist_repository.add_sound_to_playlist(
playlist_id, sound_ids[0], playlist_id,
sound_ids[0],
) # position 0 ) # position 0
await playlist_repository.add_sound_to_playlist( await playlist_repository.add_sound_to_playlist(
playlist_id, sound_ids[1], playlist_id,
sound_ids[1],
) # position 1 ) # position 1
# Now insert third sound at position 1 - should shift existing sound at position 1 to position 2 # Now insert third sound at position 1 - should shift existing sound at position 1 to position 2
await playlist_repository.add_sound_to_playlist( await playlist_repository.add_sound_to_playlist(
playlist_id, sound_ids[2], position=1, playlist_id,
sound_ids[2],
position=1,
) )
# Verify the final positions # Verify the final positions
@@ -630,15 +634,19 @@ class TestPlaylistRepository:
# Add first two sounds sequentially (positions 0, 1) # Add first two sounds sequentially (positions 0, 1)
await playlist_repository.add_sound_to_playlist( await playlist_repository.add_sound_to_playlist(
playlist_id, sound_ids[0], playlist_id,
sound_ids[0],
) # position 0 ) # position 0
await playlist_repository.add_sound_to_playlist( await playlist_repository.add_sound_to_playlist(
playlist_id, sound_ids[1], playlist_id,
sound_ids[1],
) # position 1 ) # position 1
# Now insert third sound at position 0 - should shift existing sounds to positions 1, 2 # Now insert third sound at position 0 - should shift existing sounds to positions 1, 2
await playlist_repository.add_sound_to_playlist( await playlist_repository.add_sound_to_playlist(
playlist_id, sound_ids[2], position=0, playlist_id,
sound_ids[2],
position=0,
) )
# Verify the final positions # Verify the final positions

View File

@@ -63,7 +63,8 @@ class TestSchedulerService:
} }
with patch.object( with patch.object(
scheduler_service.credit_service, "recharge_all_users_credits", scheduler_service.credit_service,
"recharge_all_users_credits",
) as mock_recharge: ) as mock_recharge:
mock_recharge.return_value = mock_stats mock_recharge.return_value = mock_stats
@@ -75,7 +76,8 @@ class TestSchedulerService:
async def test_daily_credit_recharge_failure(self, scheduler_service) -> None: async def test_daily_credit_recharge_failure(self, scheduler_service) -> None:
"""Test daily credit recharge task with failure.""" """Test daily credit recharge task with failure."""
with patch.object( with patch.object(
scheduler_service.credit_service, "recharge_all_users_credits", scheduler_service.credit_service,
"recharge_all_users_credits",
) as mock_recharge: ) as mock_recharge:
mock_recharge.side_effect = Exception("Database error") mock_recharge.side_effect = Exception("Database error")