feat: Implement pagination for extractions and playlists with total count in responses

This commit is contained in:
JSC
2025-08-17 11:21:55 +02:00
parent f598ec2c12
commit 99c757a073
6 changed files with 177 additions and 51 deletions

View File

@@ -94,14 +94,18 @@ async def get_all_extractions(
sort_by: Annotated[str, Query(description="Sort by field")] = "created_at", sort_by: Annotated[str, Query(description="Sort by field")] = "created_at",
sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc", sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc",
status_filter: Annotated[str | None, Query(description="Filter by status")] = None, 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.""" """Get all extractions with optional filtering, search, and sorting."""
try: try:
extractions = await extraction_service.get_all_extractions( result = await extraction_service.get_all_extractions(
search=search, search=search,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
status_filter=status_filter, status_filter=status_filter,
page=page,
limit=limit,
) )
except Exception as e: except Exception as e:
@@ -110,9 +114,7 @@ async def get_all_extractions(
detail=f"Failed to get extractions: {e!s}", detail=f"Failed to get extractions: {e!s}",
) from e ) from e
else: else:
return { return result
"extractions": extractions,
}
@router.get("/user") @router.get("/user")
@@ -123,7 +125,9 @@ async def get_user_extractions(
sort_by: Annotated[str, Query(description="Sort by field")] = "created_at", sort_by: Annotated[str, Query(description="Sort by field")] = "created_at",
sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc", sort_order: Annotated[str, Query(description="Sort order (asc/desc)")] = "desc",
status_filter: Annotated[str | None, Query(description="Filter by status")] = None, 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.""" """Get all extractions for the current user."""
try: try:
if current_user.id is None: if current_user.id is None:
@@ -132,12 +136,14 @@ async def get_user_extractions(
detail="User ID not available", detail="User ID not available",
) )
extractions = await extraction_service.get_user_extractions( result = await extraction_service.get_user_extractions(
user_id=current_user.id, user_id=current_user.id,
search=search, search=search,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
status_filter=status_filter, status_filter=status_filter,
page=page,
limit=limit,
) )
except Exception as e: except Exception as e:
@@ -146,6 +152,4 @@ async def get_user_extractions(
detail=f"Failed to get extractions: {e!s}", detail=f"Failed to get extractions: {e!s}",
) from e ) from e
else: else:
return { return result
"extractions": extractions,
}

View File

