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 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}

View File

@@ -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(

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.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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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")

View File

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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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]: