feat: Implement favorites management API; add endpoints for adding, removing, and retrieving favorites for sounds and playlists

feat: Create Favorite model and repository for managing user favorites in the database
feat: Add FavoriteService to handle business logic for favorites management
feat: Enhance Playlist and Sound response schemas to include favorite indicators and counts
refactor: Update API routes to include favorites functionality in playlists and sounds
This commit is contained in:
JSC
2025-08-16 21:16:02 +02:00
parent 5e6cc04ad2
commit a947fd830b
14 changed files with 1005 additions and 13 deletions

View File

@@ -7,6 +7,7 @@ from app.api.v1 import (
auth,
dashboard,
extractions,
favorites,
files,
main,
player,
@@ -22,6 +23,7 @@ api_router = APIRouter(prefix="/v1")
api_router.include_router(auth.router, tags=["authentication"])
api_router.include_router(dashboard.router, tags=["dashboard"])
api_router.include_router(extractions.router, tags=["extractions"])
api_router.include_router(favorites.router, tags=["favorites"])
api_router.include_router(files.router, tags=["files"])
api_router.include_router(main.router, tags=["main"])
api_router.include_router(player.router, tags=["player"])

194
app/api/v1/favorites.py Normal file
View File

@@ -0,0 +1,194 @@
"""Favorites management API endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_active_user
from app.models.user import User
from app.schemas.common import MessageResponse
from app.schemas.favorite import (
FavoriteCountsResponse,
FavoriteResponse,
FavoritesListResponse,
)
from app.services.favorite import FavoriteService
router = APIRouter(prefix="/favorites", tags=["favorites"])
def get_favorite_service() -> FavoriteService:
"""Get the favorite service."""
from app.core.database import get_session_factory
return FavoriteService(get_session_factory())
@router.get("/", response_model=FavoritesListResponse)
async def get_user_favorites(
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> FavoritesListResponse:
"""Get all favorites for the current user."""
favorites = await favorite_service.get_user_favorites(
current_user.id, limit, offset
)
return FavoritesListResponse(favorites=favorites)
@router.get("/sounds", response_model=FavoritesListResponse)
async def get_user_sound_favorites(
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> FavoritesListResponse:
"""Get sound favorites for the current user."""
favorites = await favorite_service.get_user_sound_favorites(
current_user.id, limit, offset
)
return FavoritesListResponse(favorites=favorites)
@router.get("/playlists", response_model=FavoritesListResponse)
async def get_user_playlist_favorites(
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> FavoritesListResponse:
"""Get playlist favorites for the current user."""
favorites = await favorite_service.get_user_playlist_favorites(
current_user.id, limit, offset
)
return FavoritesListResponse(favorites=favorites)
@router.get("/counts", response_model=FavoriteCountsResponse)
async def get_favorite_counts(
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> FavoriteCountsResponse:
"""Get favorite counts for the current user."""
counts = await favorite_service.get_favorite_counts(current_user.id)
return FavoriteCountsResponse(**counts)
@router.post("/sounds/{sound_id}", response_model=FavoriteResponse)
async def add_sound_favorite(
sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> FavoriteResponse:
"""Add a sound to favorites."""
try:
favorite = await favorite_service.add_sound_favorite(current_user.id, sound_id)
return FavoriteResponse.model_validate(favorite)
except ValueError as e:
if "not found" in str(e):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
) from e
elif "already favorited" in str(e):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
) from e
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
@router.post("/playlists/{playlist_id}", response_model=FavoriteResponse)
async def add_playlist_favorite(
playlist_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> FavoriteResponse:
"""Add a playlist to favorites."""
try:
favorite = await favorite_service.add_playlist_favorite(
current_user.id, playlist_id
)
return FavoriteResponse.model_validate(favorite)
except ValueError as e:
if "not found" in str(e):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
) from e
elif "already favorited" in str(e):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
) from e
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
@router.delete("/sounds/{sound_id}", response_model=MessageResponse)
async def remove_sound_favorite(
sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> MessageResponse:
"""Remove a sound from favorites."""
try:
await favorite_service.remove_sound_favorite(current_user.id, sound_id)
return MessageResponse(message="Sound removed from favorites")
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
) from e
@router.delete("/playlists/{playlist_id}", response_model=MessageResponse)
async def remove_playlist_favorite(
playlist_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> MessageResponse:
"""Remove a playlist from favorites."""
try:
await favorite_service.remove_playlist_favorite(current_user.id, playlist_id)
return MessageResponse(message="Playlist removed from favorites")
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
) from e
@router.get("/sounds/{sound_id}/check")
async def check_sound_favorited(
sound_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> dict[str, bool]:
"""Check if a sound is favorited by the current user."""
is_favorited = await favorite_service.is_sound_favorited(current_user.id, sound_id)
return {"is_favorited": is_favorited}
@router.get("/playlists/{playlist_id}/check")
async def check_playlist_favorited(
playlist_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> dict[str, bool]:
"""Check if a playlist is favorited by the current user."""
is_favorited = await favorite_service.is_playlist_favorited(
current_user.id, playlist_id
)
return {"is_favorited": is_favorited}

View File

@@ -19,6 +19,7 @@ from app.schemas.playlist import (
PlaylistStatsResponse,
PlaylistUpdateRequest,
)
from app.services.favorite import FavoriteService
from app.services.playlist import PlaylistService
router = APIRouter(prefix="/playlists", tags=["playlists"])
@@ -31,6 +32,12 @@ async def get_playlist_service(
return PlaylistService(session)
def get_favorite_service() -> FavoriteService:
"""Get the favorite service."""
from app.core.database import get_session_factory
return FavoriteService(get_session_factory())
@router.get("/")
async def get_all_playlists( # noqa: PLR0913
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
@@ -72,30 +79,46 @@ async def get_all_playlists( # noqa: PLR0913
async def get_user_playlists(
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
playlist_service: Annotated[PlaylistService, Depends(get_playlist_service)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> list[PlaylistResponse]:
"""Get playlists for the current user only."""
playlists = await playlist_service.get_user_playlists(current_user.id)
return [PlaylistResponse.from_playlist(playlist) for playlist in playlists]
# Add favorite indicators for each playlist
playlist_responses = []
for playlist in playlists:
is_favorited = await favorite_service.is_playlist_favorited(current_user.id, playlist.id)
favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id)
playlist_response = PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count)
playlist_responses.append(playlist_response)
return playlist_responses
@router.get("/main")
async def get_main_playlist(
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
playlist_service: Annotated[PlaylistService, Depends(get_playlist_service)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> PlaylistResponse:
"""Get the global main playlist."""
playlist = await playlist_service.get_main_playlist()
return PlaylistResponse.from_playlist(playlist)
is_favorited = await favorite_service.is_playlist_favorited(current_user.id, playlist.id)
favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id)
return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count)
@router.get("/current")
async def get_current_playlist(
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
playlist_service: Annotated[PlaylistService, Depends(get_playlist_service)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> PlaylistResponse:
"""Get the global current playlist (falls back to main playlist)."""
playlist = await playlist_service.get_current_playlist()
return PlaylistResponse.from_playlist(playlist)
is_favorited = await favorite_service.is_playlist_favorited(current_user.id, playlist.id)
favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id)
return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count)
@router.post("/")
@@ -117,12 +140,15 @@ async def create_playlist(
@router.get("/{playlist_id}")
async def get_playlist(
playlist_id: int,
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
playlist_service: Annotated[PlaylistService, Depends(get_playlist_service)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> PlaylistResponse:
"""Get a specific playlist."""
playlist = await playlist_service.get_playlist_by_id(playlist_id)
return PlaylistResponse.from_playlist(playlist)
is_favorited = await favorite_service.is_playlist_favorited(current_user.id, playlist.id)
favorite_count = await favorite_service.get_playlist_favorite_count(playlist.id)
return PlaylistResponse.from_playlist(playlist, is_favorited, favorite_count)
@router.put("/{playlist_id}")

View File

@@ -11,7 +11,9 @@ from app.models.credit_action import CreditActionType
from app.models.sound import Sound
from app.models.user import User
from app.repositories.sound import SortOrder, SoundRepository, SoundSortField
from app.schemas.sound import SoundResponse, SoundsListResponse
from app.services.credit import CreditService, InsufficientCreditsError
from app.services.favorite import FavoriteService
from app.services.vlc_player import VLCPlayerService, get_vlc_player_service
router = APIRouter(prefix="/sounds", tags=["sounds"])
@@ -27,6 +29,11 @@ def get_credit_service() -> CreditService:
return CreditService(get_session_factory())
def get_favorite_service() -> FavoriteService:
"""Get the favorite service."""
return FavoriteService(get_session_factory())
async def get_sound_repository(
session: Annotated[AsyncSession, Depends(get_db)],
) -> SoundRepository:
@@ -34,10 +41,11 @@ async def get_sound_repository(
return SoundRepository(session)
@router.get("/")
@router.get("/", response_model=SoundsListResponse)
async def get_sounds( # noqa: PLR0913
current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001
current_user: Annotated[User, Depends(get_current_active_user_flexible)],
sound_repo: Annotated[SoundRepository, Depends(get_sound_repository)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
types: Annotated[
list[str] | None,
Query(description="Filter by sound types (e.g., SDB, TTS, EXT)"),
@@ -62,7 +70,7 @@ async def get_sounds( # noqa: PLR0913
int,
Query(description="Number of results to skip", ge=0),
] = 0,
) -> dict[str, list[Sound]]:
) -> SoundsListResponse:
"""Get sounds with optional search, filtering, and sorting."""
try:
sounds = await sound_repo.search_and_sort(
@@ -73,13 +81,22 @@ async def get_sounds( # noqa: PLR0913
limit=limit,
offset=offset,
)
# Add favorite indicators for each sound
sound_responses = []
for sound in sounds:
is_favorited = await favorite_service.is_sound_favorited(current_user.id, sound.id)
favorite_count = await favorite_service.get_sound_favorite_count(sound.id)
sound_response = SoundResponse.from_sound(sound, is_favorited, favorite_count)
sound_responses.append(sound_response)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get sounds: {e!s}",
) from e
else:
return {"sounds": sounds}
return SoundsListResponse(sounds=sound_responses)
# VLC PLAYER