diff --git a/app/api/v1/favorites.py b/app/api/v1/favorites.py index e9ea583..fd66382 100644 --- a/app/api/v1/favorites.py +++ b/app/api/v1/favorites.py @@ -3,9 +3,7 @@ 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 @@ -35,7 +33,7 @@ async def get_user_favorites( ) -> FavoritesListResponse: """Get all favorites for the current user.""" favorites = await favorite_service.get_user_favorites( - current_user.id, limit, offset + current_user.id, limit, offset, ) return FavoritesListResponse(favorites=favorites) @@ -49,7 +47,7 @@ async def get_user_sound_favorites( ) -> FavoritesListResponse: """Get sound favorites for the current user.""" favorites = await favorite_service.get_user_sound_favorites( - current_user.id, limit, offset + current_user.id, limit, offset, ) return FavoritesListResponse(favorites=favorites) @@ -63,7 +61,7 @@ async def get_user_playlist_favorites( ) -> FavoritesListResponse: """Get playlist favorites for the current user.""" favorites = await favorite_service.get_user_playlist_favorites( - current_user.id, limit, offset + current_user.id, limit, offset, ) return FavoritesListResponse(favorites=favorites) @@ -94,16 +92,15 @@ async def add_sound_favorite( status_code=status.HTTP_404_NOT_FOUND, detail=str(e), ) from e - elif "already favorited" in str(e): + if "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 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e @router.post("/playlists/{playlist_id}", response_model=FavoriteResponse) @@ -115,7 +112,7 @@ async def add_playlist_favorite( """Add a playlist to favorites.""" try: favorite = await favorite_service.add_playlist_favorite( - current_user.id, playlist_id + current_user.id, playlist_id, ) return FavoriteResponse.model_validate(favorite) except ValueError as e: @@ -124,16 +121,15 @@ async def add_playlist_favorite( status_code=status.HTTP_404_NOT_FOUND, detail=str(e), ) from e - elif "already favorited" in str(e): + if "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 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e @router.delete("/sounds/{sound_id}", response_model=MessageResponse) @@ -189,6 +185,6 @@ async def check_playlist_favorited( ) -> 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 + current_user.id, playlist_id, ) return {"is_favorited": is_favorited} diff --git a/app/api/v1/playlists.py b/app/api/v1/playlists.py index 85c1be0..a055d5a 100644 --- a/app/api/v1/playlists.py +++ b/app/api/v1/playlists.py @@ -40,8 +40,9 @@ def get_favorite_service() -> FavoriteService: @router.get("/") async def get_all_playlists( # noqa: PLR0913 - 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)], search: Annotated[ str | None, Query(description="Search playlists by name"), @@ -62,9 +63,13 @@ async def get_all_playlists( # noqa: PLR0913 int, Query(description="Number of results to skip", ge=0), ] = 0, -) -> list[dict]: + favorites_only: Annotated[ + bool, + Query(description="Show only favorited playlists"), + ] = False, +) -> list[PlaylistResponse]: """Get all playlists from all users with search and sorting.""" - return await playlist_service.search_and_sort_playlists( + playlists = await playlist_service.search_and_sort_playlists( search_query=search, sort_by=sort_by, sort_order=sort_order, @@ -72,8 +77,29 @@ async def get_all_playlists( # noqa: PLR0913 include_stats=True, limit=limit, offset=offset, + favorites_only=favorites_only, + current_user_id=current_user.id, ) + # Convert to PlaylistResponse with favorite indicators + playlist_responses = [] + for playlist_dict in playlists: + # The playlist service returns dict, need to create playlist object-like structure + is_favorited = await favorite_service.is_playlist_favorited(current_user.id, playlist_dict["id"]) + favorite_count = await favorite_service.get_playlist_favorite_count(playlist_dict["id"]) + + # Create a PlaylistResponse-like dict with proper datetime conversion + playlist_response = { + **playlist_dict, + "created_at": playlist_dict["created_at"].isoformat() if playlist_dict["created_at"] else None, + "updated_at": playlist_dict["updated_at"].isoformat() if playlist_dict["updated_at"] else None, + "is_favorited": is_favorited, + "favorite_count": favorite_count, + } + playlist_responses.append(playlist_response) + + return playlist_responses + @router.get("/user") async def get_user_playlists( @@ -83,7 +109,7 @@ async def get_user_playlists( ) -> list[PlaylistResponse]: """Get playlists for the current user only.""" playlists = await playlist_service.get_user_playlists(current_user.id) - + # Add favorite indicators for each playlist playlist_responses = [] for playlist in playlists: @@ -91,7 +117,7 @@ async def get_user_playlists( 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 diff --git a/app/api/v1/sounds.py b/app/api/v1/sounds.py index 8fb5564..597e3d9 100644 --- a/app/api/v1/sounds.py +++ b/app/api/v1/sounds.py @@ -8,7 +8,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.core.database import get_db, get_session_factory from app.core.dependencies import get_current_active_user_flexible 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 @@ -87,7 +86,7 @@ async def get_sounds( # noqa: PLR0913 favorites_only=favorites_only, user_id=current_user.id, ) - + # Add favorite indicators for each sound sound_responses = [] for sound in sounds: @@ -95,7 +94,7 @@ async def get_sounds( # noqa: PLR0913 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, diff --git a/app/repositories/favorite.py b/app/repositories/favorite.py index 4ab0ab3..853c5ac 100644 --- a/app/repositories/favorite.py +++ b/app/repositories/favorite.py @@ -105,7 +105,7 @@ class FavoriteRepository(BaseRepository[Favorite]): statement = ( select(Favorite) .where( - and_(Favorite.user_id == user_id, Favorite.playlist_id.isnot(None)) + and_(Favorite.user_id == user_id, Favorite.playlist_id.isnot(None)), ) .limit(limit) .offset(offset) @@ -118,7 +118,7 @@ class FavoriteRepository(BaseRepository[Favorite]): raise async def get_by_user_and_sound( - self, user_id: int, sound_id: int + self, user_id: int, sound_id: int, ) -> Favorite | None: """Get a favorite by user and sound. @@ -132,18 +132,18 @@ class FavoriteRepository(BaseRepository[Favorite]): """ try: statement = select(Favorite).where( - and_(Favorite.user_id == user_id, Favorite.sound_id == sound_id) + and_(Favorite.user_id == user_id, Favorite.sound_id == sound_id), ) result = await self.session.exec(statement) return result.first() except Exception: logger.exception( - "Failed to get favorite for user %s and sound %s", user_id, sound_id + "Failed to get favorite for user %s and sound %s", user_id, sound_id, ) raise async def get_by_user_and_playlist( - self, user_id: int, playlist_id: int + self, user_id: int, playlist_id: int, ) -> Favorite | None: """Get a favorite by user and playlist. @@ -157,7 +157,7 @@ class FavoriteRepository(BaseRepository[Favorite]): """ try: statement = select(Favorite).where( - and_(Favorite.user_id == user_id, Favorite.playlist_id == playlist_id) + and_(Favorite.user_id == user_id, Favorite.playlist_id == playlist_id), ) result = await self.session.exec(statement) return result.first() diff --git a/app/repositories/playlist.py b/app/repositories/playlist.py index 2eaf877..a90eacf 100644 --- a/app/repositories/playlist.py +++ b/app/repositories/playlist.py @@ -9,6 +9,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from app.core.logging import get_logger +from app.models.favorite import Favorite from app.models.playlist import Playlist from app.models.playlist_sound import PlaylistSound from app.models.sound import Sound @@ -340,6 +341,8 @@ class PlaylistRepository(BaseRepository[Playlist]): include_stats: bool = False, # noqa: FBT001, FBT002 limit: int | None = None, offset: int = 0, + favorites_only: bool = False, + current_user_id: int | None = None, ) -> list[dict]: """Search and sort playlists with optional statistics.""" try: @@ -387,6 +390,15 @@ class PlaylistRepository(BaseRepository[Playlist]): if user_id is not None: subquery = subquery.where(Playlist.user_id == user_id) + # Apply favorites filter + if favorites_only and current_user_id is not None: + # Use EXISTS subquery to avoid JOIN conflicts with GROUP BY + favorites_subquery = select(1).select_from(Favorite).where( + Favorite.user_id == current_user_id, + Favorite.playlist_id == Playlist.id, + ) + subquery = subquery.where(favorites_subquery.exists()) + # Apply sorting if sort_by == PlaylistSortField.SOUND_COUNT: if sort_order == SortOrder.DESC: @@ -449,6 +461,15 @@ class PlaylistRepository(BaseRepository[Playlist]): if user_id is not None: subquery = subquery.where(Playlist.user_id == user_id) + # Apply favorites filter + if favorites_only and current_user_id is not None: + # Use EXISTS subquery to avoid JOIN conflicts with GROUP BY + favorites_subquery = select(1).select_from(Favorite).where( + Favorite.user_id == current_user_id, + Favorite.playlist_id == Playlist.id, + ) + subquery = subquery.where(favorites_subquery.exists()) + # Apply sorting if sort_by: if sort_by == PlaylistSortField.NAME: diff --git a/app/schemas/favorite.py b/app/schemas/favorite.py index c0a4d99..1e6bd67 100644 --- a/app/schemas/favorite.py +++ b/app/schemas/favorite.py @@ -11,10 +11,10 @@ class FavoriteResponse(BaseModel): id: int = Field(description="Favorite ID") user_id: int = Field(description="User ID") sound_id: int | None = Field( - description="Sound ID if this is a sound favorite", default=None + description="Sound ID if this is a sound favorite", default=None, ) playlist_id: int | None = Field( - description="Playlist ID if this is a playlist favorite", default=None + description="Playlist ID if this is a playlist favorite", default=None, ) created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/app/schemas/playlist.py b/app/schemas/playlist.py index 6cc7fac..1d66d06 100644 --- a/app/schemas/playlist.py +++ b/app/schemas/playlist.py @@ -49,6 +49,7 @@ class PlaylistResponse(BaseModel): Returns: PlaylistResponse instance + """ if playlist.id is None: msg = "Playlist ID cannot be None" diff --git a/app/schemas/sound.py b/app/schemas/sound.py index 43a9509..3618701 100644 --- a/app/schemas/sound.py +++ b/app/schemas/sound.py @@ -18,16 +18,16 @@ class SoundResponse(BaseModel): size: int = Field(description="File size in bytes") hash: str = Field(description="File hash") normalized_filename: str | None = Field( - description="Normalized filename", default=None + description="Normalized filename", default=None, ) normalized_duration: int | None = Field( - description="Normalized duration in milliseconds", default=None + description="Normalized duration in milliseconds", default=None, ) normalized_size: int | None = Field( - description="Normalized file size in bytes", default=None + description="Normalized file size in bytes", default=None, ) normalized_hash: str | None = Field( - description="Normalized file hash", default=None + description="Normalized file hash", default=None, ) thumbnail: str | None = Field(description="Thumbnail filename", default=None) play_count: int = Field(description="Number of times played") @@ -35,10 +35,10 @@ class SoundResponse(BaseModel): is_music: bool = Field(description="Whether the sound is music") is_deletable: bool = Field(description="Whether the sound can be deleted") is_favorited: bool = Field( - description="Whether the sound is favorited by the current user", default=False + description="Whether the sound is favorited by the current user", default=False, ) favorite_count: int = Field( - description="Number of users who favorited this sound", default=0 + description="Number of users who favorited this sound", default=0, ) created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") @@ -50,7 +50,7 @@ class SoundResponse(BaseModel): @classmethod def from_sound( - cls, sound: Sound, is_favorited: bool = False, favorite_count: int = 0 + cls, sound: Sound, is_favorited: bool = False, favorite_count: int = 0, ) -> "SoundResponse": """Create a SoundResponse from a Sound model. @@ -61,6 +61,7 @@ class SoundResponse(BaseModel): Returns: SoundResponse instance + """ if sound.id is None: raise ValueError("Sound ID cannot be None") diff --git a/app/services/favorite.py b/app/services/favorite.py index ced0def..37a1ff9 100644 --- a/app/services/favorite.py +++ b/app/services/favorite.py @@ -6,9 +6,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.core.logging import get_logger from app.models.favorite import Favorite -from app.models.playlist import Playlist -from app.models.sound import Sound -from app.models.user import User from app.repositories.favorite import FavoriteRepository from app.repositories.playlist import PlaylistRepository from app.repositories.sound import SoundRepository @@ -62,7 +59,7 @@ class FavoriteService: existing = await favorite_repo.get_by_user_and_sound(user_id, sound_id) if existing: raise ValueError( - f"Sound {sound_id} is already favorited by user {user_id}" + f"Sound {sound_id} is already favorited by user {user_id}", ) # Create favorite @@ -106,11 +103,11 @@ class FavoriteService: # Check if already favorited existing = await favorite_repo.get_by_user_and_playlist( - user_id, playlist_id + user_id, playlist_id, ) if existing: raise ValueError( - f"Playlist {playlist_id} is already favorited by user {user_id}" + f"Playlist {playlist_id} is already favorited by user {user_id}", ) # Create favorite @@ -159,16 +156,16 @@ class FavoriteService: favorite_repo = FavoriteRepository(session) favorite = await favorite_repo.get_by_user_and_playlist( - user_id, playlist_id + user_id, playlist_id, ) if not favorite: raise ValueError( - f"Playlist {playlist_id} is not favorited by user {user_id}" + f"Playlist {playlist_id} is not favorited by user {user_id}", ) await favorite_repo.delete(favorite) logger.info( - "User %s removed playlist %s from favorites", user_id, playlist_id + "User %s removed playlist %s from favorites", user_id, playlist_id, ) async def get_user_favorites( @@ -233,7 +230,7 @@ class FavoriteService: async with self.db_session_factory() as session: favorite_repo = FavoriteRepository(session) return await favorite_repo.get_user_playlist_favorites( - user_id, limit, offset + user_id, limit, offset, ) async def is_sound_favorited(self, user_id: int, sound_id: int) -> bool: diff --git a/app/services/playlist.py b/app/services/playlist.py index 17ffbdc..45c1476 100644 --- a/app/services/playlist.py +++ b/app/services/playlist.py @@ -246,6 +246,8 @@ class PlaylistService: include_stats: bool = False, limit: int | None = None, offset: int = 0, + favorites_only: bool = False, + current_user_id: int | None = None, ) -> list[dict]: """Search and sort playlists with optional statistics.""" return await self.playlist_repo.search_and_sort( @@ -256,6 +258,8 @@ class PlaylistService: include_stats=include_stats, limit=limit, offset=offset, + favorites_only=favorites_only, + current_user_id=current_user_id, ) async def get_playlist_sounds(self, playlist_id: int) -> list[Sound]: