feat: Enhance favorites functionality; add favorites filtering to playlists and sounds, and improve favorite indicators in responses

This commit is contained in:
JSC
2025-08-16 21:41:50 +02:00
parent 78508c84eb
commit f906b6d643
10 changed files with 97 additions and 52 deletions

View File

@@ -3,9 +3,7 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status 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.core.dependencies import get_current_active_user
from app.models.user import User from app.models.user import User
from app.schemas.common import MessageResponse from app.schemas.common import MessageResponse
@@ -35,7 +33,7 @@ async def get_user_favorites(
) -> FavoritesListResponse: ) -> FavoritesListResponse:
"""Get all favorites for the current user.""" """Get all favorites for the current user."""
favorites = await favorite_service.get_user_favorites( favorites = await favorite_service.get_user_favorites(
current_user.id, limit, offset current_user.id, limit, offset,
) )
return FavoritesListResponse(favorites=favorites) return FavoritesListResponse(favorites=favorites)
@@ -49,7 +47,7 @@ async def get_user_sound_favorites(
) -> FavoritesListResponse: ) -> FavoritesListResponse:
"""Get sound favorites for the current user.""" """Get sound favorites for the current user."""
favorites = await favorite_service.get_user_sound_favorites( favorites = await favorite_service.get_user_sound_favorites(
current_user.id, limit, offset current_user.id, limit, offset,
) )
return FavoritesListResponse(favorites=favorites) return FavoritesListResponse(favorites=favorites)
@@ -63,7 +61,7 @@ async def get_user_playlist_favorites(
) -> FavoritesListResponse: ) -> FavoritesListResponse:
"""Get playlist favorites for the current user.""" """Get playlist favorites for the current user."""
favorites = await favorite_service.get_user_playlist_favorites( favorites = await favorite_service.get_user_playlist_favorites(
current_user.id, limit, offset current_user.id, limit, offset,
) )
return FavoritesListResponse(favorites=favorites) return FavoritesListResponse(favorites=favorites)
@@ -94,16 +92,15 @@ async def add_sound_favorite(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=str(e), detail=str(e),
) from e ) from e
elif "already favorited" in str(e): if "already favorited" in str(e):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=str(e), detail=str(e),
) from e ) from e
else: raise HTTPException(
raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST,
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e),
detail=str(e), ) from e
) from e
@router.post("/playlists/{playlist_id}", response_model=FavoriteResponse) @router.post("/playlists/{playlist_id}", response_model=FavoriteResponse)
@@ -115,7 +112,7 @@ async def add_playlist_favorite(
"""Add a playlist to favorites.""" """Add a playlist to favorites."""
try: try:
favorite = await favorite_service.add_playlist_favorite( favorite = await favorite_service.add_playlist_favorite(
current_user.id, playlist_id current_user.id, playlist_id,
) )
return FavoriteResponse.model_validate(favorite) return FavoriteResponse.model_validate(favorite)
except ValueError as e: except ValueError as e:
@@ -124,16 +121,15 @@ async def add_playlist_favorite(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=str(e), detail=str(e),
) from e ) from e
elif "already favorited" in str(e): if "already favorited" in str(e):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=str(e), detail=str(e),
) from e ) from e
else: raise HTTPException(
raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST,
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e),
detail=str(e), ) from e
) from e
@router.delete("/sounds/{sound_id}", response_model=MessageResponse) @router.delete("/sounds/{sound_id}", response_model=MessageResponse)
@@ -189,6 +185,6 @@ async def check_playlist_favorited(
) -> dict[str, bool]: ) -> dict[str, bool]:
"""Check if a playlist is favorited by the current user.""" """Check if a playlist is favorited by the current user."""
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,
) )
return {"is_favorited": is_favorited} return {"is_favorited": is_favorited}

View File

