diff --git a/app/api/v1/extractions.py b/app/api/v1/extractions.py index 3e991a1..b139cc2 100644 --- a/app/api/v1/extractions.py +++ b/app/api/v1/extractions.py @@ -94,14 +94,18 @@ async def get_all_extractions( sort_by: Annotated[str, Query(description="Sort by field")] = "created_at", sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc", status_filter: Annotated[str | None, Query(description="Filter by status")] = None, -) -> dict[str, list[ExtractionInfo]]: + page: Annotated[int, Query(description="Page number", ge=1)] = 1, + limit: Annotated[int, Query(description="Items per page", ge=1, le=100)] = 50, +) -> dict: """Get all extractions with optional filtering, search, and sorting.""" try: - extractions = await extraction_service.get_all_extractions( + result = await extraction_service.get_all_extractions( search=search, sort_by=sort_by, sort_order=sort_order, status_filter=status_filter, + page=page, + limit=limit, ) except Exception as e: @@ -110,9 +114,7 @@ async def get_all_extractions( detail=f"Failed to get extractions: {e!s}", ) from e else: - return { - "extractions": extractions, - } + return result @router.get("/user") @@ -123,7 +125,9 @@ async def get_user_extractions( sort_by: Annotated[str, Query(description="Sort by field")] = "created_at", sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc", status_filter: Annotated[str | None, Query(description="Filter by status")] = None, -) -> dict[str, list[ExtractionInfo]]: + page: Annotated[int, Query(description="Page number", ge=1)] = 1, + limit: Annotated[int, Query(description="Items per page", ge=1, le=100)] = 50, +) -> dict: """Get all extractions for the current user.""" try: if current_user.id is None: @@ -132,12 +136,14 @@ async def get_user_extractions( detail="User ID not available", ) - extractions = await extraction_service.get_user_extractions( + result = await extraction_service.get_user_extractions( user_id=current_user.id, search=search, sort_by=sort_by, sort_order=sort_order, status_filter=status_filter, + page=page, + limit=limit, ) except Exception as e: @@ -146,6 +152,4 @@ async def get_user_extractions( detail=f"Failed to get extractions: {e!s}", ) from e else: - return { - "extractions": extractions, - } + return result diff --git a/app/api/v1/playlists.py b/app/api/v1/playlists.py index a055d5a..76cac08 100644 --- a/app/api/v1/playlists.py +++ b/app/api/v1/playlists.py @@ -1,6 +1,6 @@ """Playlist management API endpoints.""" -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel.ext.asyncio.session import AsyncSession @@ -55,35 +55,29 @@ async def get_all_playlists( # noqa: PLR0913 SortOrder, Query(description="Sort order (asc or desc)"), ] = SortOrder.ASC, - limit: Annotated[ - int | None, - Query(description="Maximum number of results", ge=1, le=1000), - ] = None, - offset: Annotated[ - int, - Query(description="Number of results to skip", ge=0), - ] = 0, + page: Annotated[int, Query(description="Page number", ge=1)] = 1, + limit: Annotated[int, Query(description="Items per page", ge=1, le=100)] = 50, favorites_only: Annotated[ bool, Query(description="Show only favorited playlists"), ] = False, -) -> list[PlaylistResponse]: +) -> dict[str, Any]: """Get all playlists from all users with search and sorting.""" - playlists = await playlist_service.search_and_sort_playlists( + result = await playlist_service.search_and_sort_playlists_paginated( search_query=search, sort_by=sort_by, sort_order=sort_order, user_id=None, include_stats=True, + page=page, 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: + for playlist_dict in result["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"]) @@ -98,7 +92,13 @@ async def get_all_playlists( # noqa: PLR0913 } playlist_responses.append(playlist_response) - return playlist_responses + return { + "playlists": playlist_responses, + "total": result["total"], + "page": result["page"], + "limit": result["limit"], + "total_pages": result["total_pages"], + } @router.get("/user") diff --git a/app/repositories/extraction.py b/app/repositories/extraction.py index f6b4d4a..ab6fe1d 100644 --- a/app/repositories/extraction.py +++ b/app/repositories/extraction.py @@ -1,6 +1,6 @@ """Extraction repository for database operations.""" -from sqlalchemy import asc, desc, or_ +from sqlalchemy import asc, desc, func, or_ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -65,9 +65,11 @@ class ExtractionRepository(BaseRepository[Extraction]): sort_by: str = "created_at", sort_order: str = "desc", status_filter: str | None = None, - ) -> list[tuple[Extraction, User]]: + limit: int = 50, + offset: int = 0, + ) -> tuple[list[tuple[Extraction, User]], int]: """Get extractions for a user with filtering, search, and sorting.""" - query = ( + base_query = ( select(Extraction, User) .join(User, Extraction.user_id == User.id) .where(Extraction.user_id == user_id) @@ -76,7 +78,7 @@ class ExtractionRepository(BaseRepository[Extraction]): # Apply search filter if search: search_pattern = f"%{search}%" - query = query.where( + base_query = base_query.where( or_( Extraction.title.ilike(search_pattern), Extraction.url.ilike(search_pattern), @@ -86,17 +88,26 @@ class ExtractionRepository(BaseRepository[Extraction]): # Apply status filter if status_filter: - query = query.where(Extraction.status == status_filter) + base_query = base_query.where(Extraction.status == status_filter) - # Apply sorting + # Get total count before pagination + count_query = select(func.count()).select_from( + base_query.subquery() + ) + count_result = await self.session.exec(count_query) + total_count = count_result.one() + + # Apply sorting and pagination sort_column = getattr(Extraction, sort_by, Extraction.created_at) if sort_order.lower() == "asc": - query = query.order_by(asc(sort_column)) + base_query = base_query.order_by(asc(sort_column)) else: - query = query.order_by(desc(sort_column)) + base_query = base_query.order_by(desc(sort_column)) - result = await self.session.exec(query) - return list(result.all()) + paginated_query = base_query.limit(limit).offset(offset) + result = await self.session.exec(paginated_query) + + return list(result.all()), total_count async def get_all_extractions_filtered( self, @@ -104,14 +115,16 @@ class ExtractionRepository(BaseRepository[Extraction]): sort_by: str = "created_at", sort_order: str = "desc", status_filter: str | None = None, - ) -> list[tuple[Extraction, User]]: + limit: int = 50, + offset: int = 0, + ) -> tuple[list[tuple[Extraction, User]], int]: """Get all extractions with filtering, search, and sorting.""" - query = select(Extraction, User).join(User, Extraction.user_id == User.id) + base_query = select(Extraction, User).join(User, Extraction.user_id == User.id) # Apply search filter if search: search_pattern = f"%{search}%" - query = query.where( + base_query = base_query.where( or_( Extraction.title.ilike(search_pattern), Extraction.url.ilike(search_pattern), @@ -121,14 +134,23 @@ class ExtractionRepository(BaseRepository[Extraction]): # Apply status filter if status_filter: - query = query.where(Extraction.status == status_filter) + base_query = base_query.where(Extraction.status == status_filter) - # Apply sorting + # Get total count before pagination + count_query = select(func.count()).select_from( + base_query.subquery() + ) + count_result = await self.session.exec(count_query) + total_count = count_result.one() + + # Apply sorting and pagination sort_column = getattr(Extraction, sort_by, Extraction.created_at) if sort_order.lower() == "asc": - query = query.order_by(asc(sort_column)) + base_query = base_query.order_by(asc(sort_column)) else: - query = query.order_by(desc(sort_column)) + base_query = base_query.order_by(desc(sort_column)) - result = await self.session.exec(query) - return list(result.all()) + paginated_query = base_query.limit(limit).offset(offset) + result = await self.session.exec(paginated_query) + + return list(result.all()), total_count diff --git a/app/repositories/playlist.py b/app/repositories/playlist.py index a90eacf..768b87c 100644 --- a/app/repositories/playlist.py +++ b/app/repositories/playlist.py @@ -343,7 +343,9 @@ class PlaylistRepository(BaseRepository[Playlist]): offset: int = 0, favorites_only: bool = False, current_user_id: int | None = None, - ) -> list[dict]: + *, + return_count: bool = False, + ) -> list[dict] | tuple[list[dict], int]: """Search and sort playlists with optional statistics.""" try: if include_stats and sort_by in ( @@ -491,6 +493,14 @@ class PlaylistRepository(BaseRepository[Playlist]): # Default sorting by name ascending subquery = subquery.order_by(Playlist.name.asc()) + # Get total count if requested + total_count = 0 + if return_count: + # Create count query from the subquery before pagination + count_query = select(func.count()).select_from(subquery.subquery()) + count_result = await self.session.exec(count_query) + total_count = count_result.one() + # Apply pagination if offset > 0: subquery = subquery.offset(offset) @@ -532,4 +542,6 @@ class PlaylistRepository(BaseRepository[Playlist]): ) raise else: + if return_count: + return playlists, total_count return playlists diff --git a/app/services/extraction.py b/app/services/extraction.py index 3238ac9..bb648a1 100644 --- a/app/services/extraction.py +++ b/app/services/extraction.py @@ -38,6 +38,16 @@ class ExtractionInfo(TypedDict): updated_at: str +class PaginatedExtractionsResponse(TypedDict): + """Type definition for paginated extractions response.""" + + extractions: list[ExtractionInfo] + total: int + page: int + limit: int + total_pages: int + + class ExtractionService: """Service for extracting audio from external services using yt-dlp.""" @@ -565,17 +575,22 @@ class ExtractionService: sort_by: str = "created_at", sort_order: str = "desc", status_filter: str | None = None, - ) -> list[ExtractionInfo]: + page: int = 1, + limit: int = 50, + ) -> PaginatedExtractionsResponse: """Get all extractions for a user with filtering, search, and sorting.""" - extraction_user_tuples = await self.extraction_repo.get_user_extractions_filtered( + offset = (page - 1) * limit + extraction_user_tuples, total_count = await self.extraction_repo.get_user_extractions_filtered( user_id=user_id, search=search, sort_by=sort_by, sort_order=sort_order, status_filter=status_filter, + limit=limit, + offset=offset, ) - return [ + extractions = [ { "id": extraction.id or 0, # Should never be None for existing extraction @@ -594,22 +609,37 @@ class ExtractionService: for extraction, user in extraction_user_tuples ] + total_pages = (total_count + limit - 1) // limit # Ceiling division + + return { + "extractions": extractions, + "total": total_count, + "page": page, + "limit": limit, + "total_pages": total_pages, + } + async def get_all_extractions( self, search: str | None = None, sort_by: str = "created_at", sort_order: str = "desc", status_filter: str | None = None, - ) -> list[ExtractionInfo]: + page: int = 1, + limit: int = 50, + ) -> PaginatedExtractionsResponse: """Get all extractions with filtering, search, and sorting.""" - extraction_user_tuples = await self.extraction_repo.get_all_extractions_filtered( + offset = (page - 1) * limit + extraction_user_tuples, total_count = await self.extraction_repo.get_all_extractions_filtered( search=search, sort_by=sort_by, sort_order=sort_order, status_filter=status_filter, + limit=limit, + offset=offset, ) - return [ + extractions = [ { "id": extraction.id or 0, # Should never be None for existing extraction @@ -628,6 +658,16 @@ class ExtractionService: for extraction, user in extraction_user_tuples ] + total_pages = (total_count + limit - 1) // limit # Ceiling division + + return { + "extractions": extractions, + "total": total_count, + "page": page, + "limit": limit, + "total_pages": total_pages, + } + async def get_pending_extractions(self) -> list[ExtractionInfo]: """Get all pending extractions.""" extraction_user_tuples = await self.extraction_repo.get_pending_extractions() diff --git a/app/services/playlist.py b/app/services/playlist.py index 45c1476..f7ee0d5 100644 --- a/app/services/playlist.py +++ b/app/services/playlist.py @@ -1,6 +1,6 @@ """Playlist service for business logic operations.""" -from typing import Any +from typing import Any, TypedDict from fastapi import HTTPException, status from sqlmodel.ext.asyncio.session import AsyncSession @@ -14,6 +14,15 @@ from app.repositories.sound import SoundRepository logger = get_logger(__name__) +class PaginatedPlaylistsResponse(TypedDict): + """Response type for paginated playlists.""" + playlists: list[dict] + total: int + page: int + limit: int + total_pages: int + + async def _reload_player_playlist() -> None: """Reload the player playlist after current playlist changes.""" try: @@ -262,6 +271,45 @@ class PlaylistService: current_user_id=current_user_id, ) + async def search_and_sort_playlists_paginated( # noqa: PLR0913 + self, + search_query: str | None = None, + sort_by: PlaylistSortField | None = None, + sort_order: SortOrder = SortOrder.ASC, + user_id: int | None = None, + *, + include_stats: bool = False, + page: int = 1, + limit: int = 50, + favorites_only: bool = False, + current_user_id: int | None = None, + ) -> PaginatedPlaylistsResponse: + """Search and sort playlists with pagination.""" + offset = (page - 1) * limit + + playlists, total_count = await self.playlist_repo.search_and_sort( + search_query=search_query, + sort_by=sort_by, + sort_order=sort_order, + user_id=user_id, + include_stats=include_stats, + limit=limit, + offset=offset, + favorites_only=favorites_only, + current_user_id=current_user_id, + return_count=True, + ) + + total_pages = (total_count + limit - 1) // limit # Ceiling division + + return PaginatedPlaylistsResponse( + playlists=playlists, + total=total_count, + page=page, + limit=limit, + total_pages=total_pages, + ) + async def get_playlist_sounds(self, playlist_id: int) -> list[Sound]: """Get all sounds in a playlist.""" await self.get_playlist_by_id(playlist_id) # Verify playlist exists