Feat: add paginated history endpoint

This commit is contained in:
arabcoders 2025-11-13 17:15:05 +03:00
parent 1144ac0e12
commit 1107567f86
4 changed files with 468 additions and 11 deletions

70
API.md
View file

@ -442,22 +442,84 @@ or an error:
---
### GET /api/history
**Purpose**: Returns the download queue and the download history.
**Purpose**: Returns the download queue and/or download history with optional pagination support.
**Response**:
**Query Parameters**:
- `type` (optional): Type of items to return. Default: `all`
- `all` (default): Returns both queue and history (legacy behavior, no pagination)
- `queue`: Returns only queue items (with pagination)
- `done`: Returns only history items (with pagination)
- `page` (optional): Page number (1-indexed). Default: `1`. Only used when `type != all`
- `per_page` (optional): Items per page. Default: `50`, Max: `200`. Only used when `type != all`
- `order` (optional): Sort order. Default: `DESC`. Only used when `type != all`
- `DESC`: Newest items first (descending by creation date)
- `ASC`: Oldest items first (ascending by creation date)
**Response (when `type=all` or no type set)** - Legacy format:
```json
{
"queue": [
{ ... },
{
"id": "abc123",
"url": "https://example.com/video",
"title": "Video Title",
"status": "downloading",
...
},
...
],
"history": [
{ ... },
{
"id": "def456",
"url": "https://example.com/video2",
"title": "Completed Video",
"status": "finished",
...
},
...
]
}
```
**Response (when `type=queue` or `type=done`)** - Paginated format:
```json
{
"pagination": {
"page": 1,
"per_page": 50,
"total": 1234,
"total_pages": 25,
"has_next": true,
"has_prev": false
},
"items": [
{
"id": "abc123",
"url": "https://example.com/video",
"title": "Video Title",
"status": "finished",
...
},
...
]
}
```
**Error Responses**:
- `400 Bad Request` if parameters are invalid:
```json
{ "error": "type must be one of all, queue, done." }
{ "error": "page must be >= 1." }
{ "error": "per_page must be between 1 and 1000." }
{ "error": "order must be ASC or DESC." }
{ "error": "page and per_page must be valid integers." }
```
**Notes**:
- The `type=all` behavior is considered legacy and will be removed in future versions
- For large datasets, use paginated requests (`type=queue` or `type=done`) for better performance
- The `items` array contains ItemDTO objects serialized to JSON
---
### DELETE /api/history/{id}/archive

View file