@@ -1,6 +1,6 @@
"""Playlist management API endpoints.""" """Playlist management API endpoints."""
from typing import Annotated from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -55,35 +55,29 @@ async def get_all_playlists( # noqa: PLR0913
SortOrder, SortOrder,
Query(description="Sort order (asc or desc)"), Query(description="Sort order (asc or desc)"),
] = SortOrder.ASC, ] = SortOrder.ASC,
limit: Annotated[ page: Annotated[int, Query(description="Page number", ge=1)] = 1,
int | None, limit: Annotated[int, Query(description="Items per page", ge=1, le=100)] = 50,
Query(description="Maximum number of results", ge=1, le=1000),
] = None,
offset: Annotated[
int,
Query(description="Number of results to skip", ge=0),
] = 0,
favorites_only: Annotated[ favorites_only: Annotated[
bool, bool,
Query(description="Show only favorited playlists"), Query(description="Show only favorited playlists"),
] = False, ] = False,
) -> list[PlaylistResponse]: ) -> dict[str, Any]:
"""Get all playlists from all users with search and sorting.""" """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, search_query=search,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
user_id=None, user_id=None,
include_stats=True, include_stats=True,
page=page,
limit=limit, limit=limit,
offset=offset,
favorites_only=favorites_only, favorites_only=favorites_only,
current_user_id=current_user.id, current_user_id=current_user.id,
) )
# Convert to PlaylistResponse with favorite indicators # Convert to PlaylistResponse with favorite indicators
playlist_responses = [] 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 # 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"]) 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"]) 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) 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") @router.get("/user")

View File

@@ -1,6 +1,6 @@
"""Extraction repository for database operations.""" """Extraction repository for database operations."""
from sqlalchemy import asc, desc, or_ from sqlalchemy import asc, desc, func, or_
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -65,9 +65,11 @@ class ExtractionRepository(BaseRepository[Extraction]):
sort_by: str = "created_at", sort_by: str = "created_at",
sort_order: str = "desc", sort_order: str = "desc",
status_filter: str | None = None, 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.""" """Get extractions for a user with filtering, search, and sorting."""
query = ( base_query = (
select(Extraction, User) select(Extraction, User)
.join(User, Extraction.user_id == User.id) .join(User, Extraction.user_id == User.id)
.where(Extraction.user_id == user_id) .where(Extraction.user_id == user_id)
@@ -76,7 +78,7 @@ class ExtractionRepository(BaseRepository[Extraction]):
# Apply search filter # Apply search filter
if search: if search:
search_pattern = f"%{search}%" search_pattern = f"%{search}%"
query = query.where( base_query = base_query.where(
or_( or_(
Extraction.title.ilike(search_pattern), Extraction.title.ilike(search_pattern),
Extraction.url.ilike(search_pattern), Extraction.url.ilike(search_pattern),
@@ -86,17 +88,26 @@ class ExtractionRepository(BaseRepository[Extraction]):
# Apply status filter # Apply status filter
if 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) sort_column = getattr(Extraction, sort_by, Extraction.created_at)
if sort_order.lower() == "asc": if sort_order.lower() == "asc":
query = query.order_by(asc(sort_column)) base_query = base_query.order_by(asc(sort_column))
else: else:
query = query.order_by(desc(sort_column)) base_query = base_query.order_by(desc(sort_column))
result = await self.session.exec(query) paginated_query = base_query.limit(limit).offset(offset)
return list(result.all()) result = await self.session.exec(paginated_query)
return list(result.all()), total_count
async def get_all_extractions_filtered( async def get_all_extractions_filtered(
self, self,
@@ -104,14 +115,16 @@ class ExtractionRepository(BaseRepository[Extraction]):
sort_by: str = "created_at", sort_by: str = "created_at",
sort_order: str = "desc", sort_order: str = "desc",
status_filter: str | None = None, 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.""" """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 # Apply search filter
if search: if search:
search_pattern = f"%{search}%" search_pattern = f"%{search}%"
query = query.where( base_query = base_query.where(
or_( or_(
Extraction.title.ilike(search_pattern), Extraction.title.ilike(search_pattern),
Extraction.url.ilike(search_pattern), Extraction.url.ilike(search_pattern),
@@ -121,14 +134,23 @@ class ExtractionRepository(BaseRepository[Extraction]):
# Apply status filter # Apply status filter
if 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) sort_column = getattr(Extraction, sort_by, Extraction.created_at)
if sort_order.lower() == "asc": if sort_order.lower() == "asc":
query = query.order_by(asc(sort_column)) base_query = base_query.order_by(asc(sort_column))
else: else:
query = query.order_by(desc(sort_column)) base_query = base_query.order_by(desc(sort_column))
result = await self.session.exec(query) paginated_query = base_query.limit(limit).offset(offset)
return list(result.all()) result = await self.session.exec(paginated_query)
return list(result.all()), total_count

View File

@@ -343,7 +343,9 @@ class PlaylistRepository(BaseRepository[Playlist]):
offset: int = 0, offset: int = 0,
favorites_only: bool = False, favorites_only: bool = False,
current_user_id: int | None = None, 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.""" """Search and sort playlists with optional statistics."""
try: try:
if include_stats and sort_by in ( if include_stats and sort_by in (
@@ -491,6 +493,14 @@ class PlaylistRepository(BaseRepository[Playlist]):
# Default sorting by name ascending # Default sorting by name ascending
subquery = subquery.order_by(Playlist.name.asc()) 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 # Apply pagination
if offset > 0: if offset > 0:
subquery = subquery.offset(offset) subquery = subquery.offset(offset)
@@ -532,4 +542,6 @@ class PlaylistRepository(BaseRepository[Playlist]):
) )
raise raise
else: else:
if return_count:
return playlists, total_count
return playlists return playlists

View File

@@ -38,6 +38,16 @@ class ExtractionInfo(TypedDict):
updated_at: str 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: class ExtractionService:
"""Service for extracting audio from external services using yt-dlp.""" """Service for extracting audio from external services using yt-dlp."""
@@ -565,17 +575,22 @@ class ExtractionService:
sort_by: str = "created_at", sort_by: str = "created_at",
sort_order: str = "desc", sort_order: str = "desc",
status_filter: str | None = None, 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.""" """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, user_id=user_id,
search=search, search=search,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
status_filter=status_filter, status_filter=status_filter,
limit=limit,
offset=offset,
) )
return [ extractions = [
{ {
"id": extraction.id "id": extraction.id
or 0, # Should never be None for existing extraction or 0, # Should never be None for existing extraction
@@ -594,22 +609,37 @@ class ExtractionService:
for extraction, user in extraction_user_tuples 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( async def get_all_extractions(
self, self,
search: str | None = None, search: str | None = None,
sort_by: str = "created_at", sort_by: str = "created_at",
sort_order: str = "desc", sort_order: str = "desc",
status_filter: str | None = None, status_filter: str | None = None,
) -> list[ExtractionInfo]: page: int = 1,
limit: int = 50,
) -> PaginatedExtractionsResponse:
"""Get all extractions with filtering, search, and sorting.""" """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, search=search,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
status_filter=status_filter, status_filter=status_filter,
limit=limit,
offset=offset,
) )
return [ extractions = [
{ {
"id": extraction.id "id": extraction.id
or 0, # Should never be None for existing extraction or 0, # Should never be None for existing extraction
@@ -628,6 +658,16 @@ class ExtractionService:
for extraction, user in extraction_user_tuples 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]: async def get_pending_extractions(self) -> list[ExtractionInfo]:
"""Get all pending extractions.""" """Get all pending extractions."""
extraction_user_tuples = await self.extraction_repo.get_pending_extractions() extraction_user_tuples = await self.extraction_repo.get_pending_extractions()

View File

@@ -1,6 +1,6 @@
"""Playlist service for business logic operations.""" """Playlist service for business logic operations."""
from typing import Any from typing import Any, TypedDict
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -14,6 +14,15 @@ from app.repositories.sound import SoundRepository
logger = get_logger(__name__) 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: async def _reload_player_playlist() -> None:
"""Reload the player playlist after current playlist changes.""" """Reload the player playlist after current playlist changes."""
try: try:
@@ -262,6 +271,45 @@ class PlaylistService:
current_user_id=current_user_id, 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]: async def get_playlist_sounds(self, playlist_id: int) -> list[Sound]:
"""Get all sounds in a playlist.""" """Get all sounds in a playlist."""
await self.get_playlist_by_id(playlist_id) # Verify playlist exists await self.get_playlist_by_id(playlist_id) # Verify playlist exists