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

View File

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

View File

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

View File

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

View File

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

View File

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