@@ -40,8 +40,9 @@ def get_favorite_service() -> FavoriteService:
@router.get("/") @router.get("/")
async def get_all_playlists( # noqa: PLR0913 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)], playlist_service: Annotated[PlaylistService, Depends(get_playlist_service)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
search: Annotated[ search: Annotated[
str | None, str | None,
Query(description="Search playlists by name"), Query(description="Search playlists by name"),
@@ -62,9 +63,13 @@ async def get_all_playlists( # noqa: PLR0913
int, int,
Query(description="Number of results to skip", ge=0), Query(description="Number of results to skip", ge=0),
] = 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.""" """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, search_query=search,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
@@ -72,8 +77,29 @@ async def get_all_playlists( # noqa: PLR0913
include_stats=True, include_stats=True,
limit=limit, limit=limit,
offset=offset, 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") @router.get("/user")
async def get_user_playlists( async def get_user_playlists(

View File

@@ -8,7 +8,6 @@ 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.credit_action import CreditActionType
from app.models.sound import Sound
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

View File

@@ -105,7 +105,7 @@ class FavoriteRepository(BaseRepository[Favorite]):
statement = ( statement = (
select(Favorite) select(Favorite)
.where( .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) .limit(limit)
.offset(offset) .offset(offset)
@@ -118,7 +118,7 @@ class FavoriteRepository(BaseRepository[Favorite]):
raise raise
async def get_by_user_and_sound( async def get_by_user_and_sound(
self, user_id: int, sound_id: int self, user_id: int, sound_id: int,
) -> Favorite | None: ) -> Favorite | None:
"""Get a favorite by user and sound. """Get a favorite by user and sound.
@@ -132,18 +132,18 @@ class FavoriteRepository(BaseRepository[Favorite]):
""" """
try: try:
statement = select(Favorite).where( 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) result = await self.session.exec(statement)
return result.first() return result.first()
except Exception: except Exception:
logger.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 raise
async def get_by_user_and_playlist( async def get_by_user_and_playlist(
self, user_id: int, playlist_id: int self, user_id: int, playlist_id: int,
) -> Favorite | None: ) -> Favorite | None:
"""Get a favorite by user and playlist. """Get a favorite by user and playlist.
@@ -157,7 +157,7 @@ class FavoriteRepository(BaseRepository[Favorite]):
""" """
try: try:
statement = select(Favorite).where( 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) result = await self.session.exec(statement)
return result.first() return result.first()

View File

@@ -9,6 +9,7 @@ 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.favorite import Favorite
from app.models.playlist import Playlist from app.models.playlist import Playlist
from app.models.playlist_sound import PlaylistSound from app.models.playlist_sound import PlaylistSound
from app.models.sound import Sound from app.models.sound import Sound
@@ -340,6 +341,8 @@ class PlaylistRepository(BaseRepository[Playlist]):
include_stats: bool = False, # noqa: FBT001, FBT002 include_stats: bool = False, # noqa: FBT001, FBT002
limit: int | None = None, limit: int | None = None,
offset: int = 0, offset: int = 0,
favorites_only: bool = False,
current_user_id: int | None = None,
) -> list[dict]: ) -> list[dict]:
"""Search and sort playlists with optional statistics.""" """Search and sort playlists with optional statistics."""
try: try:
@@ -387,6 +390,15 @@ class PlaylistRepository(BaseRepository[Playlist]):
if user_id is not None: if user_id is not None:
subquery = subquery.where(Playlist.user_id == user_id) 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 # Apply sorting
if sort_by == PlaylistSortField.SOUND_COUNT: if sort_by == PlaylistSortField.SOUND_COUNT:
if sort_order == SortOrder.DESC: if sort_order == SortOrder.DESC:
@@ -449,6 +461,15 @@ class PlaylistRepository(BaseRepository[Playlist]):
if user_id is not None: if user_id is not None:
subquery = subquery.where(Playlist.user_id == user_id) 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 # Apply sorting
if sort_by: if sort_by:
if sort_by == PlaylistSortField.NAME: if sort_by == PlaylistSortField.NAME:

View File

@@ -11,10 +11,10 @@ class FavoriteResponse(BaseModel):
id: int = Field(description="Favorite ID") id: int = Field(description="Favorite ID")
user_id: int = Field(description="User ID") user_id: int = Field(description="User ID")
sound_id: int | None = Field( 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( 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -49,6 +49,7 @@ class PlaylistResponse(BaseModel):
Returns: Returns:
PlaylistResponse instance PlaylistResponse instance
""" """
if playlist.id is None: if playlist.id is None:
msg = "Playlist ID cannot be None" msg = "Playlist ID cannot be None"

View File

@@ -18,16 +18,16 @@ class SoundResponse(BaseModel):
size: int = Field(description="File size in bytes") size: int = Field(description="File size in bytes")
hash: str = Field(description="File hash") hash: str = Field(description="File hash")
normalized_filename: str | None = Field( normalized_filename: str | None = Field(
description="Normalized filename", default=None description="Normalized filename", default=None,
) )
normalized_duration: int | None = Field( normalized_duration: int | None = Field(
description="Normalized duration in milliseconds", default=None description="Normalized duration in milliseconds", default=None,
) )
normalized_size: int | None = Field( 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( 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) thumbnail: str | None = Field(description="Thumbnail filename", default=None)
play_count: int = Field(description="Number of times played") 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_music: bool = Field(description="Whether the sound is music")
is_deletable: bool = Field(description="Whether the sound can be deleted") is_deletable: bool = Field(description="Whether the sound can be deleted")
is_favorited: bool = Field( 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( 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") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -50,7 +50,7 @@ class SoundResponse(BaseModel):
@classmethod @classmethod
def from_sound( 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": ) -> "SoundResponse":
"""Create a SoundResponse from a Sound model. """Create a SoundResponse from a Sound model.
@@ -61,6 +61,7 @@ class SoundResponse(BaseModel):
Returns: Returns:
SoundResponse instance SoundResponse instance
""" """
if sound.id is None: if sound.id is None:
raise ValueError("Sound ID cannot be None") raise ValueError("Sound ID cannot be None")

View File

@@ -6,9 +6,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.favorite import Favorite 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.favorite import FavoriteRepository
from app.repositories.playlist import PlaylistRepository from app.repositories.playlist import PlaylistRepository
from app.repositories.sound import SoundRepository 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) existing = await favorite_repo.get_by_user_and_sound(user_id, sound_id)
if existing: if existing:
raise ValueError( 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 # Create favorite
@@ -106,11 +103,11 @@ class FavoriteService:
# Check if already favorited # Check if already favorited
existing = await favorite_repo.get_by_user_and_playlist( existing = await favorite_repo.get_by_user_and_playlist(
user_id, playlist_id user_id, playlist_id,
) )
if existing: if existing:
raise ValueError( 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 # Create favorite
@@ -159,16 +156,16 @@ class FavoriteService:
favorite_repo = FavoriteRepository(session) favorite_repo = FavoriteRepository(session)
favorite = await favorite_repo.get_by_user_and_playlist( favorite = await favorite_repo.get_by_user_and_playlist(
user_id, playlist_id user_id, playlist_id,
) )
if not favorite: if not favorite:
raise ValueError( 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) await favorite_repo.delete(favorite)
logger.info( 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( async def get_user_favorites(
@@ -233,7 +230,7 @@ class FavoriteService:
async with self.db_session_factory() as session: async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session) favorite_repo = FavoriteRepository(session)
return await favorite_repo.get_user_playlist_favorites( 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: async def is_sound_favorited(self, user_id: int, sound_id: int) -> bool:

View File

@@ -246,6 +246,8 @@ class PlaylistService:
include_stats: bool = False, include_stats: bool = False,
limit: int | None = None, limit: int | None = None,
offset: int = 0, offset: int = 0,
favorites_only: bool = False,
current_user_id: int | None = None,
) -> list[dict]: ) -> list[dict]:
"""Search and sort playlists with optional statistics.""" """Search and sort playlists with optional statistics."""
return await self.playlist_repo.search_and_sort( return await self.playlist_repo.search_and_sort(
@@ -256,6 +258,8 @@ class PlaylistService:
include_stats=include_stats, include_stats=include_stats,
limit=limit, limit=limit,
offset=offset, offset=offset,
favorites_only=favorites_only,
current_user_id=current_user_id,
) )
async def get_playlist_sounds(self, playlist_id: int) -> list[Sound]: async def get_playlist_sounds(self, playlist_id: int) -> list[Sound]: