Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions core/bookvault_core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,134 @@ def iter_library(self, limit: int = 100) -> Iterator[dict]:
# jitter the gap so the cadence doesn't look mechanically scripted.
time.sleep(random.uniform(0.3, 0.7))


@staticmethod
def normalize_library_item(art: dict) -> dict:
"""Shape one raw `/users/me/arts` item into stable, full metadata.

The library listing already carries rich fields (authors, narrators,
series, purchase date, cover, DRM flags, rating, ...). Detail-only
fields such as ISBN / HTML annotation / genres are NOT on this
endpoint and are left out rather than inventing empty keys.

Used by the MCP `list_library` tool and the web UI's book list so both
surfaces stay in lockstep.
"""
persons_in = art.get("persons") or []
authors: list[str] = []
narrators: list[str] = []
persons: list[dict] = []
for person in persons_in:
name = person.get("full_name") or ""
role = person.get("role") or ""
if name:
persons.append(
{
"id": person.get("id"),
"full_name": name,
"role": role,
"url": person.get("url"),
}
)
if role == "author" and name:
authors.append(name)
elif role == "reader" and name:
narrators.append(name)

series_list = []
for s in art.get("series") or []:
if not isinstance(s, dict):
continue
name = s.get("name")
if not name:
continue
series_list.append(
{
"id": s.get("id"),
"name": name,
"url": s.get("url"),
"art_order": s.get("art_order"),
"arts_count": s.get("arts_count") or s.get("unique_arts_count"),
}
)
primary_series = series_list[0] if series_list else None

cover = art.get("cover_url")
if cover and not str(cover).startswith(("http://", "https://")):
cover = f"https://static.litres.ru{cover}"

rel_url = art.get("url") or ""
if rel_url and not str(rel_url).startswith(("http://", "https://")):
page_url = f"https://www.litres.ru{rel_url}"
else:
page_url = rel_url or None

art_type = art.get("art_type")
rating = art.get("rating") or {}
prices = art.get("prices") or {}
labels = art.get("labels") or {}

title = art.get("title") or (str(art.get("id")) if art.get("id") is not None else "")

return {
"id": art.get("id"),
"uuid": art.get("uuid"),
"title": title,
"subtitle": art.get("subtitle") or "",
"url": page_url,
"cover_url": cover or None,
"cover_width": art.get("cover_width"),
"cover_height": art.get("cover_height"),
"art_type": art_type,
"is_audio": art_type == 1,
"language_code": art.get("language_code"),
"min_age": art.get("min_age"),
"authors": authors,
"authors_str": ", ".join(authors),
"narrators": narrators,
"narrators_str": ", ".join(narrators),
"persons": persons,
"series": primary_series,
"series_list": series_list,
"purchased_at": art.get("purchased_at"),
"date_written_at": art.get("date_written_at"),
"available_from": art.get("available_from"),
"last_released_at": art.get("last_released_at"),
"last_updated_at": art.get("last_updated_at"),
"symbols_count": art.get("symbols_count"),
"is_drm": bool(art.get("is_drm")),
"is_adult_content": bool(art.get("is_adult_content")),
"is_free": bool(art.get("is_free")),
"is_archived": bool(art.get("is_archived")),
"labels": {
"is_bestseller": bool(labels.get("is_bestseller")),
"is_new": bool(labels.get("is_new")),
"is_sales_hit": bool(labels.get("is_sales_hit")),
"is_litres_exclusive": bool(labels.get("is_litres_exclusive")),
},
"rating_avg": rating.get("rated_avg"),
"rating_count": rating.get("rated_total_count"),
"prices": {
"final_price": prices.get("final_price"),
"full_price": prices.get("full_price"),
"currency": prices.get("currency"),
"discount_percent": prices.get("discount_percent"),
}
if prices
else None,
"synchronized_art_ids": [
a.get("id") for a in (art.get("synchronized_arts") or []) if a.get("id") is not None
],
"alternative_versions": [
{"id": a.get("id"), "art_type": a.get("art_type"), "link_type": a.get("link_type")}
for a in (art.get("alternative_versions") or [])
if a.get("id") is not None
],
"read_percent": art.get("read_percent"),
"my_art_status": art.get("my_art_status"),
"release_file_id": art.get("release_file_id"),
}

