Ying Jiang commited on
Commit
6987db3
·
1 Parent(s): c3265d8

readinglist, bookmark, profile some backend + frontend changes

Browse files
Files changed (5) hide show
  1. api.py +35 -1
  2. db/db.py +56 -0
  3. db/models.py +1 -1
  4. db/schemas.py +8 -0
  5. 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 — use auth.users.id when using Supabase Auth
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)