@ -179,6 +179,88 @@ class DataStore:
self._connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
return True
def get_total_count(self) -> int:
"""
Get the total count of items in the datastore.
Returns:
int: The total number of items in the datastore.
"""
cursor = self._connection.execute(
'SELECT COUNT(*) as count FROM "history" WHERE "type" = ?',
(str(self._type),),
)
row = cursor.fetchone()
return row["count"] if row else 0
def get_items_paginated(
self,
page: int = 1,
per_page: int = 50,
order: str = "DESC",
) -> tuple[list[tuple[str, ItemDTO]], int, int, int]:
"""
Get paginated items from the datastore.
Args:
page (int): The page number (1-indexed). Defaults to 1.
per_page (int): Number of items per page. Defaults to 50.
order (str): Sort order - 'ASC' or 'DESC'. Defaults to 'DESC' (newest first).
Returns:
tuple[list[tuple[str, ItemDTO]], int, int, int]: A tuple containing:
- List of (id, ItemDTO) tuples for the requested page
- Total number of items
- Current page number
- Total number of pages
Raises:
ValueError: If page < 1 or per_page < 1
"""
if page < 1:
msg = "page must be >= 1"
raise ValueError(msg)
if per_page < 1:
msg = "per_page must be >= 1"
raise ValueError(msg)
order = order.upper()
if order not in ("ASC", "DESC"):
msg = f"order must be 'ASC' or 'DESC', got '{order}'"
raise ValueError(msg)
order = "ASC" if order == "ASC" else "DESC"
total_items = self.get_total_count()
total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1
# Ensure page is within valid range.
if page > total_pages and total_items > 0:
page = total_pages
offset = (page - 1) * per_page
items: list[tuple[str, ItemDTO]] = []
cursor = self._connection.execute(
f'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" {order} LIMIT ? OFFSET ?', # noqa: S608
(str(self._type), per_page, offset),
)
for row in cursor:
rowDate: datetime = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data: dict = json.loads(row["data"])
data.pop("_id", None)
item: ItemDTO = init_class(ItemDTO, data)
item._id = row["id"]
item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp())
items.append((row["id"], item))
return items, total_items, page, total_pages
def _update_store_item(self, type: StoreType, item: ItemDTO) -> None:
sqlStatement = """
INSERT INTO "history" ("id", "type", "url", "data")

View file

@ -20,23 +20,84 @@ LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", r"api/history/", "items_list")
async def items_list(queue: DownloadQueue, encoder: Encoder) -> Response:
async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Get the history.
Get the history with optional pagination support.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object.
"""
data: dict = {"queue": [], "history": []}
q = queue.get()
Query Parameters:
type (str): Type of items to return - "all", "queue", or "done". Default: "all"
page (int): Page number for pagination (1-indexed). Only used when type != "all"
per_page (int): Items per page. Default: 50, Max: 1000. Only used when type != "all"
order (str): Sort order - "ASC" or "DESC". Default: "DESC". Only used when type != "all"
data["queue"].extend([q.get("queue", {}).get(k) for k in q.get("queue", {})])
data["history"].extend([q.get("done", {}).get(k) for k in q.get("done", {})])
"""
from app.library.DataStore import StoreType
store_type = request.query.get("type", "all").lower()
stores: list[str] = ["all", StoreType.QUEUE.value, StoreType.HISTORY.value]
if store_type not in stores:
return web.json_response(
data={"error": f"type must be one of {', '.join(stores)}."},
status=web.HTTPBadRequest.status_code,
)
# Legacy behavior: return all items without pagination, will be removed in future.
if "all" == store_type:
data: dict = {"queue": [], "history": []}
q = queue.get()
data["queue"].extend([q.get("queue", {}).get(k) for k in q.get("queue", {})])
data["history"].extend([q.get("done", {}).get(k) for k in q.get("done", {})])
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
ds = queue.queue if store_type == StoreType.QUEUE.value else queue.done
try:
page = int(request.query.get("page", 1))
per_page = int(request.query.get("per_page", 50))
order = request.query.get("order", "DESC").upper()
except ValueError:
return web.json_response(
data={"error": "page and per_page must be valid integers."},
status=web.HTTPBadRequest.status_code,
)
if page < 1:
return web.json_response(data={"error": "page must be >= 1."}, status=web.HTTPBadRequest.status_code)
if per_page < 1 or per_page > 200:
return web.json_response(
data={"error": "per_page must be between 1 and 1000."},
status=web.HTTPBadRequest.status_code,
)
if order not in ("ASC", "DESC"):
return web.json_response(
data={"error": "order must be ASC or DESC."},
status=web.HTTPBadRequest.status_code,
)
items, total, current_page, total_pages = ds.get_items_paginated(page=page, per_page=per_page, order=order)
data = {
"pagination": {
"page": current_page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_next": current_page < total_pages,
"has_prev": current_page > 1,
},
"items": [item for _, item in items],
}
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -0,0 +1,252 @@
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from app.library.DataStore import DataStore, StoreType
from app.library.ItemDTO import ItemDTO
class TestDataStorePagination:
"""Test pagination functionality of DataStore."""
@pytest.fixture
def temp_db(self):
"""Create a temporary database with test data."""
with TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Create the history table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS "history" (
"id" TEXT PRIMARY KEY,
"type" TEXT NOT NULL,
"url" TEXT NOT NULL,
"data" TEXT NOT NULL,
"created_at" TEXT NOT NULL
)
"""
)
# Insert test data
base_time = datetime.now(UTC)
for i in range(100):
created_at = base_time.replace(
hour=(i // 4) % 24, minute=(i * 15) % 60, second=i % 60
).strftime("%Y-%m-%d %H:%M:%S")
item_data = {
"url": f"https://example.com/video{i}",
"title": f"Test Video {i}",
"id": f"video{i}",
"folder": "/downloads",
"status": "finished",
}
conn.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
f"test-id-{i}",
str(StoreType.HISTORY),
item_data["url"],
json.dumps(item_data),
created_at,
),
)
conn.commit()
yield conn
conn.close()
def test_get_total_count(self, temp_db):
"""Test getting total count of items."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
count = datastore.get_total_count()
assert count == 100
def test_pagination_basic(self, temp_db):
"""Test basic pagination functionality."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
# Get first page
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 1
assert total_pages == 10
# Verify items are ItemDTO instances
for _item_id, item in items:
assert isinstance(item, ItemDTO)
assert item._id.startswith("test-id-")
def test_pagination_last_page(self, temp_db):
"""Test pagination on last page."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
# Get last page
items, total, page, total_pages = datastore.get_items_paginated(page=10, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 10
assert total_pages == 10
def test_pagination_partial_page(self, temp_db):
"""Test pagination with partial last page."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
# Get items with per_page that doesn't divide evenly
items, total, page, total_pages = datastore.get_items_paginated(page=4, per_page=30)
assert len(items) == 10 # 100 items / 30 per page = 3 full pages + 10 items
assert total == 100
assert page == 4
assert total_pages == 4
def test_pagination_out_of_range(self, temp_db):
"""Test pagination with page number out of range."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
# Request page beyond total pages - should return last page
items, total, page, total_pages = datastore.get_items_paginated(page=999, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 10 # Adjusted to last page
assert total_pages == 10
def test_pagination_order_desc(self, temp_db):
"""Test pagination with descending order (newest first)."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
items, _, _, _ = datastore.get_items_paginated(page=1, per_page=5, order="DESC")
assert len(items) == 5
# Verify order - should be newest to oldest
# (note: exact order depends on the timestamp generation in fixture)
def test_pagination_order_asc(self, temp_db):
"""Test pagination with ascending order (oldest first)."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
items, _, _, _ = datastore.get_items_paginated(page=1, per_page=5, order="ASC")
assert len(items) == 5
def test_pagination_invalid_page(self, temp_db):
"""Test pagination with invalid page number."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
with pytest.raises(ValueError, match="page must be >= 1"):
datastore.get_items_paginated(page=0, per_page=10)
with pytest.raises(ValueError, match="page must be >= 1"):
datastore.get_items_paginated(page=-1, per_page=10)
def test_pagination_invalid_per_page(self, temp_db):
"""Test pagination with invalid per_page value."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
with pytest.raises(ValueError, match="per_page must be >= 1"):
datastore.get_items_paginated(page=1, per_page=0)
with pytest.raises(ValueError, match="per_page must be >= 1"):
datastore.get_items_paginated(page=1, per_page=-10)
def test_pagination_invalid_order(self, temp_db):
"""Test pagination with invalid order parameter."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
with pytest.raises(ValueError, match="order must be 'ASC' or 'DESC'"):
datastore.get_items_paginated(page=1, per_page=10, order="INVALID")
def test_pagination_empty_store(self):
"""Test pagination with empty datastore."""
with TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "empty.db"
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Create empty table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS "history" (
"id" TEXT PRIMARY KEY,
"type" TEXT NOT NULL,
"url" TEXT NOT NULL,
"data" TEXT NOT NULL,
"created_at" TEXT NOT NULL
)
"""
)
conn.commit()
datastore = DataStore(type=StoreType.HISTORY, connection=conn)
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
assert len(items) == 0
assert total == 0
assert page == 1
assert total_pages == 1
conn.close()
def test_pagination_single_item(self):
"""Test pagination with single item."""
with TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "single.db"
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Create table with single item
conn.execute(
"""
CREATE TABLE IF NOT EXISTS "history" (
"id" TEXT PRIMARY KEY,
"type" TEXT NOT NULL,
"url" TEXT NOT NULL,
"data" TEXT NOT NULL,
"created_at" TEXT NOT NULL
)
"""
)
item_data = {
"url": "https://example.com/single",
"title": "Single Video",
"id": "single-video",
"folder": "/downloads",
"status": "finished",
}
conn.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"single-id",
str(StoreType.HISTORY),
item_data["url"],
json.dumps(item_data),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
conn.commit()
datastore = DataStore(type=StoreType.HISTORY, connection=conn)
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
assert len(items) == 1
assert total == 1
assert page == 1
assert total_pages == 1
conn.close()