def get_files(self, art_id, should_cancel=None) -> list:
"""Flat list of {id, extension, file_type, mime, size, is_additional} for one art."""
resp = self._get_retrying(f"{API_BASE}/arts/{art_id}/files/grouped", should_cancel=should_cancel)
Expand Down
12 changes: 9 additions & 3 deletions mcp/bookvault_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from mcp.server.fastmcp import FastMCP

from bookvault_core import session
from bookvault_core.client import LitresAuthError
from bookvault_core.client import LitresAuthError, LitresClient

load_dotenv()

Expand Down Expand Up @@ -77,14 +77,20 @@ def _sync():

@mcp.tool()
async def list_library(limit: int = 50) -> list:
"""List up to `limit` items from the logged-in user's purchased litres.ru library."""
"""List up to `limit` purchased litres.ru items with full library metadata.

Each item is shaped by `LitresClient.normalize_library_item` and includes
id/title/authors/narrators/series/cover/url/is_audio/purchased_at/dates/
language/rating/DRM flags and related fields available on the library
listing endpoint (not detail-only fields like ISBN or HTML annotation).
"""
await _ensure_logged_in()
client = session.current_client()

def _sync():
items = []
for art in client.iter_library(limit=limit):
items.append({"id": art.get("id"), "title": art.get("title")})
items.append(LitresClient.normalize_library_item(art))
if len(items) >= limit:
break
return items
Expand Down
93 changes: 93 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,3 +686,96 @@ def test_sleep_interruptible_stops_early_on_cancel():

def test_sleep_interruptible_sleeps_fully_without_cancel_hook():
assert client_mod._sleep_interruptible(0.01, should_cancel=None) is True


# --------------------------------------------------------------------------
# normalize_library_item -- pure metadata shaping from a library-list art.
# --------------------------------------------------------------------------


def test_normalize_library_item_full_shape():
art = {
"id": 42,
"uuid": "u-42",
"title": "The Book",
"subtitle": "A tale",
"art_type": 1,
"language_code": "ru",
"min_age": 16,
"cover_url": "/pub/c/cover/42.jpg",
"cover_width": 100,
"cover_height": 200,
"url": "/audiobook/author/the-book-42/",
"purchased_at": "2026-01-01T00:00:00",
"date_written_at": "2025-01-01",
"available_from": "2025-06-01T00:00:00",
"last_released_at": "2025-06-02T00:00:00",
"last_updated_at": "2025-06-03T00:00:00",
"symbols_count": 12345,
"is_drm": False,
"is_adult_content": False,
"is_free": False,
"is_archived": False,
"persons": [
{"id": 1, "full_name": "Author A", "role": "author", "url": "/author/a/"},
{"id": 2, "full_name": "Reader R", "role": "reader", "url": "/author/r/"},
{"id": 3, "full_name": "Editor E", "role": "editor"},
],
"series": [
{
"id": 9,
"name": "The Saga",
"url": "/series/saga-9/",
"art_order": 3,
"unique_arts_count": 10,
}
],
"labels": {"is_bestseller": True, "is_new": False, "is_sales_hit": True, "is_litres_exclusive": False},
"rating": {"rated_avg": 4.5, "rated_total_count": 12},
"prices": {
"final_price": 99.0,
"full_price": 199.0,
"currency": "RUB",
"discount_percent": 50,
},
"synchronized_arts": [{"id": 100}],
"alternative_versions": [{"id": 100, "art_type": 0, "link_type": "SYNCED"}],
"read_percent": 10,
"my_art_status": 1,
"release_file_id": 555,
}
meta = LitresClient.normalize_library_item(art)
assert meta["id"] == 42
assert meta["uuid"] == "u-42"
assert meta["title"] == "The Book"
assert meta["subtitle"] == "A tale"
assert meta["is_audio"] is True
assert meta["authors"] == ["Author A"]
assert meta["narrators"] == ["Reader R"]
assert meta["authors_str"] == "Author A"
assert meta["narrators_str"] == "Reader R"
assert meta["cover_url"] == "https://static.litres.ru/pub/c/cover/42.jpg"
assert meta["url"] == "https://www.litres.ru/audiobook/author/the-book-42/"
assert meta["series"]["name"] == "The Saga"
assert meta["series"]["art_order"] == 3
assert meta["series"]["arts_count"] == 10
assert meta["rating_avg"] == 4.5
assert meta["rating_count"] == 12
assert meta["prices"]["final_price"] == 99.0
assert meta["labels"]["is_bestseller"] is True
assert meta["synchronized_art_ids"] == [100]
assert meta["alternative_versions"][0]["id"] == 100
# Non-author/reader persons are still listed under persons.
assert {p["role"] for p in meta["persons"]} == {"author", "reader", "editor"}


