feat: Enhance favorites functionality; add favorites filtering to playlists and sounds, and improve favorite indicators in responses
This commit is contained in:
@@ -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,12 +92,11 @@ 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),
|
||||
@@ -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,12 +121,11 @@ 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),
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -49,6 +49,7 @@ class PlaylistResponse(BaseModel):
|
||||
|
||||
Returns:
|
||||
PlaylistResponse instance
|
||||
|
||||
"""
|
||||
if playlist.id is None:
|
||||
msg = "Playlist ID cannot be None"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user