Spaces:
Running
Running
Ying Jiang commited on
Commit ·
6987db3
1
Parent(s): c3265d8
readinglist, bookmark, profile some backend + frontend changes
Browse files- api.py +35 -1
- db/db.py +56 -0
- db/models.py +1 -1
- db/schemas.py +8 -0
- services/mangadex_service.py +48 -0
api.py
CHANGED
|
@@ -3,7 +3,7 @@ Read-only API for the frontend. Wraps db list_entries, get_segments, get_chapter
|
|
| 3 |
Run from backend: uvicorn api:app --reload --host 0.0.0.0 --port 8000
|
| 4 |
"""
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, FastAPI, HTTPException, Query
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.responses import JSONResponse
|
| 9 |
import proxy
|
|
@@ -23,6 +23,7 @@ from db.schemas import (
|
|
| 23 |
ReadingListCollectionRenameIn,
|
| 24 |
ReadingListItemOut,
|
| 25 |
SegmentListOut,
|
|
|
|
| 26 |
)
|
| 27 |
|
| 28 |
# app = FastAPI(
|
|
@@ -152,6 +153,7 @@ def post_reading_list_collection(
|
|
| 152 |
manga_count=0,
|
| 153 |
created_at=col.created_at,
|
| 154 |
updated_at=col.updated_at,
|
|
|
|
| 155 |
)
|
| 156 |
|
| 157 |
|
|
@@ -178,6 +180,7 @@ def patch_reading_list_collection(
|
|
| 178 |
manga_count=0,
|
| 179 |
created_at=col.created_at,
|
| 180 |
updated_at=col.updated_at,
|
|
|
|
| 181 |
)
|
| 182 |
|
| 183 |
|
|
@@ -240,6 +243,21 @@ def delete_reading_list_item(
|
|
| 240 |
raise HTTPException(status_code=404, detail="Not on this reading list")
|
| 241 |
return {"ok": True}
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
###########
|
| 244 |
###########
|
| 245 |
###########
|
|
@@ -252,6 +270,15 @@ async def proxy_manga_page(chapter_id: str, page_index: int):
|
|
| 252 |
|
| 253 |
return await proxy.get_manga_page_stream(urls[page_index])
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
@router.get("/api/manga/cover_art")
|
| 256 |
async def proxy_manga_cover_art(manga_id: str, file_name: str, size: int = 256):
|
| 257 |
url = f"https://uploads.mangadex.org/covers/{manga_id}/{file_name}.{size}.jpg"
|
|
@@ -266,6 +293,13 @@ async def get_manga_cover_json(manga_id: str):
|
|
| 266 |
return {"cover_url": cover_url}
|
| 267 |
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
@router.get("/api/manga/search")
|
| 270 |
async def get_popular_manga(
|
| 271 |
title: str = "",
|
|
|
|
| 3 |
Run from backend: uvicorn api:app --reload --host 0.0.0.0 --port 8000
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, FastAPI, HTTPException, Query, Response
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.responses import JSONResponse
|
| 9 |
import proxy
|
|
|
|
| 23 |
ReadingListCollectionRenameIn,
|
| 24 |
ReadingListItemOut,
|
| 25 |
SegmentListOut,
|
| 26 |
+
UserDisplayNamePatchIn,
|
| 27 |
)
|
| 28 |
|
| 29 |
# app = FastAPI(
|
|
|
|
| 153 |
manga_count=0,
|
| 154 |
created_at=col.created_at,
|
| 155 |
updated_at=col.updated_at,
|
| 156 |
+
latest_external_manga_id=None,
|
| 157 |
)
|
| 158 |
|
| 159 |
|
|
|
|
| 180 |
manga_count=0,
|
| 181 |
created_at=col.created_at,
|
| 182 |
updated_at=col.updated_at,
|
| 183 |
+
latest_external_manga_id=None,
|
| 184 |
)
|
| 185 |
|
| 186 |
|
|
|
|
| 243 |
raise HTTPException(status_code=404, detail="Not on this reading list")
|
| 244 |
return {"ok": True}
|
| 245 |
|
| 246 |
+
|
| 247 |
+
@router.patch("/users/me")
|
| 248 |
+
def patch_me_display_name(ctx: CurrentAuthContext, body: UserDisplayNamePatchIn):
|
| 249 |
+
"""Update display_name on public.users for the signed-in user."""
|
| 250 |
+
try:
|
| 251 |
+
db.update_app_user_display_name(
|
| 252 |
+
ctx.user_id,
|
| 253 |
+
body.display_name,
|
| 254 |
+
email=ctx.email,
|
| 255 |
+
)
|
| 256 |
+
except ValueError as e:
|
| 257 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 258 |
+
return Response(status_code=204)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
###########
|
| 262 |
###########
|
| 263 |
###########
|
|
|
|
| 270 |
|
| 271 |
return await proxy.get_manga_page_stream(urls[page_index])
|
| 272 |
|
| 273 |
+
|
| 274 |
+
@router.get("/api/manga/chapter/{chapter_id}/pages")
|
| 275 |
+
def get_chapter_page_urls(chapter_id: str):
|
| 276 |
+
"""MangaDex at-home CDN URLs for every page in a chapter (for client readers)."""
|
| 277 |
+
urls = mangadex_service.get_chapter_panel_urls(chapter_id)
|
| 278 |
+
if not urls:
|
| 279 |
+
raise HTTPException(status_code=404, detail="No pages for this chapter")
|
| 280 |
+
return {"urls": urls}
|
| 281 |
+
|
| 282 |
@router.get("/api/manga/cover_art")
|
| 283 |
async def proxy_manga_cover_art(manga_id: str, file_name: str, size: int = 256):
|
| 284 |
url = f"https://uploads.mangadex.org/covers/{manga_id}/{file_name}.{size}.jpg"
|
|
|
|
| 293 |
return {"cover_url": cover_url}
|
| 294 |
|
| 295 |
|
| 296 |
+
@router.get("/api/manga/{manga_id}/info")
|
| 297 |
+
async def get_manga_info_json(manga_id: str):
|
| 298 |
+
"""MangaDex title + synopsis for reading-list rows (no DB storage)."""
|
| 299 |
+
info = await mangadex_service.get_manga_public_info(manga_id)
|
| 300 |
+
return info
|
| 301 |
+
|
| 302 |
+
|
| 303 |
@router.get("/api/manga/search")
|
| 304 |
async def get_popular_manga(
|
| 305 |
title: str = "",
|
db/db.py
CHANGED
|
@@ -537,6 +537,29 @@ def ensure_app_user(
|
|
| 537 |
session.commit()
|
| 538 |
|
| 539 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
def get_reading_list_collection(
|
| 541 |
user_id: UUID, collection_id: int, db_url=None
|
| 542 |
) -> Optional[ReadingListCollection]:
|
|
@@ -631,6 +654,38 @@ def list_reading_list_collections_with_counts(
|
|
| 631 |
.group_by(ReadingListItem.reading_list_id)
|
| 632 |
)
|
| 633 |
count_map = {rid: int(n) for rid, n in session.exec(cnt_stmt).all()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
return [
|
| 635 |
ReadingListCollectionOut(
|
| 636 |
id=c.id,
|
|
@@ -638,6 +693,7 @@ def list_reading_list_collections_with_counts(
|
|
| 638 |
created_at=c.created_at,
|
| 639 |
updated_at=c.updated_at,
|
| 640 |
manga_count=count_map.get(c.id, 0),
|
|
|
|
| 641 |
)
|
| 642 |
for c in cols
|
| 643 |
]
|
|
|
|
| 537 |
session.commit()
|
| 538 |
|
| 539 |
|
| 540 |
+
def update_app_user_display_name(
|
| 541 |
+
user_id: UUID,
|
| 542 |
+
display_name: Optional[str],
|
| 543 |
+
email: Optional[str] = None,
|
| 544 |
+
db_url=None,
|
| 545 |
+
) -> None:
|
| 546 |
+
"""Sync display_name on public.users (client updates Supabase Auth metadata separately)."""
|
| 547 |
+
trimmed = (display_name or "").strip() or None
|
| 548 |
+
if trimmed and len(trimmed) > 200:
|
| 549 |
+
raise ValueError("Display name is too long")
|
| 550 |
+
ensure_app_user(user_id, email=email, db_url=db_url)
|
| 551 |
+
engine = get_engine(db_url)
|
| 552 |
+
now = datetime.now(timezone.utc)
|
| 553 |
+
with Session(engine) as session:
|
| 554 |
+
row = session.exec(select(Users).where(Users.id == user_id)).first()
|
| 555 |
+
if row is None:
|
| 556 |
+
return
|
| 557 |
+
row.display_name = trimmed
|
| 558 |
+
row.updated_at = now
|
| 559 |
+
session.add(row)
|
| 560 |
+
session.commit()
|
| 561 |
+
|
| 562 |
+
|
| 563 |
def get_reading_list_collection(
|
| 564 |
user_id: UUID, collection_id: int, db_url=None
|
| 565 |
) -> Optional[ReadingListCollection]:
|
|
|
|
| 654 |
.group_by(ReadingListItem.reading_list_id)
|
| 655 |
)
|
| 656 |
count_map = {rid: int(n) for rid, n in session.exec(cnt_stmt).all()}
|
| 657 |
+
latest_ext: dict[int, Optional[str]] = {}
|
| 658 |
+
if ids:
|
| 659 |
+
rows = list(
|
| 660 |
+
session.exec(
|
| 661 |
+
select(ReadingListItem, MangaSource)
|
| 662 |
+
.join(Manga, ReadingListItem.manga_id == Manga.id)
|
| 663 |
+
.outerjoin(
|
| 664 |
+
MangaSource,
|
| 665 |
+
(MangaSource.manga_id == Manga.id)
|
| 666 |
+
& (MangaSource.provider_id == PROVIDER_MANGADEX),
|
| 667 |
+
)
|
| 668 |
+
.where(ReadingListItem.reading_list_id.in_(ids))
|
| 669 |
+
.order_by(
|
| 670 |
+
ReadingListItem.reading_list_id,
|
| 671 |
+
desc(ReadingListItem.created_at),
|
| 672 |
+
)
|
| 673 |
+
).all()
|
| 674 |
+
)
|
| 675 |
+
for item, src in rows:
|
| 676 |
+
lid = item.reading_list_id
|
| 677 |
+
if lid in latest_ext:
|
| 678 |
+
continue
|
| 679 |
+
ext: Optional[str] = None
|
| 680 |
+
if (
|
| 681 |
+
src
|
| 682 |
+
and src.external_manga_id
|
| 683 |
+
and not _is_placeholder_external_manga_id(
|
| 684 |
+
src.external_manga_id
|
| 685 |
+
)
|
| 686 |
+
):
|
| 687 |
+
ext = src.external_manga_id
|
| 688 |
+
latest_ext[lid] = ext
|
| 689 |
return [
|
| 690 |
ReadingListCollectionOut(
|
| 691 |
id=c.id,
|
|
|
|
| 693 |
created_at=c.created_at,
|
| 694 |
updated_at=c.updated_at,
|
| 695 |
manga_count=count_map.get(c.id, 0),
|
| 696 |
+
latest_external_manga_id=latest_ext.get(c.id),
|
| 697 |
)
|
| 698 |
for c in cols
|
| 699 |
]
|
db/models.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
User table: app profile keyed by Supabase auth user id (UUID)
|
| 3 |
-
id: UUID, primary key —
|
| 4 |
email: optional, unique when set
|
| 5 |
display_name: optional
|
| 6 |
created_at / updated_at: datetime
|
|
|
|
| 1 |
"""
|
| 2 |
User table: app profile keyed by Supabase auth user id (UUID)
|
| 3 |
+
id: UUID, primary key — same as auth.users.id when using Supabase Auth
|
| 4 |
email: optional, unique when set
|
| 5 |
display_name: optional
|
| 6 |
created_at / updated_at: datetime
|
db/schemas.py
CHANGED
|
@@ -35,6 +35,8 @@ class ReadingListCollectionOut(SQLModel):
|
|
| 35 |
manga_count: int = 0
|
| 36 |
created_at: Optional[datetime] = None
|
| 37 |
updated_at: Optional[datetime] = None
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
class ReadingListCollectionCreateIn(SQLModel):
|
|
@@ -45,6 +47,12 @@ class ReadingListCollectionRenameIn(SQLModel):
|
|
| 45 |
name: str
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
class ReadingListItemOut(SQLModel):
|
| 49 |
"""One manga row inside a named list (with umbrella title + optional MangaDex id)."""
|
| 50 |
|
|
|
|
| 35 |
manga_count: int = 0
|
| 36 |
created_at: Optional[datetime] = None
|
| 37 |
updated_at: Optional[datetime] = None
|
| 38 |
+
#: MangaDex id of the most recently *added* list item (for cover thumbnails).
|
| 39 |
+
latest_external_manga_id: Optional[str] = None
|
| 40 |
|
| 41 |
|
| 42 |
class ReadingListCollectionCreateIn(SQLModel):
|
|
|
|
| 47 |
name: str
|
| 48 |
|
| 49 |
|
| 50 |
+
class UserDisplayNamePatchIn(SQLModel):
|
| 51 |
+
"""Display name stored in public.users and mirrored from Supabase Auth user_metadata."""
|
| 52 |
+
|
| 53 |
+
display_name: str = Field(default="", max_length=200)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
class ReadingListItemOut(SQLModel):
|
| 57 |
"""One manga row inside a named list (with umbrella title + optional MangaDex id)."""
|
| 58 |
|
services/mangadex_service.py
CHANGED
|
@@ -39,6 +39,54 @@ async def get_manga_cover_url_256(manga_id: str) -> str | None:
|
|
| 39 |
return _cover_url_from_manga_payload(data, manga_id)
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
async def search_manga(title: str, limit: int =20, offset: int = 0, order_by: str = "followedCount", order_direction: str = "desc", cover_art: bool = True):
|
| 43 |
"""
|
| 44 |
Todo: filters by tags (include, exclude)
|
|
|
|
| 39 |
return _cover_url_from_manga_payload(data, manga_id)
|
| 40 |
|
| 41 |
|
| 42 |
+
def _pick_localized_string(obj) -> str | None:
|
| 43 |
+
if not isinstance(obj, dict) or not obj:
|
| 44 |
+
return None
|
| 45 |
+
en = obj.get("en")
|
| 46 |
+
if isinstance(en, str) and en.strip():
|
| 47 |
+
return en.strip()
|
| 48 |
+
for v in obj.values():
|
| 49 |
+
if isinstance(v, str) and v.strip():
|
| 50 |
+
return v.strip()
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _available_translated_languages(attrs: dict) -> list[str]:
|
| 55 |
+
raw = attrs.get("availableTranslatedLanguages")
|
| 56 |
+
if not isinstance(raw, list):
|
| 57 |
+
return []
|
| 58 |
+
return [str(x) for x in raw if isinstance(x, str)]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def get_manga_public_info(manga_id: str) -> dict:
|
| 62 |
+
"""Localized title, description, and chapter languages from GET /manga/{id}."""
|
| 63 |
+
empty = {
|
| 64 |
+
"title": None,
|
| 65 |
+
"description": None,
|
| 66 |
+
"availableTranslatedLanguages": [],
|
| 67 |
+
}
|
| 68 |
+
url = f"{BASE_URL}/manga/{manga_id}"
|
| 69 |
+
async with httpx.AsyncClient() as client:
|
| 70 |
+
try:
|
| 71 |
+
response = await client.get(url, timeout=10.0)
|
| 72 |
+
response.raise_for_status()
|
| 73 |
+
payload = response.json()
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"get_manga_public_info: {e}")
|
| 76 |
+
return empty
|
| 77 |
+
data = payload.get("data")
|
| 78 |
+
if not isinstance(data, dict):
|
| 79 |
+
return empty
|
| 80 |
+
attrs = data.get("attributes") or {}
|
| 81 |
+
if not isinstance(attrs, dict):
|
| 82 |
+
attrs = {}
|
| 83 |
+
return {
|
| 84 |
+
"title": _pick_localized_string(attrs.get("title")),
|
| 85 |
+
"description": _pick_localized_string(attrs.get("description")),
|
| 86 |
+
"availableTranslatedLanguages": _available_translated_languages(attrs),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
async def search_manga(title: str, limit: int =20, offset: int = 0, order_by: str = "followedCount", order_direction: str = "desc", cover_art: bool = True):
|
| 91 |
"""
|
| 92 |
Todo: filters by tags (include, exclude)
|