def test_normalize_library_item_handles_sparse_art():
meta = LitresClient.normalize_library_item({"id": 7, "title": None, "art_type": 0})
assert meta["id"] == 7
assert meta["title"] == "7"
assert meta["is_audio"] is False
assert meta["authors"] == []
assert meta["series"] is None
assert meta["cover_url"] is None
assert meta["url"] is None
assert meta["prices"] is None
30 changes: 28 additions & 2 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,38 @@ async def test_list_library_bootstraps_via_restore_session(monkeypatch):
client_factory(
monkeypatch,
session,
library=[{"id": 1, "title": "Book One"}, {"id": 2, "title": "Book Two"}],
library=[
{
"id": 1,
"title": "Book One",
"art_type": 0,
"persons": [{"full_name": "Author A", "role": "author"}],
"cover_url": "/pub/c/cover/1.jpg",
"url": "/book/author-a/book-one-1/",
"purchased_at": "2026-01-02T03:04:05",
"series": [{"id": 9, "name": "Saga", "art_order": 1}],
},
{"id": 2, "title": "Book Two", "art_type": 1, "persons": [], "cover_url": None},
],
)

items = await mcp_server.list_library()

assert items == [{"id": 1, "title": "Book One"}, {"id": 2, "title": "Book Two"}]
assert len(items) == 2
assert items[0]["id"] == 1
assert items[0]["title"] == "Book One"
assert items[0]["authors"] == ["Author A"]
assert items[0]["authors_str"] == "Author A"
assert items[0]["is_audio"] is False
assert items[0]["cover_url"] == "https://static.litres.ru/pub/c/cover/1.jpg"
assert items[0]["url"] == "https://www.litres.ru/book/author-a/book-one-1/"
assert items[0]["purchased_at"] == "2026-01-02T03:04:05"
assert items[0]["series"]["name"] == "Saga"
assert items[1]["id"] == 2
assert items[1]["is_audio"] is True
# Full metadata keys are always present (even when source fields are missing).
for key in ("narrators", "language_code", "is_drm", "labels", "rating_avg"):
assert key in items[0]


async def test_list_library_respects_limit(monkeypatch):
Expand Down
20 changes: 12 additions & 8 deletions web/bookvault_web/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,22 @@ def _update(**changes) -> None:

def build_books(client: LitresClient) -> list:
"""Turn the raw litres.ru library listing into the flat book shape the
web UI renders (id/title/authors/is_audio/cover_url)."""
web UI renders (id/title/authors/is_audio/cover_url).

Built on `LitresClient.normalize_library_item` so web and MCP share one
metadata mapping; the UI keeps its slim card fields, while MCP returns
the full normalized dict.
"""
books = []
for art in client.iter_library():
authors = [p.get("full_name") for p in (art.get("persons") or []) if p.get("role") == "author"]
cover_url = art.get("cover_url")
meta = LitresClient.normalize_library_item(art)
books.append(
{
"id": art.get("id"),
"title": art.get("title") or str(art.get("id")),
"authors": ", ".join(a for a in authors if a),
"is_audio": art.get("art_type") == 1,
"cover_url": f"https://static.litres.ru{cover_url}" if cover_url else None,
"id": meta["id"],
"title": meta["title"],
"authors": meta["authors_str"],
"is_audio": meta["is_audio"],
"cover_url": meta["cover_url"],
}
)
return books
Expand Down