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, auth,
dashboard, dashboard,
extractions, extractions,
favorites,
files, files,
main, main,
player, player,
@@ -22,6 +23,7 @@ api_router = APIRouter(prefix="/v1")
api_router.include_router(auth.router, tags=["authentication"]) api_router.include_router(auth.router, tags=["authentication"])
api_router.include_router(dashboard.router, tags=["dashboard"]) api_router.include_router(dashboard.router, tags=["dashboard"])
api_router.include_router(extractions.router, tags=["extractions"]) 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(files.router, tags=["files"])
api_router.include_router(main.router, tags=["main"]) api_router.include_router(main.router, tags=["main"])
api_router.include_router(player.router, tags=["player"]) 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, PlaylistStatsResponse,
PlaylistUpdateRequest, PlaylistUpdateRequest,
) )
from app.services.favorite import FavoriteService
from app.services.playlist import PlaylistService from app.services.playlist import PlaylistService
router = APIRouter(prefix="/playlists", tags=["playlists"]) router = APIRouter(prefix="/playlists", tags=["playlists"])
@@ -31,6 +32,12 @@ async def get_playlist_service(
return PlaylistService(session) 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("/") @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)], # noqa: ARG001
@@ -72,30 +79,46 @@ async def get_all_playlists( # noqa: PLR0913
async def get_user_playlists( async def get_user_playlists(
current_user: Annotated[User, Depends(get_current_active_user_flexible)], 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)],
) -> list[PlaylistResponse]: ) -> list[PlaylistResponse]:
"""Get playlists for the current user only.""" """Get playlists for the current user only."""
playlists = await playlist_service.get_user_playlists(current_user.id) 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") @router.get("/main")
async def get_main_playlist( 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)], playlist_service: Annotated[PlaylistService, Depends(get_playlist_service)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> PlaylistResponse: ) -> PlaylistResponse:
"""Get the global main playlist.""" """Get the global main playlist."""
playlist = await playlist_service.get_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") @router.get("/current")
async def get_current_playlist( 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)], playlist_service: Annotated[PlaylistService, Depends(get_playlist_service)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> PlaylistResponse: ) -> PlaylistResponse:
"""Get the global current playlist (falls back to main playlist).""" """Get the global current playlist (falls back to main playlist)."""
playlist = await playlist_service.get_current_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("/") @router.post("/")
@@ -117,12 +140,15 @@ async def create_playlist(
@router.get("/{playlist_id}") @router.get("/{playlist_id}")
async def get_playlist( async def get_playlist(
playlist_id: int, 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)], playlist_service: Annotated[PlaylistService, Depends(get_playlist_service)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
) -> PlaylistResponse: ) -> PlaylistResponse:
"""Get a specific playlist.""" """Get a specific playlist."""
playlist = await playlist_service.get_playlist_by_id(playlist_id) 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}") @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.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.services.credit import CreditService, InsufficientCreditsError from app.services.credit import CreditService, InsufficientCreditsError
from app.services.favorite import FavoriteService
from app.services.vlc_player import VLCPlayerService, get_vlc_player_service from app.services.vlc_player import VLCPlayerService, get_vlc_player_service
router = APIRouter(prefix="/sounds", tags=["sounds"]) router = APIRouter(prefix="/sounds", tags=["sounds"])
@@ -27,6 +29,11 @@ def get_credit_service() -> CreditService:
return CreditService(get_session_factory()) return CreditService(get_session_factory())
def get_favorite_service() -> FavoriteService:
"""Get the favorite service."""
return FavoriteService(get_session_factory())
async def get_sound_repository( async def get_sound_repository(
session: Annotated[AsyncSession, Depends(get_db)], session: Annotated[AsyncSession, Depends(get_db)],
) -> SoundRepository: ) -> SoundRepository:
@@ -34,10 +41,11 @@ async def get_sound_repository(
return SoundRepository(session) return SoundRepository(session)
@router.get("/") @router.get("/", response_model=SoundsListResponse)
async def get_sounds( # noqa: PLR0913 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)], sound_repo: Annotated[SoundRepository, Depends(get_sound_repository)],
favorite_service: Annotated[FavoriteService, Depends(get_favorite_service)],
types: Annotated[ types: Annotated[
list[str] | None, list[str] | None,
Query(description="Filter by sound types (e.g., SDB, TTS, EXT)"), Query(description="Filter by sound types (e.g., SDB, TTS, EXT)"),
@@ -62,7 +70,7 @@ async def get_sounds( # noqa: PLR0913
int, int,
Query(description="Number of results to skip", ge=0), Query(description="Number of results to skip", ge=0),
] = 0, ] = 0,
) -> dict[str, list[Sound]]: ) -> SoundsListResponse:
"""Get sounds with optional search, filtering, and sorting.""" """Get sounds with optional search, filtering, and sorting."""
try: try:
sounds = await sound_repo.search_and_sort( sounds = await sound_repo.search_and_sort(
@@ -73,13 +81,22 @@ async def get_sounds( # noqa: PLR0913
limit=limit, limit=limit,
offset=offset, 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: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get sounds: {e!s}", detail=f"Failed to get sounds: {e!s}",
) from e ) from e
else: else:
return {"sounds": sounds} return SoundsListResponse(sounds=sound_responses)
# VLC PLAYER # VLC PLAYER

View File

@@ -9,6 +9,7 @@ from app.core.logging import get_logger
from app.core.seeds import seed_all_data from app.core.seeds import seed_all_data
from app.models import ( # noqa: F401 from app.models import ( # noqa: F401
extraction, extraction,
favorite,
plan, plan,
playlist, playlist,
playlist_sound, playlist_sound,

29
app/models/favorite.py Normal file
View File

@@ -0,0 +1,29 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, UniqueConstraint
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.playlist import Playlist
from app.models.sound import Sound
from app.models.user import User
class Favorite(BaseModel, table=True):
"""Database model for user favorites (sounds and playlists)."""
user_id: int = Field(foreign_key="user.id", nullable=False)
sound_id: int | None = Field(foreign_key="sound.id", default=None)
playlist_id: int | None = Field(foreign_key="playlist.id", default=None)
# constraints
__table_args__ = (
UniqueConstraint("user_id", "sound_id", name="uq_favorite_user_sound"),
UniqueConstraint("user_id", "playlist_id", name="uq_favorite_user_playlist"),
)
# relationships
user: "User" = Relationship(back_populates="favorites")
sound: "Sound" = Relationship(back_populates="favorites")
playlist: "Playlist" = Relationship(back_populates="favorites")

View File

@@ -5,6 +5,7 @@ from sqlmodel import Field, Relationship
from app.models.base import BaseModel from app.models.base import BaseModel
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.favorite import Favorite
from app.models.playlist_sound import PlaylistSound from app.models.playlist_sound import PlaylistSound
from app.models.user import User from app.models.user import User
@@ -23,3 +24,4 @@ class Playlist(BaseModel, table=True):
# relationships # relationships
user: "User" = Relationship(back_populates="playlists") user: "User" = Relationship(back_populates="playlists")
playlist_sounds: list["PlaylistSound"] = Relationship(back_populates="playlist") playlist_sounds: list["PlaylistSound"] = Relationship(back_populates="playlist")
favorites: list["Favorite"] = Relationship(back_populates="playlist")

View File

@@ -6,6 +6,7 @@ from app.models.base import BaseModel
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.extraction import Extraction from app.models.extraction import Extraction
from app.models.favorite import Favorite
from app.models.playlist_sound import PlaylistSound from app.models.playlist_sound import PlaylistSound
from app.models.sound_played import SoundPlayed from app.models.sound_played import SoundPlayed
@@ -36,3 +37,4 @@ class Sound(BaseModel, table=True):
playlist_sounds: list["PlaylistSound"] = Relationship(back_populates="sound") playlist_sounds: list["PlaylistSound"] = Relationship(back_populates="sound")
extractions: list["Extraction"] = Relationship(back_populates="sound") extractions: list["Extraction"] = Relationship(back_populates="sound")
play_history: list["SoundPlayed"] = Relationship(back_populates="sound") play_history: list["SoundPlayed"] = Relationship(back_populates="sound")
favorites: list["Favorite"] = Relationship(back_populates="sound")

View File

@@ -8,6 +8,7 @@ from app.models.base import BaseModel
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.credit_transaction import CreditTransaction from app.models.credit_transaction import CreditTransaction
from app.models.extraction import Extraction from app.models.extraction import Extraction
from app.models.favorite import Favorite
from app.models.plan import Plan from app.models.plan import Plan
from app.models.playlist import Playlist from app.models.playlist import Playlist
from app.models.sound_played import SoundPlayed from app.models.sound_played import SoundPlayed
@@ -37,3 +38,4 @@ class User(BaseModel, table=True):
sounds_played: list["SoundPlayed"] = Relationship(back_populates="user") sounds_played: list["SoundPlayed"] = Relationship(back_populates="user")
extractions: list["Extraction"] = Relationship(back_populates="user") extractions: list["Extraction"] = Relationship(back_populates="user")
credit_transactions: list["CreditTransaction"] = Relationship(back_populates="user") credit_transactions: list["CreditTransaction"] = Relationship(back_populates="user")
favorites: list["Favorite"] = Relationship(back_populates="user")

View File

@@ -0,0 +1,252 @@
"""Repository for managing favorites."""
from sqlmodel import and_, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger
from app.models.favorite import Favorite
from app.repositories.base import BaseRepository
logger = get_logger(__name__)
class FavoriteRepository(BaseRepository[Favorite]):
"""Repository for managing favorites."""
def __init__(self, session: AsyncSession) -> None:
"""Initialize the favorite repository.
Args:
session: Database session
"""
super().__init__(Favorite, session)
async def get_user_favorites(
self,
user_id: int,
limit: int = 100,
offset: int = 0,
) -> list[Favorite]:
"""Get all favorites for a user.
Args:
user_id: The user ID
limit: Maximum number of favorites to return
offset: Number of favorites to skip
Returns:
List of user favorites
"""
try:
statement = (
select(Favorite)
.where(Favorite.user_id == user_id)
.limit(limit)
.offset(offset)
.order_by(Favorite.created_at.desc())
)
result = await self.session.exec(statement)
return list(result.all())
except Exception:
logger.exception("Failed to get favorites for user: %s", user_id)
raise
async def get_user_sound_favorites(
self,
user_id: int,
limit: int = 100,
offset: int = 0,
) -> list[Favorite]:
"""Get sound favorites for a user.
Args:
user_id: The user ID
limit: Maximum number of favorites to return
offset: Number of favorites to skip
Returns:
List of user sound favorites
"""
try:
statement = (
select(Favorite)
.where(and_(Favorite.user_id == user_id, Favorite.sound_id.isnot(None)))
.limit(limit)
.offset(offset)
.order_by(Favorite.created_at.desc())
)
result = await self.session.exec(statement)
return list(result.all())
except Exception:
logger.exception("Failed to get sound favorites for user: %s", user_id)
raise
async def get_user_playlist_favorites(
self,
user_id: int,
limit: int = 100,
offset: int = 0,
) -> list[Favorite]:
"""Get playlist favorites for a user.
Args:
user_id: The user ID
limit: Maximum number of favorites to return
offset: Number of favorites to skip
Returns:
List of user playlist favorites
"""
try:
statement = (
select(Favorite)
.where(
and_(Favorite.user_id == user_id, Favorite.playlist_id.isnot(None))
)
.limit(limit)
.offset(offset)
.order_by(Favorite.created_at.desc())
)
result = await self.session.exec(statement)
return list(result.all())
except Exception:
logger.exception("Failed to get playlist favorites for user: %s", user_id)
raise
async def get_by_user_and_sound(
self, user_id: int, sound_id: int
) -> Favorite | None:
"""Get a favorite by user and sound.
Args:
user_id: The user ID
sound_id: The sound ID
Returns:
The favorite if found, None otherwise
"""
try:
statement = select(Favorite).where(
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
)
raise
async def get_by_user_and_playlist(
self, user_id: int, playlist_id: int
) -> Favorite | None:
"""Get a favorite by user and playlist.
Args:
user_id: The user ID
playlist_id: The playlist ID
Returns:
The favorite if found, None otherwise
"""
try:
statement = select(Favorite).where(
and_(Favorite.user_id == user_id, Favorite.playlist_id == playlist_id)
)
result = await self.session.exec(statement)
return result.first()
except Exception:
logger.exception(
"Failed to get favorite for user %s and playlist %s",
user_id,
playlist_id,
)
raise
async def is_sound_favorited(self, user_id: int, sound_id: int) -> bool:
"""Check if a sound is favorited by a user.
Args:
user_id: The user ID
sound_id: The sound ID
Returns:
True if the sound is favorited, False otherwise
"""
favorite = await self.get_by_user_and_sound(user_id, sound_id)
return favorite is not None
async def is_playlist_favorited(self, user_id: int, playlist_id: int) -> bool:
"""Check if a playlist is favorited by a user.
Args:
user_id: The user ID
playlist_id: The playlist ID
Returns:
True if the playlist is favorited, False otherwise
"""
favorite = await self.get_by_user_and_playlist(user_id, playlist_id)
return favorite is not None
async def count_user_favorites(self, user_id: int) -> int:
"""Count total favorites for a user.
Args:
user_id: The user ID
Returns:
Total number of favorites
"""
try:
statement = select(Favorite).where(Favorite.user_id == user_id)
result = await self.session.exec(statement)
return len(list(result.all()))
except Exception:
logger.exception("Failed to count favorites for user: %s", user_id)
raise
async def count_sound_favorites(self, sound_id: int) -> int:
"""Count how many users have favorited a sound.
Args:
sound_id: The sound ID
Returns:
Number of users who favorited this sound
"""
try:
statement = select(Favorite).where(Favorite.sound_id == sound_id)
result = await self.session.exec(statement)
return len(list(result.all()))
except Exception:
logger.exception("Failed to count favorites for sound: %s", sound_id)
raise
async def count_playlist_favorites(self, playlist_id: int) -> int:
"""Count how many users have favorited a playlist.
Args:
playlist_id: The playlist ID
Returns:
Number of users who favorited this playlist
"""
try:
statement = select(Favorite).where(Favorite.playlist_id == playlist_id)
result = await self.session.exec(statement)
return len(list(result.all()))
except Exception:
logger.exception("Failed to count favorites for playlist: %s", playlist_id)
raise

39
app/schemas/favorite.py Normal file
View File

@@ -0,0 +1,39 @@
"""Favorite response schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
class FavoriteResponse(BaseModel):
"""Response schema for a favorite."""
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
)
playlist_id: int | None = Field(
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")
class Config:
"""Pydantic config."""
from_attributes = True
class FavoritesListResponse(BaseModel):
"""Response schema for a list of favorites."""
favorites: list[FavoriteResponse] = Field(description="List of favorites")
class FavoriteCountsResponse(BaseModel):
"""Response schema for favorite counts."""
total: int = Field(description="Total number of favorites")
sounds: int = Field(description="Number of favorited sounds")
playlists: int = Field(description="Number of favorited playlists")

View File

@@ -33,12 +33,23 @@ class PlaylistResponse(BaseModel):
is_main: bool is_main: bool
is_current: bool is_current: bool
is_deletable: bool is_deletable: bool
is_favorited: bool = False
favorite_count: int = 0
created_at: str created_at: str
updated_at: str | None updated_at: str | None
@classmethod @classmethod
def from_playlist(cls, playlist: Playlist) -> "PlaylistResponse": def from_playlist(cls, playlist: Playlist, is_favorited: bool = False, favorite_count: int = 0) -> "PlaylistResponse":
"""Create response from playlist model.""" """Create response from playlist model.
Args:
playlist: The Playlist model
is_favorited: Whether the playlist is favorited by the current user
favorite_count: Number of users who favorited this playlist
Returns:
PlaylistResponse instance
"""
if playlist.id is None: if playlist.id is None:
msg = "Playlist ID cannot be None" msg = "Playlist ID cannot be None"
raise ValueError(msg) raise ValueError(msg)
@@ -50,6 +61,8 @@ class PlaylistResponse(BaseModel):
is_main=playlist.is_main, is_main=playlist.is_main,
is_current=playlist.is_current, is_current=playlist.is_current,
is_deletable=playlist.is_deletable, is_deletable=playlist.is_deletable,
is_favorited=is_favorited,
favorite_count=favorite_count,
created_at=playlist.created_at.isoformat(), created_at=playlist.created_at.isoformat(),
updated_at=playlist.updated_at.isoformat() if playlist.updated_at else None, updated_at=playlist.updated_at.isoformat() if playlist.updated_at else None,
) )

95
app/schemas/sound.py Normal file
View File

@@ -0,0 +1,95 @@
"""Sound response schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.sound import Sound
class SoundResponse(BaseModel):
"""Response schema for a sound with favorite indicator."""
id: int = Field(description="Sound ID")
type: str = Field(description="Sound type")
name: str = Field(description="Sound name")
filename: str = Field(description="Sound filename")
duration: int = Field(description="Duration in milliseconds")
size: int = Field(description="File size in bytes")
hash: str = Field(description="File hash")
normalized_filename: str | None = Field(
description="Normalized filename", default=None
)
normalized_duration: int | None = Field(
description="Normalized duration in milliseconds", default=None
)
normalized_size: int | None = Field(
description="Normalized file size in bytes", default=None
)
normalized_hash: str | None = Field(
description="Normalized file hash", default=None
)
thumbnail: str | None = Field(description="Thumbnail filename", default=None)
play_count: int = Field(description="Number of times played")
is_normalized: bool = Field(description="Whether the sound is normalized")
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
)
favorite_count: int = Field(
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")
class Config:
"""Pydantic config."""
from_attributes = True
@classmethod
def from_sound(
cls, sound: Sound, is_favorited: bool = False, favorite_count: int = 0
) -> "SoundResponse":
"""Create a SoundResponse from a Sound model.
Args:
sound: The Sound model
is_favorited: Whether the sound is favorited by the current user
favorite_count: Number of users who favorited this sound
Returns:
SoundResponse instance
"""
if sound.id is None:
raise ValueError("Sound ID cannot be None")
return cls(
id=sound.id,
type=sound.type,
name=sound.name,
filename=sound.filename,
duration=sound.duration,
size=sound.size,
hash=sound.hash,
normalized_filename=sound.normalized_filename,
normalized_duration=sound.normalized_duration,
normalized_size=sound.normalized_size,
normalized_hash=sound.normalized_hash,
thumbnail=sound.thumbnail,
play_count=sound.play_count,
is_normalized=sound.is_normalized,
is_music=sound.is_music,
is_deletable=sound.is_deletable,
is_favorited=is_favorited,
favorite_count=favorite_count,
created_at=sound.created_at,
updated_at=sound.updated_at,
)
class SoundsListResponse(BaseModel):
"""Response schema for a list of sounds."""
sounds: list[SoundResponse] = Field(description="List of sounds")

318
app/services/favorite.py Normal file
View File

@@ -0,0 +1,318 @@
"""Service for managing user favorites."""
from collections.abc import Callable
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
from app.repositories.user import UserRepository
logger = get_logger(__name__)
class FavoriteService:
"""Service for managing user favorites."""
def __init__(self, db_session_factory: Callable[[], AsyncSession]) -> None:
"""Initialize the favorite service.
Args:
db_session_factory: Factory function to create database sessions
"""
self.db_session_factory = db_session_factory
async def add_sound_favorite(self, user_id: int, sound_id: int) -> Favorite:
"""Add a sound to user's favorites.
Args:
user_id: The user ID
sound_id: The sound ID
Returns:
The created favorite
Raises:
ValueError: If user or sound not found, or already favorited
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
user_repo = UserRepository(session)
sound_repo = SoundRepository(session)
# Verify user exists
user = await user_repo.get_by_id(user_id)
if not user:
raise ValueError(f"User with ID {user_id} not found")
# Verify sound exists
sound = await sound_repo.get_by_id(sound_id)
if not sound:
raise ValueError(f"Sound with ID {sound_id} not found")
# Check if already favorited
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}"
)
# Create favorite
favorite_data = {
"user_id": user_id,
"sound_id": sound_id,
"playlist_id": None,
}
favorite = await favorite_repo.create(favorite_data)
logger.info("User %s favorited sound %s", user_id, sound_id)
return favorite
async def add_playlist_favorite(self, user_id: int, playlist_id: int) -> Favorite:
"""Add a playlist to user's favorites.
Args:
user_id: The user ID
playlist_id: The playlist ID
Returns:
The created favorite
Raises:
ValueError: If user or playlist not found, or already favorited
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
user_repo = UserRepository(session)
playlist_repo = PlaylistRepository(session)
# Verify user exists
user = await user_repo.get_by_id(user_id)
if not user:
raise ValueError(f"User with ID {user_id} not found")
# Verify playlist exists
playlist = await playlist_repo.get_by_id(playlist_id)
if not playlist:
raise ValueError(f"Playlist with ID {playlist_id} not found")
# Check if already favorited
existing = await favorite_repo.get_by_user_and_playlist(
user_id, playlist_id
)
if existing:
raise ValueError(
f"Playlist {playlist_id} is already favorited by user {user_id}"
)
# Create favorite
favorite_data = {
"user_id": user_id,
"sound_id": None,
"playlist_id": playlist_id,
}
favorite = await favorite_repo.create(favorite_data)
logger.info("User %s favorited playlist %s", user_id, playlist_id)
return favorite
async def remove_sound_favorite(self, user_id: int, sound_id: int) -> None:
"""Remove a sound from user's favorites.
Args:
user_id: The user ID
sound_id: The sound ID
Raises:
ValueError: If favorite not found
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
favorite = await favorite_repo.get_by_user_and_sound(user_id, sound_id)
if not favorite:
raise ValueError(f"Sound {sound_id} is not favorited by user {user_id}")
await favorite_repo.delete(favorite)
logger.info("User %s removed sound %s from favorites", user_id, sound_id)
async def remove_playlist_favorite(self, user_id: int, playlist_id: int) -> None:
"""Remove a playlist from user's favorites.
Args:
user_id: The user ID
playlist_id: The playlist ID
Raises:
ValueError: If favorite not found
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
favorite = await favorite_repo.get_by_user_and_playlist(
user_id, playlist_id
)
if not favorite:
raise ValueError(
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
)
async def get_user_favorites(
self,
user_id: int,
limit: int = 100,
offset: int = 0,
) -> list[Favorite]:
"""Get all favorites for a user.
Args:
user_id: The user ID
limit: Maximum number of favorites to return
offset: Number of favorites to skip
Returns:
List of user favorites
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
return await favorite_repo.get_user_favorites(user_id, limit, offset)
async def get_user_sound_favorites(
self,
user_id: int,
limit: int = 100,
offset: int = 0,
) -> list[Favorite]:
"""Get sound favorites for a user.
Args:
user_id: The user ID
limit: Maximum number of favorites to return
offset: Number of favorites to skip
Returns:
List of user sound favorites
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
return await favorite_repo.get_user_sound_favorites(user_id, limit, offset)
async def get_user_playlist_favorites(
self,
user_id: int,
limit: int = 100,
offset: int = 0,
) -> list[Favorite]:
"""Get playlist favorites for a user.
Args:
user_id: The user ID
limit: Maximum number of favorites to return
offset: Number of favorites to skip
Returns:
List of user playlist favorites
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
return await favorite_repo.get_user_playlist_favorites(
user_id, limit, offset
)
async def is_sound_favorited(self, user_id: int, sound_id: int) -> bool:
"""Check if a sound is favorited by a user.
Args:
user_id: The user ID
sound_id: The sound ID
Returns:
True if the sound is favorited, False otherwise
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
return await favorite_repo.is_sound_favorited(user_id, sound_id)
async def is_playlist_favorited(self, user_id: int, playlist_id: int) -> bool:
"""Check if a playlist is favorited by a user.
Args:
user_id: The user ID
playlist_id: The playlist ID
Returns:
True if the playlist is favorited, False otherwise
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
return await favorite_repo.is_playlist_favorited(user_id, playlist_id)
async def get_favorite_counts(self, user_id: int) -> dict[str, int]:
"""Get favorite counts for a user.
Args:
user_id: The user ID
Returns:
Dictionary with favorite counts
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
total = await favorite_repo.count_user_favorites(user_id)
sounds = len(await favorite_repo.get_user_sound_favorites(user_id))
playlists = len(await favorite_repo.get_user_playlist_favorites(user_id))
return {
"total": total,
"sounds": sounds,
"playlists": playlists,
}
async def get_sound_favorite_count(self, sound_id: int) -> int:
"""Get the number of users who have favorited a sound.
Args:
sound_id: The sound ID
Returns:
Number of users who favorited this sound
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
return await favorite_repo.count_sound_favorites(sound_id)
async def get_playlist_favorite_count(self, playlist_id: int) -> int:
"""Get the number of users who have favorited a playlist.
Args:
playlist_id: The playlist ID
Returns:
Number of users who favorited this playlist
"""
async with self.db_session_factory() as session:
favorite_repo = FavoriteRepository(session)
return await favorite_repo.count_playlist_favorites(playlist_id)