Spaces:
Running
Running
Ying Jiang commited on
Commit ·
c3265d8
1
Parent(s): 5028126
check point 2
Browse files- README_API.md +0 -20
- alembic/versions/20260329_reading_list_item_external_ids.py +0 -152
- api.py +8 -0
- services/mangadex_service.py +34 -0
README_API.md
CHANGED
|
@@ -11,26 +11,6 @@ pip install fastapi "uvicorn[standard]"
|
|
| 11 |
|
| 12 |
Or install all deps: `pip install -r requirements.txt`
|
| 13 |
|
| 14 |
-
### Environment (reading lists / Supabase auth)
|
| 15 |
-
|
| 16 |
-
| Variable | Purpose |
|
| 17 |
-
|----------|---------|
|
| 18 |
-
| `DATABASE_URL` | PostgreSQL for app data |
|
| 19 |
-
| **Either** `SUPABASE_JWT_SECRET` **or** remote verification below | Verify the user’s `access_token` |
|
| 20 |
-
|
| 21 |
-
**If your project no longer exposes a legacy JWT secret** (dashboard says to use signing keys / publishable keys), skip `SUPABASE_JWT_SECRET` and set:
|
| 22 |
-
|
| 23 |
-
| Variable | Where to copy |
|
| 24 |
-
|----------|----------------|
|
| 25 |
-
| `SUPABASE_URL` | Same as frontend `EXPO_PUBLIC_SUPABASE_URL` (e.g. `https://xxxx.supabase.co`) |
|
| 26 |
-
| `SUPABASE_ANON_KEY` | Same as frontend `EXPO_PUBLIC_SUPABASE_ANON_KEY` (anon / publishable key) |
|
| 27 |
-
|
| 28 |
-
The API then calls `GET {SUPABASE_URL}/auth/v1/user` with the client’s Bearer token to validate it (no local JWT secret needed).
|
| 29 |
-
|
| 30 |
-
If `SUPABASE_JWT_SECRET` is set, it is used first (offline verification; faster).
|
| 31 |
-
|
| 32 |
-
See `backend/.env.example`.
|
| 33 |
-
|
| 34 |
uvicorn is ASGI server, in production will need something like to use to start
|
| 35 |
|
| 36 |
```bash
|
|
|
|
| 11 |
|
| 12 |
Or install all deps: `pip install -r requirements.txt`
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
uvicorn is ASGI server, in production will need something like to use to start
|
| 15 |
|
| 16 |
```bash
|
alembic/versions/20260329_reading_list_item_external_ids.py
DELETED
|
@@ -1,152 +0,0 @@
|
|
| 1 |
-
"""reading_list_item: store provider + external manga id, drop FK to public.manga
|
| 2 |
-
|
| 3 |
-
Revision ID: b7e2a1c0d9f8
|
| 4 |
-
Revises: 34d55d0d8469
|
| 5 |
-
Create Date: 2026-03-29
|
| 6 |
-
|
| 7 |
-
"""
|
| 8 |
-
from alembic import op
|
| 9 |
-
import sqlalchemy as sa
|
| 10 |
-
from sqlalchemy import text
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
revision = "b7e2a1c0d9f8"
|
| 14 |
-
down_revision = "34d55d0d8469"
|
| 15 |
-
branch_labels = None
|
| 16 |
-
depends_on = None
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def upgrade() -> None:
|
| 20 |
-
op.add_column(
|
| 21 |
-
"reading_list_item",
|
| 22 |
-
sa.Column("provider_id", sa.String(), nullable=True),
|
| 23 |
-
)
|
| 24 |
-
op.add_column(
|
| 25 |
-
"reading_list_item",
|
| 26 |
-
sa.Column("external_manga_id", sa.String(), nullable=True),
|
| 27 |
-
)
|
| 28 |
-
op.add_column(
|
| 29 |
-
"reading_list_item",
|
| 30 |
-
sa.Column("manga_title", sa.String(length=500), nullable=True),
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
conn = op.get_bind()
|
| 34 |
-
conn.execute(
|
| 35 |
-
text(
|
| 36 |
-
"""
|
| 37 |
-
UPDATE reading_list_item AS rli
|
| 38 |
-
SET
|
| 39 |
-
provider_id = ms.provider_id,
|
| 40 |
-
external_manga_id = ms.external_manga_id,
|
| 41 |
-
manga_title = m.manga_title
|
| 42 |
-
FROM manga AS m
|
| 43 |
-
INNER JOIN manga_source AS ms
|
| 44 |
-
ON ms.manga_id = m.id
|
| 45 |
-
AND ms.provider_id = 'mangadex'
|
| 46 |
-
WHERE rli.manga_id = m.id
|
| 47 |
-
"""
|
| 48 |
-
)
|
| 49 |
-
)
|
| 50 |
-
conn.execute(text("DELETE FROM reading_list_item WHERE external_manga_id IS NULL"))
|
| 51 |
-
|
| 52 |
-
op.drop_constraint(
|
| 53 |
-
"reading_list_item_manga_id_fkey",
|
| 54 |
-
"reading_list_item",
|
| 55 |
-
type_="foreignkey",
|
| 56 |
-
)
|
| 57 |
-
op.drop_constraint("uq_rli_list_manga", "reading_list_item", type_="unique")
|
| 58 |
-
op.drop_index(op.f("ix_reading_list_item_manga_id"), table_name="reading_list_item")
|
| 59 |
-
op.drop_column("reading_list_item", "manga_id")
|
| 60 |
-
|
| 61 |
-
op.alter_column(
|
| 62 |
-
"reading_list_item",
|
| 63 |
-
"provider_id",
|
| 64 |
-
existing_type=sa.String(),
|
| 65 |
-
nullable=False,
|
| 66 |
-
)
|
| 67 |
-
op.alter_column(
|
| 68 |
-
"reading_list_item",
|
| 69 |
-
"external_manga_id",
|
| 70 |
-
existing_type=sa.String(),
|
| 71 |
-
nullable=False,
|
| 72 |
-
)
|
| 73 |
-
op.alter_column(
|
| 74 |
-
"reading_list_item",
|
| 75 |
-
"manga_title",
|
| 76 |
-
existing_type=sa.String(length=500),
|
| 77 |
-
nullable=False,
|
| 78 |
-
)
|
| 79 |
-
|
| 80 |
-
op.create_index(
|
| 81 |
-
op.f("ix_reading_list_item_external_manga_id"),
|
| 82 |
-
"reading_list_item",
|
| 83 |
-
["external_manga_id"],
|
| 84 |
-
unique=False,
|
| 85 |
-
)
|
| 86 |
-
op.create_index(
|
| 87 |
-
op.f("ix_reading_list_item_provider_id"),
|
| 88 |
-
"reading_list_item",
|
| 89 |
-
["provider_id"],
|
| 90 |
-
unique=False,
|
| 91 |
-
)
|
| 92 |
-
op.create_unique_constraint(
|
| 93 |
-
"uq_rli_list_provider_external",
|
| 94 |
-
"reading_list_item",
|
| 95 |
-
["reading_list_id", "provider_id", "external_manga_id"],
|
| 96 |
-
)
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def downgrade() -> None:
|
| 100 |
-
op.drop_constraint(
|
| 101 |
-
"uq_rli_list_provider_external", "reading_list_item", type_="unique"
|
| 102 |
-
)
|
| 103 |
-
op.drop_index(
|
| 104 |
-
op.f("ix_reading_list_item_provider_id"), table_name="reading_list_item"
|
| 105 |
-
)
|
| 106 |
-
op.drop_index(
|
| 107 |
-
op.f("ix_reading_list_item_external_manga_id"),
|
| 108 |
-
table_name="reading_list_item",
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
op.add_column(
|
| 112 |
-
"reading_list_item",
|
| 113 |
-
sa.Column("manga_id", sa.Integer(), nullable=True),
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
conn = op.get_bind()
|
| 117 |
-
conn.execute(
|
| 118 |
-
text(
|
| 119 |
-
"""
|
| 120 |
-
UPDATE reading_list_item AS rli
|
| 121 |
-
SET manga_id = ms.manga_id
|
| 122 |
-
FROM manga_source AS ms
|
| 123 |
-
WHERE rli.provider_id = ms.provider_id
|
| 124 |
-
AND rli.external_manga_id = ms.external_manga_id
|
| 125 |
-
"""
|
| 126 |
-
)
|
| 127 |
-
)
|
| 128 |
-
conn.execute(text("DELETE FROM reading_list_item WHERE manga_id IS NULL"))
|
| 129 |
-
|
| 130 |
-
op.alter_column(
|
| 131 |
-
"reading_list_item", "manga_id", existing_type=sa.Integer(), nullable=False
|
| 132 |
-
)
|
| 133 |
-
op.create_foreign_key(
|
| 134 |
-
"reading_list_item_manga_id_fkey",
|
| 135 |
-
"reading_list_item",
|
| 136 |
-
"manga",
|
| 137 |
-
["manga_id"],
|
| 138 |
-
["id"],
|
| 139 |
-
)
|
| 140 |
-
op.create_index(
|
| 141 |
-
op.f("ix_reading_list_item_manga_id"),
|
| 142 |
-
"reading_list_item",
|
| 143 |
-
["manga_id"],
|
| 144 |
-
unique=False,
|
| 145 |
-
)
|
| 146 |
-
op.create_unique_constraint(
|
| 147 |
-
"uq_rli_list_manga", "reading_list_item", ["reading_list_id", "manga_id"]
|
| 148 |
-
)
|
| 149 |
-
|
| 150 |
-
op.drop_column("reading_list_item", "manga_title")
|
| 151 |
-
op.drop_column("reading_list_item", "external_manga_id")
|
| 152 |
-
op.drop_column("reading_list_item", "provider_id")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api.py
CHANGED
|
@@ -258,6 +258,14 @@ async def proxy_manga_cover_art(manga_id: str, file_name: str, size: int = 256):
|
|
| 258 |
print(url)
|
| 259 |
return await proxy.get_manga_page_stream(url)
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
@router.get("/api/manga/search")
|
| 262 |
async def get_popular_manga(
|
| 263 |
title: str = "",
|
|
|
|
| 258 |
print(url)
|
| 259 |
return await proxy.get_manga_page_stream(url)
|
| 260 |
|
| 261 |
+
|
| 262 |
+
@router.get("/api/manga/{manga_id}/cover")
|
| 263 |
+
async def get_manga_cover_json(manga_id: str):
|
| 264 |
+
"""MangaDex manga UUID → 256px cover URL for list UIs (no DB storage)."""
|
| 265 |
+
cover_url = await mangadex_service.get_manga_cover_url_256(manga_id)
|
| 266 |
+
return {"cover_url": cover_url}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
@router.get("/api/manga/search")
|
| 270 |
async def get_popular_manga(
|
| 271 |
title: str = "",
|
services/mangadex_service.py
CHANGED
|
@@ -5,6 +5,40 @@ import httpx
|
|
| 5 |
|
| 6 |
BASE_URL = "https://api.mangadex.org"
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
async def search_manga(title: str, limit: int =20, offset: int = 0, order_by: str = "followedCount", order_direction: str = "desc", cover_art: bool = True):
|
| 9 |
"""
|
| 10 |
Todo: filters by tags (include, exclude)
|
|
|
|
| 5 |
|
| 6 |
BASE_URL = "https://api.mangadex.org"
|
| 7 |
|
| 8 |
+
|
| 9 |
+
def _cover_url_from_manga_payload(data: dict, manga_id: str) -> str | None:
|
| 10 |
+
"""Build 256px cover URL from MangaDex /manga/{id} JSON (needs cover_art relationship)."""
|
| 11 |
+
rels = data.get("relationships") or []
|
| 12 |
+
for rel in rels:
|
| 13 |
+
if rel.get("type") != "cover_art":
|
| 14 |
+
continue
|
| 15 |
+
attrs = rel.get("attributes") or {}
|
| 16 |
+
fn = attrs.get("fileName")
|
| 17 |
+
if isinstance(fn, str) and fn.strip():
|
| 18 |
+
return f"https://uploads.mangadex.org/covers/{manga_id}/{fn}.256.jpg"
|
| 19 |
+
return None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def get_manga_cover_url_256(manga_id: str) -> str | None:
|
| 23 |
+
"""GET /manga/{id} with cover_art included; returns CDN URL or None."""
|
| 24 |
+
url = f"{BASE_URL}/manga/{manga_id}"
|
| 25 |
+
params = {"includes[]": ["cover_art"]}
|
| 26 |
+
async with httpx.AsyncClient() as client:
|
| 27 |
+
try:
|
| 28 |
+
response = await client.get(url, params=params, timeout=10.0)
|
| 29 |
+
response.raise_for_status()
|
| 30 |
+
payload = response.json()
|
| 31 |
+
except httpx.HTTPStatusError:
|
| 32 |
+
return None
|
| 33 |
+
except Exception as e:
|
| 34 |
+
print(f"get_manga_cover_url_256: {e}")
|
| 35 |
+
return None
|
| 36 |
+
data = payload.get("data")
|
| 37 |
+
if not isinstance(data, dict):
|
| 38 |
+
return 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)
|