Ying Jiang commited on
Commit
ee75ec9
·
1 Parent(s): 8ddf42d

good luck

Browse files
README_API.md CHANGED
@@ -34,8 +34,8 @@ uvicorn api:app --reload --host 0.0.0.0 --port 8000
34
  | Method | Path | Description |
35
  | ------ | -------------------- | -------------------------------------------------------------------------------------------------- |
36
  | GET | `/entries` | List all chapters. Query: `order_by`, `order_desc` |
37
- | GET | `/segments` | Get segments. Query: `provider_id`, `manga_title`, `chapter_number`, `page_number` (all optional) |
38
- | GET | `/chapters/segments` | Get all segments for one chapter. Query: `provider_id`, `manga_title`, `chapter_number` (required) |
39
  | GET | `/health` | Health check |
40
 
41
  ## Example requests (frontend or curl)
@@ -48,10 +48,10 @@ curl "http://localhost:8000/entries"
48
  curl "http://localhost:8000/entries?order_by=manga_title&order_desc=false"
49
 
50
  # Get all segments for a chapter
51
- curl "http://localhost:8000/chapters/segments?provider_id=local&manga_title=One%20Piece&chapter_number=1"
52
 
53
  # Get segments for one page
54
- curl "http://localhost:8000/segments?provider_id=local&manga_title=One%20Piece&chapter_number=1&page_number=1"
55
  ```
56
 
57
  ## CORS
 
34
  | Method | Path | Description |
35
  | ------ | -------------------- | -------------------------------------------------------------------------------------------------- |
36
  | GET | `/entries` | List all chapters. Query: `order_by`, `order_desc` |
37
+ | GET | `/panels` | Get panels. Query: `manga_title`, `chapter_number`, `page_number` (all optional) |
38
+ | GET | `/chapters/panels` | Get all panels for one chapter. Query: `manga_title`, `chapter_number` (required) |
39
  | GET | `/health` | Health check |
40
 
41
  ## Example requests (frontend or curl)
 
48
  curl "http://localhost:8000/entries?order_by=manga_title&order_desc=false"
49
 
50
  # Get all segments for a chapter
51
+ curl "http://localhost:8000/chapters/panels?manga_title=One%20Piece&chapter_number=1"
52
 
53
  # Get segments for one page
54
+ curl "http://localhost:8000/panels?manga_title=One%20Piece&chapter_number=1&page_number=1"
55
  ```
56
 
57
  ## CORS
alembic/env.py CHANGED
@@ -21,10 +21,8 @@ except ImportError:
21
  # Import all models so they're registered with SQLModel.metadata
22
  from db.models import (
23
  Manga,
24
- MangaSource,
25
  Chapters,
26
- Pages,
27
- Segments,
28
  Users,
29
  ReadingListCollection,
30
  ReadingListItem,
 
21
  # Import all models so they're registered with SQLModel.metadata
22
  from db.models import (
23
  Manga,
 
24
  Chapters,
25
+ Panels,
 
26
  Users,
27
  ReadingListCollection,
28
  ReadingListItem,
alembic/versions/20260429_000005_squash_panels_mangadex_remove_manga_source.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """squash: panels refactor + mangadex ids + remove manga_source
2
+
3
+ Revision ID: 20260429_000005
4
+ Revises: 34d55d0d8469
5
+ Create Date: 2026-04-29
6
+
7
+ This squashes the following revisions into one:
8
+ - 20260429_000001_segments_match_translated_segment_shape.py
9
+ - 20260429_000002_panels_drop_pages.py
10
+ - 20260429_000003_add_mangadex_chapter_id_to_chapters_panels.py
11
+ - 20260429_000004_remove_manga_source.py
12
+
13
+ Target end-state:
14
+ - pages/segments removed; panels table used instead
15
+ - chapters and panels include mangadex_chapter_id
16
+ - manga includes mangadex_manga_id; manga_source removed
17
+ """
18
+
19
+ from alembic import op
20
+ import sqlalchemy as sa
21
+ from sqlalchemy import text
22
+
23
+
24
+ revision = "20260429_000005"
25
+ down_revision = "34d55d0d8469"
26
+ branch_labels = None
27
+ depends_on = None
28
+
29
+
30
+ def upgrade() -> None:
31
+ conn = op.get_bind()
32
+
33
+ # --- 1) segments: rename segment_index -> bubble_index; add width/height ---
34
+ # Handle both "segment_index" (older) and already-renamed "bubble_index" (idempotent).
35
+ conn.execute(
36
+ text(
37
+ """
38
+ DO $$
39
+ BEGIN
40
+ IF EXISTS (
41
+ SELECT 1 FROM information_schema.columns
42
+ WHERE table_name='segments' AND column_name='segment_index'
43
+ ) AND NOT EXISTS (
44
+ SELECT 1 FROM information_schema.columns
45
+ WHERE table_name='segments' AND column_name='bubble_index'
46
+ ) THEN
47
+ ALTER TABLE segments RENAME COLUMN segment_index TO bubble_index;
48
+ END IF;
49
+ END
50
+ $$;
51
+ """
52
+ )
53
+ )
54
+
55
+ conn.execute(
56
+ text(
57
+ """
58
+ DO $$
59
+ BEGIN
60
+ IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='segments') THEN
61
+ IF NOT EXISTS (
62
+ SELECT 1 FROM information_schema.columns
63
+ WHERE table_name='segments' AND column_name='width'
64
+ ) THEN
65
+ ALTER TABLE segments ADD COLUMN width INTEGER NULL;
66
+ END IF;
67
+ IF NOT EXISTS (
68
+ SELECT 1 FROM information_schema.columns
69
+ WHERE table_name='segments' AND column_name='height'
70
+ ) THEN
71
+ ALTER TABLE segments ADD COLUMN height INTEGER NULL;
72
+ END IF;
73
+ END IF;
74
+ END
75
+ $$;
76
+ """
77
+ )
78
+ )
79
+
80
+ # Ensure composite index matches the renamed column (best-effort).
81
+ conn.execute(text("DROP INDEX IF EXISTS ix_segments_page_segment"))
82
+ conn.execute(text("DROP INDEX IF EXISTS ix_segments_page_bubble"))
83
+ conn.execute(
84
+ text(
85
+ """
86
+ DO $$
87
+ BEGIN
88
+ IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='segments') THEN
89
+ CREATE INDEX IF NOT EXISTS ix_segments_page_bubble ON segments (page_id, bubble_index);
90
+ END IF;
91
+ END
92
+ $$;
93
+ """
94
+ )
95
+ )
96
+
97
+ # --- 2) create panels and migrate data from segments+pages ---
98
+ op.create_table(
99
+ "panels",
100
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
101
+ sa.Column("chapter_id", sa.Integer(), nullable=False),
102
+ sa.Column("page_number", sa.Integer(), nullable=False),
103
+ sa.Column("mangadex_chapter_id", sa.String(), nullable=True),
104
+ sa.Column("bubble_index", sa.Integer(), nullable=False),
105
+ sa.Column("width", sa.Integer(), nullable=True),
106
+ sa.Column("height", sa.Integer(), nullable=True),
107
+ sa.Column("x1", sa.Float(), nullable=False),
108
+ sa.Column("y1", sa.Float(), nullable=False),
109
+ sa.Column("x2", sa.Float(), nullable=False),
110
+ sa.Column("y2", sa.Float(), nullable=False),
111
+ sa.Column("original_text", sa.String(), nullable=False),
112
+ sa.Column("translated_text", sa.String(), nullable=False),
113
+ sa.Column("created_at", sa.DateTime(), nullable=True),
114
+ sa.ForeignKeyConstraint(["chapter_id"], ["chapters.id"]),
115
+ sa.PrimaryKeyConstraint("id"),
116
+ )
117
+ op.create_index("ix_panels_chapter_id", "panels", ["chapter_id"], unique=False)
118
+ op.create_index("ix_panels_page_number", "panels", ["page_number"], unique=False)
119
+ op.create_index(
120
+ "ix_panels_chapter_page_bubble",
121
+ "panels",
122
+ ["chapter_id", "page_number", "bubble_index"],
123
+ unique=False,
124
+ )
125
+ op.create_index(
126
+ "ix_panels_mangadex_chapter_id",
127
+ "panels",
128
+ ["mangadex_chapter_id"],
129
+ unique=False,
130
+ )
131
+
132
+ # Copy all existing segment rows into panels (if the old tables exist).
133
+ conn.execute(
134
+ text(
135
+ """
136
+ DO $$
137
+ BEGIN
138
+ IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='segments')
139
+ AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='pages')
140
+ THEN
141
+ INSERT INTO panels
142
+ (chapter_id, page_number, bubble_index, width, height,
143
+ x1, y1, x2, y2, original_text, translated_text, created_at)
144
+ SELECT
145
+ p.chapter_id,
146
+ p.page_number,
147
+ s.bubble_index,
148
+ s.width,
149
+ s.height,
150
+ s.x1, s.y1, s.x2, s.y2,
151
+ s.original_text,
152
+ s.translated_text,
153
+ s.created_at
154
+ FROM segments s
155
+ INNER JOIN pages p ON p.id = s.page_id;
156
+ END IF;
157
+ END
158
+ $$;
159
+ """
160
+ )
161
+ )
162
+
163
+ # --- 3) add mangadex_chapter_id to chapters (nullable) ---
164
+ conn.execute(
165
+ text(
166
+ """
167
+ DO $$
168
+ BEGIN
169
+ IF NOT EXISTS (
170
+ SELECT 1 FROM information_schema.columns
171
+ WHERE table_name='chapters' AND column_name='mangadex_chapter_id'
172
+ ) THEN
173
+ ALTER TABLE chapters ADD COLUMN mangadex_chapter_id VARCHAR NULL;
174
+ END IF;
175
+ END
176
+ $$;
177
+ """
178
+ )
179
+ )
180
+ conn.execute(text("CREATE INDEX IF NOT EXISTS ix_chapters_mangadex_chapter_id ON chapters (mangadex_chapter_id)"))
181
+
182
+ # Copy chapter mangadex id into panels (best-effort backfill).
183
+ conn.execute(
184
+ text(
185
+ """
186
+ UPDATE panels p
187
+ SET mangadex_chapter_id = c.mangadex_chapter_id
188
+ FROM chapters c
189
+ WHERE p.chapter_id = c.id
190
+ AND p.mangadex_chapter_id IS NULL
191
+ AND c.mangadex_chapter_id IS NOT NULL;
192
+ """
193
+ )
194
+ )
195
+
196
+ # --- 4) manga: add mangadex_manga_id and backfill from manga_source, then drop manga_source ---
197
+ conn.execute(
198
+ text(
199
+ """
200
+ DO $$
201
+ BEGIN
202
+ IF NOT EXISTS (
203
+ SELECT 1 FROM information_schema.columns
204
+ WHERE table_name='manga' AND column_name='mangadex_manga_id'
205
+ ) THEN
206
+ ALTER TABLE manga ADD COLUMN mangadex_manga_id VARCHAR NULL;
207
+ END IF;
208
+ END
209
+ $$;
210
+ """
211
+ )
212
+ )
213
+ conn.execute(text("CREATE INDEX IF NOT EXISTS ix_manga_mangadex_manga_id ON manga (mangadex_manga_id)"))
214
+ conn.execute(
215
+ text(
216
+ """
217
+ DO $$
218
+ BEGIN
219
+ IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='manga_source') THEN
220
+ UPDATE manga m
221
+ SET mangadex_manga_id = ms.external_manga_id
222
+ FROM (
223
+ SELECT manga_id, MIN(external_manga_id) AS external_manga_id
224
+ FROM manga_source
225
+ WHERE provider_id = 'mangadex'
226
+ AND external_manga_id IS NOT NULL
227
+ AND external_manga_id NOT LIKE 'legacy-%'
228
+ AND external_manga_id NOT LIKE 'local-%'
229
+ GROUP BY manga_id
230
+ ) ms
231
+ WHERE m.id = ms.manga_id
232
+ AND (m.mangadex_manga_id IS NULL OR m.mangadex_manga_id = '');
233
+ END IF;
234
+ END
235
+ $$;
236
+ """
237
+ )
238
+ )
239
+ conn.execute(
240
+ text(
241
+ "CREATE UNIQUE INDEX IF NOT EXISTS uq_manga_mangadex_manga_id ON manga (mangadex_manga_id)"
242
+ )
243
+ )
244
+ conn.execute(text("DROP TABLE IF EXISTS manga_source CASCADE"))
245
+
246
+ # --- 5) drop old pages/segments tables (if present) ---
247
+ conn.execute(text("DROP TABLE IF EXISTS segments CASCADE"))
248
+ conn.execute(text("DROP TABLE IF EXISTS pages CASCADE"))
249
+
250
+
251
+ def downgrade() -> None:
252
+ raise NotImplementedError("Squashed migration is not designed to downgrade.")
253
+
alembic/versions/20260429_000006_single_provider_drop_provider_id_nullable_page_number.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """single provider: drop chapters.provider_id; panels.page_number nullable
2
+
3
+ Revision ID: 20260429_000006
4
+ Revises: 20260429_000005
5
+ Create Date: 2026-04-29
6
+ """
7
+
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ from sqlalchemy import text
11
+
12
+
13
+ revision = "20260429_000006"
14
+ down_revision = "20260429_000005"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ conn = op.get_bind()
21
+
22
+ # --- chapters: drop provider_id + replace unique constraint ---
23
+ # Drop old constraint/index if present.
24
+ conn.execute(text("ALTER TABLE chapters DROP CONSTRAINT IF EXISTS uq_chapters_manga_provider_chapter"))
25
+ conn.execute(text("DROP INDEX IF EXISTS ix_chapters_provider_id"))
26
+
27
+ # Drop the column if it exists.
28
+ conn.execute(
29
+ text(
30
+ """
31
+ DO $$
32
+ BEGIN
33
+ IF EXISTS (
34
+ SELECT 1 FROM information_schema.columns
35
+ WHERE table_name='chapters' AND column_name='provider_id'
36
+ ) THEN
37
+ ALTER TABLE chapters DROP COLUMN provider_id;
38
+ END IF;
39
+ END
40
+ $$;
41
+ """
42
+ )
43
+ )
44
+
45
+ # Ensure new uniqueness.
46
+ conn.execute(text("ALTER TABLE chapters DROP CONSTRAINT IF EXISTS uq_chapters_manga_chapter"))
47
+ conn.execute(text("ALTER TABLE chapters ADD CONSTRAINT uq_chapters_manga_chapter UNIQUE (manga_id, chapter_number)"))
48
+
49
+ # --- panels: allow page_number NULL ---
50
+ op.alter_column(
51
+ "panels",
52
+ "page_number",
53
+ existing_type=sa.Integer(),
54
+ nullable=True,
55
+ )
56
+
57
+
58
+ def downgrade() -> None:
59
+ raise NotImplementedError("Not supporting downgrade for this schema simplification.")
60
+
alembic/versions/20260429_000007_panels_add_panel_url.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """panels: add panel_url
2
+
3
+ Revision ID: 20260429_000007
4
+ Revises: 20260429_000006
5
+ Create Date: 2026-04-29
6
+ """
7
+
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+
11
+
12
+ revision = "20260429_000007"
13
+ down_revision = "20260429_000006"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ op.add_column("panels", sa.Column("panel_url", sa.String(), nullable=True))
20
+
21
+
22
+ def downgrade() -> None:
23
+ op.drop_column("panels", "panel_url")
24
+
alembic/versions/20260429_000008_panels_panel_url_unique_partial.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """panels: global unique panel_url (when set)
2
+
3
+ Revision ID: 20260429_000008
4
+ Revises: 20260429_000007
5
+ Create Date: 2026-04-29
6
+ """
7
+
8
+ from alembic import op
9
+
10
+
11
+ revision = "20260429_000008"
12
+ down_revision = "20260429_000007"
13
+ branch_labels = None
14
+ depends_on = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ # Postgres: enforce uniqueness only when panel_url is present (still allows many NULLs).
19
+ op.execute(
20
+ """
21
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_panels_panel_url_not_null
22
+ ON panels (panel_url)
23
+ WHERE panel_url IS NOT NULL;
24
+ """
25
+ )
26
+
27
+
28
+ def downgrade() -> None:
29
+ op.execute("DROP INDEX IF EXISTS uq_panels_panel_url_not_null;")
alembic/versions/20260429_000009_dedupe_manga_mangadex.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data migration: merge duplicate manga rows sharing the same MangaDex series id.
2
+
3
+ Revision ID: 20260429_000009
4
+ Revises: 20260429_000008
5
+ Create Date: 2026-04-29
6
+
7
+ When the same series was added to several reading lists in parallel, multiple
8
+ ``manga`` rows could be created with the same ``mangadex_manga_id`` (possibly
9
+ differing only by case). This migration:
10
+
11
+ - Keeps the smallest ``manga.id`` per ``lower(trim(mangadex_manga_id))`` group
12
+ - Points ``reading_list_item`` and ``chapters`` at that canonical row
13
+ - Merges overlapping chapters (same ``chapter_number``) by moving panels and
14
+ dropping duplicate chapters; deletes source panels that would duplicate a
15
+ non-null ``panel_url`` already present on the target chapter
16
+ - Removes extra ``reading_list_item`` rows that share the same
17
+ ``(reading_list_id, manga_id)`` after the merge (keeps best progress)
18
+
19
+ Requires PostgreSQL. Irreversible: duplicate rows are deleted, not restored.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from alembic import op
25
+ from sqlalchemy import text
26
+
27
+
28
+ revision = "20260429_000009"
29
+ down_revision = "20260429_000008"
30
+ branch_labels = None
31
+ depends_on = None
32
+
33
+
34
+ def _merge_one_duplicate_manga(conn, canonical_id: int, dup_id: int) -> None:
35
+ """Remap FKs from dup_id onto canonical_id, then delete dup_id manga row."""
36
+ conn.execute(
37
+ text(
38
+ "UPDATE reading_list_item SET manga_id = :canon WHERE manga_id = :dup",
39
+ ),
40
+ {"canon": canonical_id, "dup": dup_id},
41
+ )
42
+
43
+ chapters = conn.execute(
44
+ text(
45
+ "SELECT id, chapter_number FROM chapters WHERE manga_id = :d ORDER BY id",
46
+ ),
47
+ {"d": dup_id},
48
+ ).fetchall()
49
+
50
+ for ch_id, ch_num in chapters:
51
+ row = conn.execute(
52
+ text(
53
+ "SELECT id FROM chapters WHERE manga_id = :c AND chapter_number = :n "
54
+ "LIMIT 1",
55
+ ),
56
+ {"c": canonical_id, "n": ch_num},
57
+ ).fetchone()
58
+
59
+ if row is not None:
60
+ canon_ch_id = row[0]
61
+ # Drop source panels whose panel_url already exists on the target chapter
62
+ # (partial unique index uq_panels_panel_url_not_null).
63
+ conn.execute(
64
+ text(
65
+ """
66
+ DELETE FROM panels p
67
+ WHERE p.chapter_id = :old_ch
68
+ AND p.panel_url IS NOT NULL
69
+ AND EXISTS (
70
+ SELECT 1 FROM panels p2
71
+ WHERE p2.chapter_id = :new_ch
72
+ AND p2.panel_url IS NOT NULL
73
+ AND p2.panel_url = p.panel_url
74
+ )
75
+ """,
76
+ ),
77
+ {"old_ch": ch_id, "new_ch": canon_ch_id},
78
+ )
79
+ conn.execute(
80
+ text(
81
+ "UPDATE panels SET chapter_id = :cc WHERE chapter_id = :oc",
82
+ ),
83
+ {"cc": canon_ch_id, "oc": ch_id},
84
+ )
85
+ conn.execute(text("DELETE FROM chapters WHERE id = :oc"), {"oc": ch_id})
86
+ else:
87
+ conn.execute(
88
+ text("UPDATE chapters SET manga_id = :c WHERE id = :cid"),
89
+ {"c": canonical_id, "cid": ch_id},
90
+ )
91
+
92
+ conn.execute(text("DELETE FROM manga WHERE id = :d"), {"d": dup_id})
93
+
94
+
95
+ def upgrade() -> None:
96
+ conn = op.get_bind()
97
+
98
+ groups = conn.execute(
99
+ text(
100
+ """
101
+ SELECT array_agg(id ORDER BY id) AS ids
102
+ FROM manga
103
+ WHERE mangadex_manga_id IS NOT NULL
104
+ AND btrim(mangadex_manga_id) <> ''
105
+ GROUP BY lower(btrim(mangadex_manga_id))
106
+ HAVING COUNT(*) > 1
107
+ """,
108
+ ),
109
+ ).fetchall()
110
+
111
+ for (ids_arr,) in groups:
112
+ if not ids_arr:
113
+ continue
114
+ ids = [int(x) for x in ids_arr]
115
+ canonical_id = min(ids)
116
+ dup_ids = [i for i in ids if i != canonical_id]
117
+ for dup_id in dup_ids:
118
+ _merge_one_duplicate_manga(conn, canonical_id, dup_id)
119
+
120
+ # Same list may now reference the same manga twice — keep one row (best progress).
121
+ conn.execute(
122
+ text(
123
+ """
124
+ DELETE FROM reading_list_item
125
+ WHERE id IN (
126
+ SELECT id FROM (
127
+ SELECT id,
128
+ ROW_NUMBER() OVER (
129
+ PARTITION BY reading_list_id, manga_id
130
+ ORDER BY COALESCE(last_chapter_number, -1) DESC NULLS LAST,
131
+ updated_at DESC NULLS LAST,
132
+ id ASC
133
+ ) AS rn
134
+ FROM reading_list_item
135
+ ) sub
136
+ WHERE rn > 1
137
+ )
138
+ """,
139
+ ),
140
+ )
141
+
142
+
143
+ def downgrade() -> None:
144
+ raise NotImplementedError(
145
+ "Dedupe migration is irreversible (merged rows are deleted).",
146
+ )
api.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Read-only API for the frontend. Wraps db list_entries, get_segments, get_chapter_segments.
3
  Run from backend: uvicorn api:app --reload --host 0.0.0.0 --port 8000
4
  """
5
 
@@ -22,7 +22,8 @@ from db.schemas import (
22
  ReadingListCollectionOut,
23
  ReadingListCollectionRenameIn,
24
  ReadingListItemOut,
25
- SegmentListOut,
 
26
  UserDisplayNamePatchIn,
27
  )
28
 
@@ -78,26 +79,23 @@ def list_mangas(
78
  @router.get("/chapters", response_model=list[ChapterListOut])
79
  def list_chapters(
80
  manga_title: str = Query(...),
81
- provider_id: str | None = Query(None, description="e.g. local, mangadex"),
82
  limit: int | None = Query(None, ge=1, le=500, description="Max results (default: all)"),
83
  offset: int = Query(0, ge=0, description="Skip N results"),
84
  ):
85
- """List chapters (id, chapter_number, created_at, updated_at). Filters by manga_title; optionally by provider_id."""
86
- return db.list_chapters(manga_title, provider_id, limit=limit, offset=offset)
87
 
88
 
89
- @router.get("/segments", response_model=list[SegmentListOut])
90
- def get_segments(
91
- provider_id: str | None = Query(None, description="e.g. local, mangadex"),
92
  manga_title: str | None = Query(None),
93
  chapter_number: float | None = Query(None),
94
  page_number: int | None = Query(None),
95
  limit: int | None = Query(None, ge=1, le=1000, description="Max results (default: all)"),
96
  offset: int = Query(0, ge=0, description="Skip N results"),
97
  ):
98
- """Get segments with optional filters. Supports pagination."""
99
- return db.get_segments(
100
- provider_id=provider_id,
101
  manga_title=manga_title,
102
  chapter_number=chapter_number,
103
  page_number=page_number,
@@ -106,16 +104,17 @@ def get_segments(
106
  )
107
 
108
 
109
- @router.get("/chapters/segments", response_model=list[SegmentListOut])
110
- def get_chapter_segments(
111
- provider_id: str = Query(..., description="e.g. local, mangadex"),
112
  manga_title: str = Query(...),
113
  chapter_number: float = Query(...),
114
  limit: int | None = Query(None, ge=1, le=1000, description="Max results (default: all)"),
115
  offset: int = Query(0, ge=0, description="Skip N results"),
116
  ):
117
- """Get all segments for one chapter. Supports pagination."""
118
- return db.get_chapter_segments(provider_id, manga_title, chapter_number, limit=limit, offset=offset)
 
 
119
 
120
 
121
  # to make sure api is running and responding
@@ -214,14 +213,13 @@ def get_reading_list_items(
214
  def post_reading_list_item(
215
  user_id: CurrentUserId, reading_list_id: int, body: ReadingListAddIn
216
  ):
217
- """Add or update a manga on a named list (by provider catalog id)."""
218
  if db.get_reading_list_collection(user_id, reading_list_id) is None:
219
  raise HTTPException(status_code=404, detail="Reading list not found")
220
  try:
221
  row = db.add_reading_list_item_by_source(
222
  user_id,
223
  reading_list_id,
224
- body.provider_id,
225
  body.external_manga_id,
226
  body.manga_title,
227
  last_chapter_number=body.last_chapter_number,
@@ -246,6 +244,32 @@ def delete_reading_list_item(
246
  return {"ok": True}
247
 
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  @router.patch("/users/me")
250
  def patch_me_display_name(ctx: CurrentAuthContext, body: UserDisplayNamePatchIn):
251
  """Update display_name on public.users for the signed-in user."""
 
1
  """
2
+ Read-only API for the frontend. Wraps db list_entries, get_panels, get_chapter_panels.
3
  Run from backend: uvicorn api:app --reload --host 0.0.0.0 --port 8000
4
  """
5
 
 
22
  ReadingListCollectionOut,
23
  ReadingListCollectionRenameIn,
24
  ReadingListItemOut,
25
+ ReadingListProgressPatchIn,
26
+ PanelListOut,
27
  UserDisplayNamePatchIn,
28
  )
29
 
 
79
  @router.get("/chapters", response_model=list[ChapterListOut])
80
  def list_chapters(
81
  manga_title: str = Query(...),
 
82
  limit: int | None = Query(None, ge=1, le=500, description="Max results (default: all)"),
83
  offset: int = Query(0, ge=0, description="Skip N results"),
84
  ):
85
+ """List chapters (id, chapter_number, created_at, updated_at). Filters by manga_title."""
86
+ return db.list_chapters(manga_title, limit=limit, offset=offset)
87
 
88
 
89
+ @router.get("/panels", response_model=list[PanelListOut])
90
+ def get_panels(
 
91
  manga_title: str | None = Query(None),
92
  chapter_number: float | None = Query(None),
93
  page_number: int | None = Query(None),
94
  limit: int | None = Query(None, ge=1, le=1000, description="Max results (default: all)"),
95
  offset: int = Query(0, ge=0, description="Skip N results"),
96
  ):
97
+ """Get panels with optional filters. Supports pagination."""
98
+ return db.get_panels(
 
99
  manga_title=manga_title,
100
  chapter_number=chapter_number,
101
  page_number=page_number,
 
104
  )
105
 
106
 
107
+ @router.get("/chapters/panels", response_model=list[PanelListOut])
108
+ def get_chapter_panels(
 
109
  manga_title: str = Query(...),
110
  chapter_number: float = Query(...),
111
  limit: int | None = Query(None, ge=1, le=1000, description="Max results (default: all)"),
112
  offset: int = Query(0, ge=0, description="Skip N results"),
113
  ):
114
+ """Get all panels for one chapter. Supports pagination."""
115
+ return db.get_chapter_panels(manga_title, chapter_number, limit=limit, offset=offset)
116
+
117
+
118
 
119
 
120
  # to make sure api is running and responding
 
213
  def post_reading_list_item(
214
  user_id: CurrentUserId, reading_list_id: int, body: ReadingListAddIn
215
  ):
216
+ """Add or update a manga on a named list (by MangaDex series id)."""
217
  if db.get_reading_list_collection(user_id, reading_list_id) is None:
218
  raise HTTPException(status_code=404, detail="Reading list not found")
219
  try:
220
  row = db.add_reading_list_item_by_source(
221
  user_id,
222
  reading_list_id,
 
223
  body.external_manga_id,
224
  body.manga_title,
225
  last_chapter_number=body.last_chapter_number,
 
244
  return {"ok": True}
245
 
246
 
247
+ @router.patch(
248
+ "/reading-lists/{reading_list_id}/items/{manga_id}",
249
+ response_model=ReadingListItemOut,
250
+ )
251
+ def patch_reading_list_item_progress(
252
+ user_id: CurrentUserId,
253
+ reading_list_id: int,
254
+ manga_id: int,
255
+ body: ReadingListProgressPatchIn,
256
+ ):
257
+ """Update last-read chapter for one manga row (stored value only increases)."""
258
+ ok = db.update_reading_list_item_last_read(
259
+ user_id,
260
+ reading_list_id,
261
+ manga_id,
262
+ body.last_chapter_number,
263
+ )
264
+ if not ok:
265
+ raise HTTPException(status_code=404, detail="Not on this reading list")
266
+ items = db.list_reading_list_items_with_manga(user_id, reading_list_id, limit=500)
267
+ for it in items:
268
+ if it.manga_id == manga_id:
269
+ return it
270
+ raise HTTPException(status_code=404, detail="Item not found after update")
271
+
272
+
273
  @router.patch("/users/me")
274
  def patch_me_display_name(ctx: CurrentAuthContext, body: UserDisplayNamePatchIn):
275
  """Update display_name on public.users for the signed-in user."""
db/__init__.py CHANGED
@@ -4,8 +4,6 @@
4
  Users,
5
  ReadingListCollection,
6
  ReadingListItem,
7
- MangaSource,
8
- resolve_manga_id_by_source,
9
  get_reading_list_collection,
10
  create_reading_list_collection,
11
  update_reading_list_collection,
@@ -15,36 +13,39 @@
15
  upsert_reading_list_item,
16
  remove_reading_list_item_from_list,
17
  add_reading_list_item_by_source,
18
- save_page_translation,
19
- get_segments,
20
- get_chapter_segments,
 
 
21
  list_entries,
22
- delete_page_segments,
23
- delete_chapter_segments,
24
  get_connection,
25
  get_engine
26
  """
27
- from .const import PROVIDER_IDS, PROVIDER_LOCAL, PROVIDER_MANGADEX
28
- from .models import Users, ReadingListCollection, ReadingListItem, MangaSource
29
  from .db import (
30
  init_db,
31
- resolve_manga_id_by_source,
32
  get_reading_list_collection,
33
  create_reading_list_collection,
34
  update_reading_list_collection,
 
35
  delete_reading_list_collection,
36
  list_reading_list_collections_with_counts,
37
  list_reading_list_items_with_manga,
38
  upsert_reading_list_item,
 
39
  remove_reading_list_item_from_list,
40
  add_reading_list_item_by_source,
41
- save_page_translation,
42
- get_segments,
43
- get_chapter_segments,
 
 
 
 
 
44
  list_mangas,
45
  list_chapters,
46
- delete_page_segments,
47
- delete_chapter_segments,
48
  delete_all_manga,
49
  get_connection,
50
  get_engine,
 
4
  Users,
5
  ReadingListCollection,
6
  ReadingListItem,
 
 
7
  get_reading_list_collection,
8
  create_reading_list_collection,
9
  update_reading_list_collection,
 
13
  upsert_reading_list_item,
14
  remove_reading_list_item_from_list,
15
  add_reading_list_item_by_source,
16
+ save_panels_translation,
17
+ get_panels,
18
+ get_chapter_panels,
19
+ delete_page_panels,
20
+ delete_chapter_panels,
21
  list_entries,
 
 
22
  get_connection,
23
  get_engine
24
  """
25
+ from .models import Users, ReadingListCollection, ReadingListItem
 
26
  from .db import (
27
  init_db,
 
28
  get_reading_list_collection,
29
  create_reading_list_collection,
30
  update_reading_list_collection,
31
+ update_app_user_display_name,
32
  delete_reading_list_collection,
33
  list_reading_list_collections_with_counts,
34
  list_reading_list_items_with_manga,
35
  upsert_reading_list_item,
36
+ update_reading_list_item_last_read,
37
  remove_reading_list_item_from_list,
38
  add_reading_list_item_by_source,
39
+ save_panels_translation,
40
+ upsert_panel_translation,
41
+ get_panels,
42
+ get_panel_by_panel_url,
43
+ delete_panel_by_panel_url,
44
+ get_chapter_panels,
45
+ delete_page_panels,
46
+ delete_chapter_panels,
47
  list_mangas,
48
  list_chapters,
 
 
49
  delete_all_manga,
50
  get_connection,
51
  get_engine,
db/const.py DELETED
@@ -1,3 +0,0 @@
1
- PROVIDER_LOCAL = "local"
2
- PROVIDER_MANGADEX = "mangadex"
3
- PROVIDER_IDS = frozenset((PROVIDER_LOCAL, PROVIDER_MANGADEX))
 
 
 
 
db/db.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  Database: SQL (PostgreSQL) via SQLModel.
3
 
4
- Schema: Users, reading_list_collection, reading_list_item; Manga → Chapters → Pages → Segments
5
 
6
  Utility Functions:
7
 
@@ -20,27 +20,27 @@ get_connection(db_url=None):
20
 
21
  init_db(db_url=None):
22
  Initializes the database schema by creating tables for all SQLModel models
23
- (Users, ReadingListCollection, ReadingListItem, Manga, Chapters, Pages, Segments).
24
 
25
  Other Core Database Functions:
26
 
27
- save_page_translation(provider_id, manga_title, chapter_number, page_number, bubbles, language_code, db_url=None):
28
- Saves text bubble/segment data for a specific manga page to the database.
29
 
30
- get_segments(provider_id=None, manga_title=None, chapter_number=None, page_number=None, db_url=None):
31
- Returns all segments (bubbles) matching the specified filters.
32
 
33
- get_chapter_segments(provider_id, manga_title, chapter_number, db_url=None):
34
- Returns all segments for every page in a single chapter.
35
 
36
  list_entries(db_url=None, order_by="created_at", order_desc=True):
37
- Lists chapters with provider_id, manga_title, chapter_number, and last_updated.
38
 
39
- delete_page_segments(provider_id, manga_title, chapter_number, page_number, db_url=None):
40
- Removes all segments for a specific page in a chapter.
41
 
42
- delete_chapter_segments(provider_id, manga_title, chapter_number, db_url=None):
43
- Removes all pages and segments for an entire chapter.
44
  """
45
 
46
  import os
@@ -49,15 +49,14 @@ from pathlib import Path
49
  from typing import Optional
50
  from uuid import UUID
51
  from sqlalchemy import desc, func
 
52
  from sqlmodel import Session, SQLModel, create_engine, select
53
 
54
- from .const import PROVIDER_IDS, PROVIDER_LOCAL, PROVIDER_MANGADEX
55
  from .models import (
56
  Manga,
57
- MangaSource,
58
  Chapters,
59
- Pages,
60
- Segments,
61
  ReadingListCollection,
62
  ReadingListItem,
63
  Users,
@@ -66,15 +65,10 @@ from .schemas import (
66
  ChapterListOut,
67
  ReadingListCollectionOut,
68
  ReadingListItemOut,
69
- SegmentListOut,
70
  )
71
 
72
 
73
- def _validate_provider_id(provider_id: str) -> None:
74
- if provider_id not in PROVIDER_IDS:
75
- raise ValueError(f"provider_id must be one of {sorted(PROVIDER_IDS)}, got {provider_id!r}")
76
-
77
-
78
  try:
79
  from dotenv import load_dotenv
80
  load_dotenv(Path(__file__).resolve().parent.parent / ".env")
@@ -109,109 +103,98 @@ def get_connection(db_url=None):
109
 
110
 
111
  def init_db(db_url=None):
112
- """Create tables: users, reading_list_collection, reading_list_item, manga, manga_source, chapters, pages, segments."""
113
  engine = get_engine(db_url)
114
  SQLModel.metadata.create_all(engine)
115
 
116
 
117
- def _is_placeholder_external_manga_id(external_manga_id: str) -> bool:
118
- """Synthetic ids we generate when the provider has no catalog key (local-*, legacy-*)."""
119
- return external_manga_id.startswith("legacy-") or external_manga_id.startswith("local-")
120
-
121
-
122
- def _synthetic_external_manga_id(provider_id: str, manga_id: int) -> str:
123
- """Stable per-umbrella id for manga_source when no real external id was passed."""
124
- if provider_id == PROVIDER_LOCAL:
125
- return f"local-{manga_id}"
126
- return f"legacy-{manga_id}"
127
-
128
-
129
- def _ensure_manga_source(session, manga_id: int, provider_id: str, external_manga_id: str) -> None:
130
- stmt = select(MangaSource).where(
131
- MangaSource.manga_id == manga_id,
132
- MangaSource.provider_id == provider_id,
133
- )
134
- row = session.exec(stmt).first()
135
- if row:
136
- if external_manga_id and not _is_placeholder_external_manga_id(external_manga_id):
137
- row.external_manga_id = external_manga_id
138
- session.add(row)
139
- session.flush()
140
- return
141
- session.add(
142
- MangaSource(
143
- manga_id=manga_id,
144
- provider_id=provider_id,
145
- external_manga_id=external_manga_id,
146
- )
147
- )
148
- session.flush()
149
-
150
-
151
  def resolve_manga_id(
152
  session,
153
- provider_id: str,
154
  manga_title: str,
155
  external_manga_id: Optional[str],
156
  ) -> int:
157
- """Find or create umbrella manga and ensure a MangaSource row for this provider."""
158
- ext_in = (external_manga_id or "").strip()
159
- if ext_in:
160
- src = session.exec(
161
- select(MangaSource).where(
162
- MangaSource.provider_id == provider_id,
163
- MangaSource.external_manga_id == ext_in,
164
- )
 
 
 
 
 
 
 
 
165
  ).first()
166
- if src:
167
- return src.manga_id
168
 
169
- row = session.exec(select(Manga).where(Manga.manga_title == manga_title)).first()
 
 
170
  if row:
171
- synth = _synthetic_external_manga_id(provider_id, row.id)
172
- _ensure_manga_source(session, row.id, provider_id, ext_in or synth)
 
 
 
 
 
173
  return row.id
174
 
175
- m = Manga(manga_title=manga_title)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  session.add(m)
177
  session.flush()
178
- synth = _synthetic_external_manga_id(provider_id, m.id)
179
- _ensure_manga_source(session, m.id, provider_id, ext_in or synth)
180
  return m.id
181
 
182
 
183
- def resolve_manga_id_by_source(provider_id: str, external_manga_id: str, db_url=None) -> Optional[int]:
184
- """Return umbrella manga id for a provider catalog id, if mapped."""
185
- _validate_provider_id(provider_id)
186
- ext = external_manga_id.strip()
187
- if not ext:
188
- return None
189
- engine = get_engine(db_url)
190
- with Session(engine) as session:
191
- src = session.exec(
192
- select(MangaSource).where(
193
- MangaSource.provider_id == provider_id,
194
- MangaSource.external_manga_id == ext,
195
- )
196
- ).first()
197
- return src.manga_id if src else None
198
-
199
-
200
  def _get_or_create_chapter(
201
- session, manga_id: int, provider_id: str, chapter_number: float, language_code: str
 
 
 
 
 
202
  ) -> int:
203
  stmt = select(Chapters).where(
204
  Chapters.manga_id == manga_id,
205
- Chapters.provider_id == provider_id,
206
  Chapters.chapter_number == chapter_number,
207
  )
208
  row = session.exec(stmt).first()
209
  if row:
 
 
 
 
 
 
 
210
  return row.id
211
  ch = Chapters(
212
  manga_id=manga_id,
213
- provider_id=provider_id,
214
  chapter_number=chapter_number,
 
215
  language_code=language_code,
216
  )
217
  session.add(ch)
@@ -219,134 +202,202 @@ def _get_or_create_chapter(
219
  return ch.id
220
 
221
 
222
- def _get_or_create_page(session, chapter_id: int, page_number: int) -> int:
223
- stmt = select(Pages).where(Pages.chapter_id == chapter_id, Pages.page_number == page_number)
224
- row = session.exec(stmt).first()
225
- if row:
226
- return row.id
227
- p = Pages(chapter_id=chapter_id, page_number=page_number)
228
- session.add(p)
229
- session.flush()
230
- return p.id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
 
233
- def save_page_translation(
234
- provider_id: str,
 
 
 
235
  manga_title: str,
236
  chapter_number: float,
237
- page_number: int,
238
- bubbles: list[dict],
239
  language_code: str,
240
  *,
241
  external_manga_id: Optional[str] = None,
 
242
  db_url=None,
243
  ) -> None:
244
- """Save one page's segments (replace existing for this provider/manga/chapter/page)."""
245
- _validate_provider_id(provider_id)
 
 
 
246
  engine = get_engine(db_url)
247
  with Session(engine) as session:
248
- manga_id = resolve_manga_id(session, provider_id, manga_title, external_manga_id)
249
- chapter_id = _get_or_create_chapter(session, manga_id, provider_id, chapter_number, language_code)
250
- page_id = _get_or_create_page(session, chapter_id, page_number)
251
- for s in session.exec(select(Segments).where(Segments.page_id == page_id)).all():
252
- session.delete(s)
253
- session.flush()
254
- for b in bubbles:
255
- seg = Segments(
256
- page_id=page_id,
257
- segment_index=b["bubble_index"],
258
- x1=b["x1"], y1=b["y1"], x2=b["x2"], y2=b["y2"],
259
- original_text=b["original_text"],
260
- translated_text=b["translated_text"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  )
262
- session.add(seg)
 
263
  now = datetime.now(timezone.utc)
264
- page = session.get(Pages, page_id)
265
- if page:
266
- page.updated_at = now
267
- chapter = session.get(Chapters, page.chapter_id)
268
- if chapter:
269
- chapter.updated_at = now
270
- manga = session.get(Manga, chapter.manga_id)
271
- if manga:
272
- manga.updated_at = now
273
  session.commit()
274
 
275
 
276
- def delete_page_segments(
277
- provider_id: str,
278
  manga_title: str,
279
  chapter_number: float,
280
- page_number: int,
281
  db_url=None,
282
  ) -> None:
283
- """Delete all segments for one page and the page row."""
284
- _validate_provider_id(provider_id)
285
  engine = get_engine(db_url)
286
  with Session(engine) as session:
287
  m = session.exec(select(Manga).where(Manga.manga_title == manga_title)).first()
288
  if not m:
289
  return
290
- ch_stmt = select(Chapters).where(
291
- Chapters.manga_id == m.id,
292
- Chapters.provider_id == provider_id,
293
- Chapters.chapter_number == chapter_number,
294
- )
295
  ch = session.exec(ch_stmt).first()
296
  if not ch:
297
  return
298
- page_stmt = select(Pages).where(Pages.chapter_id == ch.id, Pages.page_number == page_number)
299
- page = session.exec(page_stmt).first()
300
- if not page:
301
- return
302
- for seg in session.exec(select(Segments).where(Segments.page_id == page.id)).all():
303
- session.delete(seg)
304
- session.flush()
305
- session.delete(page)
306
  session.commit()
307
 
308
 
309
- def delete_chapter_segments(
310
- provider_id: str,
311
  manga_title: str,
312
  chapter_number: float,
313
  db_url=None,
314
  ) -> None:
315
- """Delete chapter and all its pages/segments (explicit deletes; DB may not have CASCADE)."""
316
- _validate_provider_id(provider_id)
317
  engine = get_engine(db_url)
318
  with Session(engine) as session:
319
  m = session.exec(select(Manga).where(Manga.manga_title == manga_title)).first()
320
  if not m:
321
  return
322
- ch = session.exec(
323
- select(Chapters).where(
324
- Chapters.manga_id == m.id,
325
- Chapters.provider_id == provider_id,
326
- Chapters.chapter_number == chapter_number,
327
- )
328
- ).first()
329
  if not ch:
330
  return
331
- pages = list(session.exec(select(Pages).where(Pages.chapter_id == ch.id)).all())
332
- for page in pages:
333
- for seg in session.exec(select(Segments).where(Segments.page_id == page.id)).all():
334
- session.delete(seg)
335
- session.flush()
336
- for page in pages:
337
- session.delete(page)
338
  session.flush()
339
  session.delete(ch)
340
  session.commit()
341
 
342
 
343
  def delete_all_manga(db_url=None) -> None:
344
- """Delete all segments, pages, chapters, reading-list rows, manga_source rows, and manga rows."""
345
  engine = get_engine(db_url)
346
  with Session(engine) as session:
347
- for seg in session.exec(select(Segments)).all():
348
- session.delete(seg)
349
- for p in session.exec(select(Pages)).all():
350
  session.delete(p)
351
  for ch in session.exec(select(Chapters)).all():
352
  session.delete(ch)
@@ -354,83 +405,231 @@ def delete_all_manga(db_url=None) -> None:
354
  session.delete(rli)
355
  for rlc in session.exec(select(ReadingListCollection)).all():
356
  session.delete(rlc)
357
- for ms in session.exec(select(MangaSource)).all():
358
- session.delete(ms)
359
  for m in session.exec(select(Manga)).all():
360
  session.delete(m)
361
  session.commit()
362
 
363
 
364
- def get_segments(
365
- provider_id: Optional[str] = None,
366
  manga_title: Optional[str] = None,
367
  chapter_number: Optional[float] = None,
368
  page_number: Optional[int] = None,
369
  limit: Optional[int] = None,
370
  offset: int = 0,
371
  db_url=None,
372
- ) -> list[SegmentListOut]:
373
- """Query segments; provider_id filters on chapter. Supports limit/offset for pagination."""
374
- if provider_id is not None:
375
- _validate_provider_id(provider_id)
376
  engine = get_engine(db_url)
377
  with Session(engine) as session:
378
  stmt = (
379
  select(
380
- Segments.id,
381
- Chapters.provider_id,
382
  Manga.manga_title,
383
  Chapters.chapter_number,
384
- Pages.page_number,
385
- Segments.segment_index,
386
- Segments.x1, Segments.y1, Segments.x2, Segments.y2,
387
- Segments.original_text,
388
- Segments.translated_text,
 
 
 
 
389
  Chapters.language_code,
390
- Segments.created_at,
391
  )
392
- .select_from(Segments)
393
- .join(Pages, Segments.page_id == Pages.id)
394
- .join(Chapters, Pages.chapter_id == Chapters.id)
395
  .join(Manga, Chapters.manga_id == Manga.id)
396
  )
397
- if provider_id is not None:
398
- stmt = stmt.where(Chapters.provider_id == provider_id)
399
  if manga_title is not None:
400
  stmt = stmt.where(Manga.manga_title == manga_title)
401
  if chapter_number is not None:
402
  stmt = stmt.where(Chapters.chapter_number == chapter_number)
403
  if page_number is not None:
404
- stmt = stmt.where(Pages.page_number == page_number)
405
- stmt = stmt.order_by(Chapters.chapter_number, Pages.page_number, Segments.segment_index)
 
 
 
 
406
  if offset > 0:
407
  stmt = stmt.offset(offset)
408
  if limit is not None and limit > 0:
409
  stmt = stmt.limit(limit)
410
  rows = session.exec(stmt).all()
411
  return [
412
- SegmentListOut(
413
- id=r[0], provider_id=r[1], manga_title=r[2], chapter_number=r[3],
414
- page_number=r[4], segment_index=r[5], x1=r[6], y1=r[7], x2=r[8],
415
- y2=r[9], original_text=r[10], translated_text=r[11], language_code=r[12],
416
- created_at=r[13],
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  )
418
  for r in rows
419
  ]
420
 
421
 
422
- def get_chapter_segments(
423
- provider_id: str,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  manga_title: str,
425
  chapter_number: float,
426
  limit: Optional[int] = None,
427
  offset: int = 0,
428
  db_url=None,
429
- ) -> list[SegmentListOut]:
430
- """Get all segments for a chapter. Supports limit/offset for pagination."""
431
- _validate_provider_id(provider_id)
432
- return get_segments(
433
- provider_id=provider_id,
434
  manga_title=manga_title,
435
  chapter_number=chapter_number,
436
  limit=limit,
@@ -439,25 +638,29 @@ def get_chapter_segments(
439
  )
440
 
441
 
 
442
  def list_chapters(
443
  manga_title: str,
444
- provider_id: Optional[str] = None,
445
  limit: Optional[int] = None,
446
  offset: int = 0,
447
  db_url=None,
448
  ) -> list[ChapterListOut]:
449
- """List chapters for an umbrella manga_title; optionally filter by chapter provider_id."""
450
  engine = get_engine(db_url)
451
  with Session(engine) as session:
452
  stmt = (
453
- select(Manga.manga_title, Chapters.provider_id, Chapters.id, Chapters.chapter_number, Chapters.created_at, Chapters.updated_at)
 
 
 
 
 
 
 
454
  .select_from(Chapters)
455
  .join(Manga, Chapters.manga_id == Manga.id)
456
  .where(Manga.manga_title == manga_title)
457
  )
458
- if provider_id is not None:
459
- _validate_provider_id(provider_id)
460
- stmt = stmt.where(Chapters.provider_id == provider_id)
461
  if offset > 0:
462
  stmt = stmt.offset(offset)
463
  if limit is not None and limit > 0:
@@ -465,8 +668,12 @@ def list_chapters(
465
  rows = session.exec(stmt).all()
466
  return [
467
  ChapterListOut(
468
- manga_title=r[0], provider_id=r[1], id=r[2], chapter_number=r[3],
469
- created_at=r[4], updated_at=r[5],
 
 
 
 
470
  )
471
  for r in rows
472
  ]
@@ -658,13 +865,8 @@ def list_reading_list_collections_with_counts(
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,
@@ -672,20 +874,11 @@ def list_reading_list_collections_with_counts(
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,
@@ -712,13 +905,8 @@ def list_reading_list_items_with_manga(
712
  engine = get_engine(db_url)
713
  with Session(engine) as session:
714
  stmt = (
715
- select(ReadingListItem, Manga, MangaSource)
716
  .join(Manga, ReadingListItem.manga_id == Manga.id)
717
- .outerjoin(
718
- MangaSource,
719
- (MangaSource.manga_id == Manga.id)
720
- & (MangaSource.provider_id == PROVIDER_MANGADEX),
721
- )
722
  .where(ReadingListItem.reading_list_id == reading_list_id)
723
  .order_by(desc(ReadingListItem.updated_at))
724
  )
@@ -728,12 +916,8 @@ def list_reading_list_items_with_manga(
728
  stmt = stmt.limit(limit)
729
  rows = session.exec(stmt).all()
730
  out: list[ReadingListItemOut] = []
731
- for item, manga, src in rows:
732
- ext = None
733
- if src and src.external_manga_id and not _is_placeholder_external_manga_id(
734
- src.external_manga_id
735
- ):
736
- ext = src.external_manga_id
737
  out.append(
738
  ReadingListItemOut(
739
  id=item.id,
@@ -774,7 +958,10 @@ def upsert_reading_list_item(
774
  row = session.exec(stmt).first()
775
  if row:
776
  if last_chapter_number is not None:
777
- row.last_chapter_number = last_chapter_number
 
 
 
778
  row.updated_at = now
779
  session.add(row)
780
  col.updated_at = now
@@ -797,6 +984,47 @@ def upsert_reading_list_item(
797
  return entry
798
 
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  def remove_reading_list_item_from_list(
801
  user_id: UUID, reading_list_id: int, manga_id: int, db_url=None
802
  ) -> bool:
@@ -828,21 +1056,19 @@ def remove_reading_list_item_from_list(
828
  def add_reading_list_item_by_source(
829
  user_id: UUID,
830
  reading_list_id: int,
831
- provider_id: str,
832
  external_manga_id: str,
833
  manga_title: str,
834
  *,
835
  last_chapter_number: Optional[float] = None,
836
  db_url=None,
837
  ) -> ReadingListItem:
838
- _validate_provider_id(provider_id)
839
  ext = (external_manga_id or "").strip()
840
  title = (manga_title or "").strip() or "Untitled"
841
  if not ext:
842
  raise ValueError("external_manga_id is required")
843
  engine = get_engine(db_url)
844
  with Session(engine) as session:
845
- manga_id = resolve_manga_id(session, provider_id, title, ext)
846
  session.commit()
847
  return upsert_reading_list_item(
848
  user_id,
 
1
  """
2
  Database: SQL (PostgreSQL) via SQLModel.
3
 
4
+ Schema: Users, reading_list_collection, reading_list_item; Manga → Chapters → Panels
5
 
6
  Utility Functions:
7
 
 
20
 
21
  init_db(db_url=None):
22
  Initializes the database schema by creating tables for all SQLModel models
23
+ (Users, ReadingListCollection, ReadingListItem, Manga, Chapters, Panels).
24
 
25
  Other Core Database Functions:
26
 
27
+ save_panels_translation(manga_title, chapter_number, page_number, panels, language_code, db_url=None):
28
+ Saves text bubble/panel data for a specific manga page to the database.
29
 
30
+ get_panels(manga_title=None, chapter_number=None, page_number=None, db_url=None):
31
+ Returns all panels (bubbles) matching the specified filters.
32
 
33
+ get_chapter_panels(manga_title, chapter_number, db_url=None):
34
+ Returns all panels for every page in a single chapter.
35
 
36
  list_entries(db_url=None, order_by="created_at", order_desc=True):
37
+ Lists chapters with manga_title, chapter_number, and last_updated.
38
 
39
+ delete_page_panels(manga_title, chapter_number, page_number, db_url=None):
40
+ Removes all panels for a specific page in a chapter.
41
 
42
+ delete_chapter_panels(manga_title, chapter_number, db_url=None):
43
+ Removes all panels for an entire chapter.
44
  """
45
 
46
  import os
 
49
  from typing import Optional
50
  from uuid import UUID
51
  from sqlalchemy import desc, func
52
+ from sqlalchemy.exc import IntegrityError
53
  from sqlmodel import Session, SQLModel, create_engine, select
54
 
55
+ # Single-provider setup (MangaDex).
56
  from .models import (
57
  Manga,
 
58
  Chapters,
59
+ Panels,
 
60
  ReadingListCollection,
61
  ReadingListItem,
62
  Users,
 
65
  ChapterListOut,
66
  ReadingListCollectionOut,
67
  ReadingListItemOut,
68
+ PanelListOut,
69
  )
70
 
71
 
 
 
 
 
 
72
  try:
73
  from dotenv import load_dotenv
74
  load_dotenv(Path(__file__).resolve().parent.parent / ".env")
 
103
 
104
 
105
  def init_db(db_url=None):
106
+ """Create tables: users, reading_list_collection, reading_list_item, manga, chapters, panels."""
107
  engine = get_engine(db_url)
108
  SQLModel.metadata.create_all(engine)
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def resolve_manga_id(
112
  session,
 
113
  manga_title: str,
114
  external_manga_id: Optional[str],
115
  ) -> int:
116
+ """Find or create umbrella manga.
117
+
118
+ If external_manga_id is provided, it is treated as MangaDex series id and stored
119
+ on Manga.mangadex_manga_id for later lookups.
120
+
121
+ MangaDex ids are normalized to lowercase so lookups stay stable. Concurrent
122
+ inserts for the same series id are merged via DB uniqueness + IntegrityError
123
+ handling (important when one title is added to several lists in parallel).
124
+ """
125
+ title_stripped = (manga_title or "").strip() or "Untitled"
126
+ ext_raw = (external_manga_id or "").strip()
127
+ norm_ext = ext_raw.lower() if ext_raw else ""
128
+
129
+ if norm_ext:
130
+ row = session.exec(
131
+ select(Manga).where(func.lower(Manga.mangadex_manga_id) == norm_ext),
132
  ).first()
133
+ if row:
134
+ return row.id
135
 
136
+ row = session.exec(
137
+ select(Manga).where(Manga.manga_title == title_stripped),
138
+ ).first()
139
  if row:
140
+ if norm_ext:
141
+ existing_norm = (row.mangadex_manga_id or "").strip().lower()
142
+ if existing_norm != norm_ext:
143
+ row.mangadex_manga_id = norm_ext
144
+ row.updated_at = datetime.now(timezone.utc)
145
+ session.add(row)
146
+ session.flush()
147
  return row.id
148
 
149
+ if norm_ext:
150
+ try:
151
+ with session.begin_nested():
152
+ m = Manga(manga_title=title_stripped, mangadex_manga_id=norm_ext)
153
+ session.add(m)
154
+ session.flush()
155
+ return m.id
156
+ except IntegrityError:
157
+ dup = session.exec(
158
+ select(Manga).where(
159
+ func.lower(Manga.mangadex_manga_id) == norm_ext,
160
+ ),
161
+ ).first()
162
+ if dup is None:
163
+ raise
164
+ return dup.id
165
+
166
+ m = Manga(manga_title=title_stripped, mangadex_manga_id=None)
167
  session.add(m)
168
  session.flush()
 
 
169
  return m.id
170
 
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  def _get_or_create_chapter(
173
+ session,
174
+ manga_id: int,
175
+ chapter_number: float,
176
+ language_code: str,
177
+ *,
178
+ mangadex_chapter_id: Optional[str] = None,
179
  ) -> int:
180
  stmt = select(Chapters).where(
181
  Chapters.manga_id == manga_id,
 
182
  Chapters.chapter_number == chapter_number,
183
  )
184
  row = session.exec(stmt).first()
185
  if row:
186
+ if mangadex_chapter_id is not None:
187
+ v = mangadex_chapter_id.strip()
188
+ if v and row.mangadex_chapter_id != v:
189
+ row.mangadex_chapter_id = v
190
+ row.updated_at = datetime.now(timezone.utc)
191
+ session.add(row)
192
+ session.flush()
193
  return row.id
194
  ch = Chapters(
195
  manga_id=manga_id,
 
196
  chapter_number=chapter_number,
197
+ mangadex_chapter_id=(mangadex_chapter_id.strip() if mangadex_chapter_id else None),
198
  language_code=language_code,
199
  )
200
  session.add(ch)
 
202
  return ch.id
203
 
204
 
205
+ def save_panels_translation(
206
+ manga_title: str,
207
+ chapter_number: float,
208
+ page_number: Optional[int],
209
+ panels: list[dict],
210
+ language_code: str,
211
+ *,
212
+ external_manga_id: Optional[str] = None,
213
+ mangadex_chapter_id: Optional[str] = None,
214
+ db_url=None,
215
+ ) -> None:
216
+ """Save panels (replace existing for this manga/chapter/page)."""
217
+ engine = get_engine(db_url)
218
+ with Session(engine) as session:
219
+ manga_id = resolve_manga_id(session, manga_title, external_manga_id)
220
+ chapter_id = _get_or_create_chapter(
221
+ session,
222
+ manga_id,
223
+ chapter_number,
224
+ language_code,
225
+ mangadex_chapter_id=mangadex_chapter_id,
226
+ )
227
+ for p in session.exec(
228
+ select(Panels).where(Panels.chapter_id == chapter_id, Panels.page_number == page_number)
229
+ ).all():
230
+ session.delete(p)
231
+ session.flush()
232
+ for panel in panels:
233
+ row = Panels(
234
+ chapter_id=chapter_id,
235
+ page_number=page_number,
236
+ mangadex_chapter_id=(mangadex_chapter_id.strip() if mangadex_chapter_id else None),
237
+ bubble_index=panel["bubble_index"],
238
+ width=panel.get("width"),
239
+ height=panel.get("height"),
240
+ panel_url=panel.get("panel_url"),
241
+ x1=panel["x1"],
242
+ y1=panel["y1"],
243
+ x2=panel["x2"],
244
+ y2=panel["y2"],
245
+ original_text=panel["original_text"],
246
+ translated_text=panel["translated_text"],
247
+ )
248
+ session.add(row)
249
+ now = datetime.now(timezone.utc)
250
+ chapter = session.get(Chapters, chapter_id)
251
+ if chapter:
252
+ chapter.updated_at = now
253
+ manga = session.get(Manga, chapter.manga_id)
254
+ if manga:
255
+ manga.updated_at = now
256
+ session.commit()
257
 
258
 
259
+ # Backwards-compatible alias (older code/tests may still import this name).
260
+ save_panel_translation = save_panels_translation
261
+
262
+
263
+ def upsert_panel_translation(
264
  manga_title: str,
265
  chapter_number: float,
266
+ page_number: Optional[int],
267
+ panel: dict,
268
  language_code: str,
269
  *,
270
  external_manga_id: Optional[str] = None,
271
+ mangadex_chapter_id: Optional[str] = None,
272
  db_url=None,
273
  ) -> None:
274
+ """Insert/update a single panel without deleting other panels.
275
+
276
+ If panel_url is provided, uniqueness is treated as (chapter_id, page_number, panel_url).
277
+ Otherwise falls back to (chapter_id, page_number, bubble_index).
278
+ """
279
  engine = get_engine(db_url)
280
  with Session(engine) as session:
281
+ manga_id = resolve_manga_id(session, manga_title, external_manga_id)
282
+ chapter_id = _get_or_create_chapter(
283
+ session,
284
+ manga_id,
285
+ chapter_number,
286
+ language_code,
287
+ mangadex_chapter_id=mangadex_chapter_id,
288
+ )
289
+
290
+ bubble_index = panel["bubble_index"]
291
+ panel_url = panel.get("panel_url")
292
+ panel_url = panel_url.strip() if isinstance(panel_url, str) else None
293
+ if panel_url:
294
+ existing = session.exec(
295
+ select(Panels).where(
296
+ Panels.chapter_id == chapter_id,
297
+ Panels.page_number == page_number,
298
+ Panels.panel_url == panel_url,
299
+ )
300
+ ).first()
301
+ else:
302
+ existing = session.exec(
303
+ select(Panels).where(
304
+ Panels.chapter_id == chapter_id,
305
+ Panels.page_number == page_number,
306
+ Panels.bubble_index == bubble_index,
307
+ )
308
+ ).first()
309
+
310
+ mdx = mangadex_chapter_id.strip() if mangadex_chapter_id else None
311
+ if existing:
312
+ existing.mangadex_chapter_id = mdx
313
+ existing.panel_url = panel_url
314
+ existing.bubble_index = bubble_index
315
+ existing.width = panel.get("width")
316
+ existing.height = panel.get("height")
317
+ existing.x1 = panel["x1"]
318
+ existing.y1 = panel["y1"]
319
+ existing.x2 = panel["x2"]
320
+ existing.y2 = panel["y2"]
321
+ existing.original_text = panel["original_text"]
322
+ existing.translated_text = panel["translated_text"]
323
+ session.add(existing)
324
+ else:
325
+ row = Panels(
326
+ chapter_id=chapter_id,
327
+ page_number=page_number,
328
+ mangadex_chapter_id=mdx,
329
+ bubble_index=bubble_index,
330
+ width=panel.get("width"),
331
+ height=panel.get("height"),
332
+ panel_url=panel_url,
333
+ x1=panel["x1"],
334
+ y1=panel["y1"],
335
+ x2=panel["x2"],
336
+ y2=panel["y2"],
337
+ original_text=panel["original_text"],
338
+ translated_text=panel["translated_text"],
339
  )
340
+ session.add(row)
341
+
342
  now = datetime.now(timezone.utc)
343
+ chapter = session.get(Chapters, chapter_id)
344
+ if chapter:
345
+ chapter.updated_at = now
346
+ manga = session.get(Manga, chapter.manga_id)
347
+ if manga:
348
+ manga.updated_at = now
 
 
 
349
  session.commit()
350
 
351
 
352
+ def delete_page_panels(
 
353
  manga_title: str,
354
  chapter_number: float,
355
+ page_number: Optional[int],
356
  db_url=None,
357
  ) -> None:
358
+ """Delete all panels for one page."""
 
359
  engine = get_engine(db_url)
360
  with Session(engine) as session:
361
  m = session.exec(select(Manga).where(Manga.manga_title == manga_title)).first()
362
  if not m:
363
  return
364
+ ch_stmt = select(Chapters).where(Chapters.manga_id == m.id, Chapters.chapter_number == chapter_number)
 
 
 
 
365
  ch = session.exec(ch_stmt).first()
366
  if not ch:
367
  return
368
+ for p in session.exec(
369
+ select(Panels).where(Panels.chapter_id == ch.id, Panels.page_number == page_number)
370
+ ).all():
371
+ session.delete(p)
 
 
 
 
372
  session.commit()
373
 
374
 
375
+ def delete_chapter_panels(
 
376
  manga_title: str,
377
  chapter_number: float,
378
  db_url=None,
379
  ) -> None:
380
+ """Delete chapter and all its panels (explicit deletes; DB may not have CASCADE)."""
 
381
  engine = get_engine(db_url)
382
  with Session(engine) as session:
383
  m = session.exec(select(Manga).where(Manga.manga_title == manga_title)).first()
384
  if not m:
385
  return
386
+ ch = session.exec(select(Chapters).where(Chapters.manga_id == m.id, Chapters.chapter_number == chapter_number)).first()
 
 
 
 
 
 
387
  if not ch:
388
  return
389
+ for p in session.exec(select(Panels).where(Panels.chapter_id == ch.id)).all():
390
+ session.delete(p)
 
 
 
 
 
391
  session.flush()
392
  session.delete(ch)
393
  session.commit()
394
 
395
 
396
  def delete_all_manga(db_url=None) -> None:
397
+ """Delete all panels, chapters, reading-list rows, and manga rows."""
398
  engine = get_engine(db_url)
399
  with Session(engine) as session:
400
+ for p in session.exec(select(Panels)).all():
 
 
401
  session.delete(p)
402
  for ch in session.exec(select(Chapters)).all():
403
  session.delete(ch)
 
405
  session.delete(rli)
406
  for rlc in session.exec(select(ReadingListCollection)).all():
407
  session.delete(rlc)
 
 
408
  for m in session.exec(select(Manga)).all():
409
  session.delete(m)
410
  session.commit()
411
 
412
 
413
+ def get_panels(
 
414
  manga_title: Optional[str] = None,
415
  chapter_number: Optional[float] = None,
416
  page_number: Optional[int] = None,
417
  limit: Optional[int] = None,
418
  offset: int = 0,
419
  db_url=None,
420
+ ) -> list[PanelListOut]:
421
+ """Query panels. Supports limit/offset for pagination.
422
+ """
 
423
  engine = get_engine(db_url)
424
  with Session(engine) as session:
425
  stmt = (
426
  select(
427
+ Panels.id,
 
428
  Manga.manga_title,
429
  Chapters.chapter_number,
430
+ Chapters.mangadex_chapter_id,
431
+ Panels.page_number,
432
+ Panels.bubble_index,
433
+ Panels.width,
434
+ Panels.height,
435
+ Panels.x1, Panels.y1, Panels.x2, Panels.y2,
436
+ Panels.original_text,
437
+ Panels.translated_text,
438
+ Panels.panel_url,
439
  Chapters.language_code,
440
+ Panels.created_at,
441
  )
442
+ .select_from(Panels)
443
+ .join(Chapters, Panels.chapter_id == Chapters.id)
 
444
  .join(Manga, Chapters.manga_id == Manga.id)
445
  )
 
 
446
  if manga_title is not None:
447
  stmt = stmt.where(Manga.manga_title == manga_title)
448
  if chapter_number is not None:
449
  stmt = stmt.where(Chapters.chapter_number == chapter_number)
450
  if page_number is not None:
451
+ stmt = stmt.where(Panels.page_number == page_number)
452
+ stmt = stmt.order_by(
453
+ Chapters.chapter_number,
454
+ func.coalesce(Panels.page_number, 0),
455
+ Panels.bubble_index,
456
+ )
457
  if offset > 0:
458
  stmt = stmt.offset(offset)
459
  if limit is not None and limit > 0:
460
  stmt = stmt.limit(limit)
461
  rows = session.exec(stmt).all()
462
  return [
463
+ PanelListOut(
464
+ id=r[0],
465
+ manga_title=r[1],
466
+ chapter_number=r[2],
467
+ mangadex_chapter_id=r[3],
468
+ page_number=r[4],
469
+ bubble_index=r[5],
470
+ width=r[6],
471
+ height=r[7],
472
+ x1=r[8],
473
+ y1=r[9],
474
+ x2=r[10],
475
+ y2=r[11],
476
+ original_text=r[12],
477
+ translated_text=r[13],
478
+ panel_url=r[14],
479
+ language_code=r[15],
480
+ created_at=r[16],
481
  )
482
  for r in rows
483
  ]
484
 
485
 
486
+ def get_panel_by_panel_url(
487
+ panel_url: str,
488
+ *,
489
+ manga_title: Optional[str] = None,
490
+ chapter_number: Optional[float] = None,
491
+ mangadex_chapter_id: Optional[str] = None,
492
+ page_number: Optional[int] = None,
493
+ db_url=None,
494
+ ) -> Optional[PanelListOut]:
495
+ """Return the first matching panel for panel_url (or None)."""
496
+ url = (panel_url or "").strip()
497
+ if not url:
498
+ return None
499
+ engine = get_engine(db_url)
500
+ with Session(engine) as session:
501
+ stmt = (
502
+ select(
503
+ Panels.id,
504
+ Manga.manga_title,
505
+ Chapters.chapter_number,
506
+ Chapters.mangadex_chapter_id,
507
+ Panels.page_number,
508
+ Panels.bubble_index,
509
+ Panels.width,
510
+ Panels.height,
511
+ Panels.x1,
512
+ Panels.y1,
513
+ Panels.x2,
514
+ Panels.y2,
515
+ Panels.original_text,
516
+ Panels.translated_text,
517
+ Panels.panel_url,
518
+ Chapters.language_code,
519
+ Panels.created_at,
520
+ )
521
+ .select_from(Panels)
522
+ .join(Chapters, Panels.chapter_id == Chapters.id)
523
+ .join(Manga, Chapters.manga_id == Manga.id)
524
+ .where(Panels.panel_url == url)
525
+ )
526
+ if manga_title is not None:
527
+ stmt = stmt.where(Manga.manga_title == manga_title)
528
+ if chapter_number is not None:
529
+ stmt = stmt.where(Chapters.chapter_number == chapter_number)
530
+ if mangadex_chapter_id is not None:
531
+ v = mangadex_chapter_id.strip()
532
+ if v:
533
+ stmt = stmt.where(Chapters.mangadex_chapter_id == v)
534
+ if page_number is not None:
535
+ stmt = stmt.where(Panels.page_number == page_number)
536
+ stmt = stmt.order_by(
537
+ Chapters.chapter_number,
538
+ func.coalesce(Panels.page_number, 0),
539
+ Panels.bubble_index,
540
+ ).limit(1)
541
+ r = session.exec(stmt).first()
542
+ if not r:
543
+ return None
544
+ return PanelListOut(
545
+ id=r[0],
546
+ manga_title=r[1],
547
+ chapter_number=r[2],
548
+ mangadex_chapter_id=r[3],
549
+ page_number=r[4],
550
+ bubble_index=r[5],
551
+ width=r[6],
552
+ height=r[7],
553
+ x1=r[8],
554
+ y1=r[9],
555
+ x2=r[10],
556
+ y2=r[11],
557
+ original_text=r[12],
558
+ translated_text=r[13],
559
+ panel_url=r[14],
560
+ language_code=r[15],
561
+ created_at=r[16],
562
+ )
563
+
564
+
565
+ def delete_panel_by_panel_url(
566
+ panel_url: str,
567
+ *,
568
+ manga_title: Optional[str] = None,
569
+ chapter_number: Optional[float] = None,
570
+ mangadex_chapter_id: Optional[str] = None,
571
+ page_number: Optional[int] = None,
572
+ db_url=None,
573
+ ) -> int:
574
+ """Delete panel rows matching panel_url (optionally scoped).
575
+
576
+ Returns number of deleted rows.
577
+ """
578
+ url = (panel_url or "").strip()
579
+ if not url:
580
+ return 0
581
+ engine = get_engine(db_url)
582
+ with Session(engine) as session:
583
+ stmt = select(Panels).where(Panels.panel_url == url)
584
+ if (
585
+ manga_title is not None
586
+ or chapter_number is not None
587
+ or mangadex_chapter_id is not None
588
+ ):
589
+ stmt = stmt.join(Chapters, Panels.chapter_id == Chapters.id).join(
590
+ Manga, Chapters.manga_id == Manga.id
591
+ )
592
+ if manga_title is not None:
593
+ stmt = stmt.where(Manga.manga_title == manga_title)
594
+ if chapter_number is not None:
595
+ stmt = stmt.where(Chapters.chapter_number == chapter_number)
596
+ if mangadex_chapter_id is not None:
597
+ v = mangadex_chapter_id.strip()
598
+ if v:
599
+ stmt = stmt.where(Chapters.mangadex_chapter_id == v)
600
+ if page_number is not None:
601
+ stmt = stmt.where(Panels.page_number == page_number)
602
+
603
+ panels = session.exec(stmt).all()
604
+ if not panels:
605
+ return 0
606
+
607
+ chapter_ids = {p.chapter_id for p in panels}
608
+ for p in panels:
609
+ session.delete(p)
610
+ session.flush()
611
+
612
+ now = datetime.now(timezone.utc)
613
+ for cid in chapter_ids:
614
+ chapter = session.get(Chapters, cid)
615
+ if chapter:
616
+ chapter.updated_at = now
617
+ manga = session.get(Manga, chapter.manga_id)
618
+ if manga:
619
+ manga.updated_at = now
620
+ session.commit()
621
+ return len(panels)
622
+
623
+
624
+ def get_chapter_panels(
625
  manga_title: str,
626
  chapter_number: float,
627
  limit: Optional[int] = None,
628
  offset: int = 0,
629
  db_url=None,
630
+ ) -> list[PanelListOut]:
631
+ """Get all panels for a chapter. Supports limit/offset for pagination."""
632
+ return get_panels(
 
 
633
  manga_title=manga_title,
634
  chapter_number=chapter_number,
635
  limit=limit,
 
638
  )
639
 
640
 
641
+
642
  def list_chapters(
643
  manga_title: str,
 
644
  limit: Optional[int] = None,
645
  offset: int = 0,
646
  db_url=None,
647
  ) -> list[ChapterListOut]:
648
+ """List chapters for an umbrella manga_title."""
649
  engine = get_engine(db_url)
650
  with Session(engine) as session:
651
  stmt = (
652
+ select(
653
+ Manga.manga_title,
654
+ Chapters.id,
655
+ Chapters.chapter_number,
656
+ Chapters.mangadex_chapter_id,
657
+ Chapters.created_at,
658
+ Chapters.updated_at,
659
+ )
660
  .select_from(Chapters)
661
  .join(Manga, Chapters.manga_id == Manga.id)
662
  .where(Manga.manga_title == manga_title)
663
  )
 
 
 
664
  if offset > 0:
665
  stmt = stmt.offset(offset)
666
  if limit is not None and limit > 0:
 
668
  rows = session.exec(stmt).all()
669
  return [
670
  ChapterListOut(
671
+ manga_title=r[0],
672
+ id=r[1],
673
+ chapter_number=r[2],
674
+ mangadex_chapter_id=r[3],
675
+ created_at=r[4],
676
+ updated_at=r[5],
677
  )
678
  for r in rows
679
  ]
 
865
  if ids:
866
  rows = list(
867
  session.exec(
868
+ select(ReadingListItem, Manga)
869
  .join(Manga, ReadingListItem.manga_id == Manga.id)
 
 
 
 
 
870
  .where(ReadingListItem.reading_list_id.in_(ids))
871
  .order_by(
872
  ReadingListItem.reading_list_id,
 
874
  )
875
  ).all()
876
  )
877
+ for item, manga in rows:
878
  lid = item.reading_list_id
879
  if lid in latest_ext:
880
  continue
881
+ latest_ext[lid] = manga.mangadex_manga_id
 
 
 
 
 
 
 
 
 
882
  return [
883
  ReadingListCollectionOut(
884
  id=c.id,
 
905
  engine = get_engine(db_url)
906
  with Session(engine) as session:
907
  stmt = (
908
+ select(ReadingListItem, Manga)
909
  .join(Manga, ReadingListItem.manga_id == Manga.id)
 
 
 
 
 
910
  .where(ReadingListItem.reading_list_id == reading_list_id)
911
  .order_by(desc(ReadingListItem.updated_at))
912
  )
 
916
  stmt = stmt.limit(limit)
917
  rows = session.exec(stmt).all()
918
  out: list[ReadingListItemOut] = []
919
+ for item, manga in rows:
920
+ ext = manga.mangadex_manga_id
 
 
 
 
921
  out.append(
922
  ReadingListItemOut(
923
  id=item.id,
 
958
  row = session.exec(stmt).first()
959
  if row:
960
  if last_chapter_number is not None:
961
+ new_val = float(last_chapter_number)
962
+ prev = row.last_chapter_number
963
+ if prev is None or new_val > prev:
964
+ row.last_chapter_number = new_val
965
  row.updated_at = now
966
  session.add(row)
967
  col.updated_at = now
 
984
  return entry
985
 
986
 
987
+ def update_reading_list_item_last_read(
988
+ user_id: UUID,
989
+ reading_list_id: int,
990
+ manga_id: int,
991
+ last_chapter_number: float,
992
+ *,
993
+ db_url=None,
994
+ ) -> bool:
995
+ """Set last-read chapter for one list row (only increases stored value)."""
996
+ if get_reading_list_collection(user_id, reading_list_id, db_url) is None:
997
+ return False
998
+ engine = get_engine(db_url)
999
+ now = datetime.now(timezone.utc)
1000
+ new_val = float(last_chapter_number)
1001
+ with Session(engine) as session:
1002
+ row = session.exec(
1003
+ select(ReadingListItem).where(
1004
+ ReadingListItem.reading_list_id == reading_list_id,
1005
+ ReadingListItem.manga_id == manga_id,
1006
+ )
1007
+ ).first()
1008
+ if row is None:
1009
+ return False
1010
+ prev = row.last_chapter_number
1011
+ if prev is None or new_val > prev:
1012
+ row.last_chapter_number = new_val
1013
+ row.updated_at = now
1014
+ session.add(row)
1015
+ col = session.exec(
1016
+ select(ReadingListCollection).where(
1017
+ ReadingListCollection.id == reading_list_id,
1018
+ ReadingListCollection.user_id == user_id,
1019
+ )
1020
+ ).first()
1021
+ if col:
1022
+ col.updated_at = now
1023
+ session.add(col)
1024
+ session.commit()
1025
+ return True
1026
+
1027
+
1028
  def remove_reading_list_item_from_list(
1029
  user_id: UUID, reading_list_id: int, manga_id: int, db_url=None
1030
  ) -> bool:
 
1056
  def add_reading_list_item_by_source(
1057
  user_id: UUID,
1058
  reading_list_id: int,
 
1059
  external_manga_id: str,
1060
  manga_title: str,
1061
  *,
1062
  last_chapter_number: Optional[float] = None,
1063
  db_url=None,
1064
  ) -> ReadingListItem:
 
1065
  ext = (external_manga_id or "").strip()
1066
  title = (manga_title or "").strip() or "Untitled"
1067
  if not ext:
1068
  raise ValueError("external_manga_id is required")
1069
  engine = get_engine(db_url)
1070
  with Session(engine) as session:
1071
+ manga_id = resolve_manga_id(session, title, ext)
1072
  session.commit()
1073
  return upsert_reading_list_item(
1074
  user_id,
db/models.py CHANGED
@@ -7,7 +7,6 @@ User table: app profile keyed by Supabase auth user id (UUID)
7
 
8
  Manga table: stores manga metadata
9
  id: int, primary key
10
- provider_id: str
11
  manga_title: str
12
  created_at: datetime
13
 
@@ -17,9 +16,7 @@ Chapter table: stores manga chapter info
17
  page_number: int
18
  created_at: datetime
19
 
20
- Chapter table: chapter data scoped by provider (same umbrella manga can have chapters per source)
21
-
22
- Page / Segment tables: unchanged hierarchy under chapters
23
 
24
  ReadingListCollection: named list per user (e.g. "Want to read").
25
  ReadingListItem: one row per named list + umbrella manga (manga_id).
@@ -47,33 +44,21 @@ class Users(SQLModel, table=True):
47
 
48
 
49
  class Manga(SQLModel, table=True):
50
- """Umbrella series; use MangaSource for per-provider external ids."""
 
 
 
 
51
 
52
  __table_args__ = ({"extend_existing": True},)
53
 
54
  id: Optional[int] = Field(default=None, primary_key=True)
55
  manga_title: str = Field(index=True)
 
56
  created_at: Optional[datetime] = Field(default_factory=_utc_now)
57
  updated_at: Optional[datetime] = Field(default_factory=_utc_now)
58
 
59
 
60
- class MangaSource(SQLModel, table=True):
61
- """Links an umbrella manga to a provider catalog id (e.g. MangaDex UUID)."""
62
-
63
- __tablename__ = "manga_source"
64
- __table_args__ = (
65
- UniqueConstraint("provider_id", "external_manga_id", name="uq_manga_source_provider_external"),
66
- UniqueConstraint("manga_id", "provider_id", name="uq_manga_source_manga_provider"),
67
- {"extend_existing": True},
68
- )
69
-
70
- id: Optional[int] = Field(default=None, primary_key=True)
71
- manga_id: int = Field(foreign_key="manga.id", index=True)
72
- provider_id: str = Field(index=True)
73
- external_manga_id: str = Field(index=True) #if there is no external manga id, it will be the same as the manga_id with local in front
74
- created_at: Optional[datetime] = Field(default_factory=_utc_now)
75
-
76
-
77
  class ReadingListCollection(SQLModel, table=True):
78
  """User-owned named reading list (container for manga)."""
79
 
@@ -106,41 +91,39 @@ class ReadingListItem(SQLModel, table=True):
106
 
107
  class Chapters(SQLModel, table=True):
108
  __table_args__ = (
109
- UniqueConstraint("manga_id", "provider_id", "chapter_number", name="uq_chapters_manga_provider_chapter"),
110
  {"extend_existing": True},
111
  )
112
 
113
  id: Optional[int] = Field(default=None, primary_key=True)
114
  manga_id: int = Field(foreign_key="manga.id", index=True)
115
- provider_id: str = Field(index=True)
116
  chapter_number: float = Field(index=True)
 
 
117
  language_code: str
118
  created_at: Optional[datetime] = Field(default_factory=_utc_now)
119
  updated_at: Optional[datetime] = Field(default_factory=_utc_now)
120
 
121
 
122
- class Pages(SQLModel, table=True):
 
123
  __table_args__ = (
124
- UniqueConstraint("chapter_id", "page_number", name="uq_pages_chapter_page"),
125
  {"extend_existing": True},
126
  )
127
 
128
  id: Optional[int] = Field(default=None, primary_key=True)
129
  chapter_id: int = Field(foreign_key="chapters.id", index=True)
130
- page_number: int = Field(index=True)
131
- created_at: Optional[datetime] = Field(default_factory=_utc_now)
132
- updated_at: Optional[datetime] = Field(default_factory=_utc_now)
133
-
134
-
135
- class Segments(SQLModel, table=True):
136
- __table_args__ = (
137
- Index("ix_segments_page_segment", "page_id", "segment_index"),
138
- {"extend_existing": True},
139
- )
140
-
141
- id: Optional[int] = Field(default=None, primary_key=True)
142
- page_id: int = Field(foreign_key="pages.id", index=True)
143
- segment_index: int
144
  x1: float
145
  y1: float
146
  x2: float
 
7
 
8
  Manga table: stores manga metadata
9
  id: int, primary key
 
10
  manga_title: str
11
  created_at: datetime
12
 
 
16
  page_number: int
17
  created_at: datetime
18
 
19
+ Chapter table: chapter data (single-provider setup)
 
 
20
 
21
  ReadingListCollection: named list per user (e.g. "Want to read").
22
  ReadingListItem: one row per named list + umbrella manga (manga_id).
 
44
 
45
 
46
  class Manga(SQLModel, table=True):
47
+ """Umbrella series.
48
+
49
+ We keep MangaDex series id directly on this table (optional) since MangaDex
50
+ is the primary external provider used by the app.
51
+ """
52
 
53
  __table_args__ = ({"extend_existing": True},)
54
 
55
  id: Optional[int] = Field(default=None, primary_key=True)
56
  manga_title: str = Field(index=True)
57
+ mangadex_manga_id: Optional[str] = Field(default=None, index=True, unique=True)
58
  created_at: Optional[datetime] = Field(default_factory=_utc_now)
59
  updated_at: Optional[datetime] = Field(default_factory=_utc_now)
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  class ReadingListCollection(SQLModel, table=True):
63
  """User-owned named reading list (container for manga)."""
64
 
 
91
 
92
  class Chapters(SQLModel, table=True):
93
  __table_args__ = (
94
+ UniqueConstraint("manga_id", "chapter_number", name="uq_chapters_manga_chapter"),
95
  {"extend_existing": True},
96
  )
97
 
98
  id: Optional[int] = Field(default=None, primary_key=True)
99
  manga_id: int = Field(foreign_key="manga.id", index=True)
 
100
  chapter_number: float = Field(index=True)
101
+ # MangaDex chapter UUID (single-provider setup)
102
+ mangadex_chapter_id: Optional[str] = Field(default=None, index=True)
103
  language_code: str
104
  created_at: Optional[datetime] = Field(default_factory=_utc_now)
105
  updated_at: Optional[datetime] = Field(default_factory=_utc_now)
106
 
107
 
108
+ class Panels(SQLModel, table=True):
109
+ __tablename__ = "panels"
110
  __table_args__ = (
111
+ Index("ix_panels_chapter_page_bubble", "chapter_id", "page_number", "bubble_index"),
112
  {"extend_existing": True},
113
  )
114
 
115
  id: Optional[int] = Field(default=None, primary_key=True)
116
  chapter_id: int = Field(foreign_key="chapters.id", index=True)
117
+ page_number: Optional[int] = Field(default=None, index=True)
118
+ # Denormalized copy of MangaDex chapter UUID for convenience (optional).
119
+ mangadex_chapter_id: Optional[str] = Field(default=None, index=True)
120
+ # Optional URL for this panel's image (e.g. MangaDex at-home URL or proxied URL).
121
+ panel_url: Optional[str] = Field(default=None)
122
+ # bubble index within a page (0..N-1)
123
+ bubble_index: int
124
+ # page dimensions for this segment's coordinate space (optional; helpful for clients)
125
+ width: Optional[int] = Field(default=None)
126
+ height: Optional[int] = Field(default=None)
 
 
 
 
127
  x1: float
128
  y1: float
129
  x2: float
db/schemas.py CHANGED
@@ -17,12 +17,12 @@ class MangaOut(SQLModel):
17
 
18
 
19
  class ChapterListOut(SQLModel):
20
- """Chapter list response (includes manga_title, provider_id from chapter)."""
21
 
22
  manga_title: str
23
- provider_id: str
24
  id: int
25
  chapter_number: float
 
26
  created_at: Optional[datetime] = None
27
  updated_at: Optional[datetime] = None
28
 
@@ -66,28 +66,36 @@ class ReadingListItemOut(SQLModel):
66
 
67
 
68
  class ReadingListAddIn(SQLModel):
69
- """Add a title by provider catalog id (e.g. MangaDex UUID)."""
70
 
71
- provider_id: str = Field(default="mangadex")
72
  external_manga_id: str
73
  manga_title: str
74
  last_chapter_number: Optional[float] = None
75
 
76
 
77
- class SegmentListOut(SQLModel):
78
- """Segment list response (join of Segments + Pages + Chapters + Manga)."""
 
 
 
 
 
 
79
 
80
  id: int
81
- provider_id: str
82
  manga_title: str
83
  chapter_number: float
84
- page_number: int
85
- segment_index: int
 
 
 
86
  x1: float
87
  y1: float
88
  x2: float
89
  y2: float
90
  original_text: str
91
  translated_text: str
 
92
  language_code: str
93
  created_at: Optional[datetime] = None
 
17
 
18
 
19
  class ChapterListOut(SQLModel):
20
+ """Chapter list response (includes manga_title)."""
21
 
22
  manga_title: str
 
23
  id: int
24
  chapter_number: float
25
+ mangadex_chapter_id: Optional[str] = None
26
  created_at: Optional[datetime] = None
27
  updated_at: Optional[datetime] = None
28
 
 
66
 
67
 
68
  class ReadingListAddIn(SQLModel):
69
+ """Add a title by MangaDex series id (UUID)."""
70
 
 
71
  external_manga_id: str
72
  manga_title: str
73
  last_chapter_number: Optional[float] = None
74
 
75
 
76
+ class ReadingListProgressPatchIn(SQLModel):
77
+ """Update furthest chapter read for a title already on a list."""
78
+
79
+ last_chapter_number: float
80
+
81
+
82
+ class PanelListOut(SQLModel):
83
+ """Panel list response (join of Panels + Chapters + Manga)."""
84
 
85
  id: int
 
86
  manga_title: str
87
  chapter_number: float
88
+ page_number: Optional[int] = None
89
+ mangadex_chapter_id: Optional[str] = None
90
+ bubble_index: int
91
+ width: Optional[int] = None
92
+ height: Optional[int] = None
93
  x1: float
94
  y1: float
95
  x2: float
96
  y2: float
97
  original_text: str
98
  translated_text: str
99
+ panel_url: Optional[str] = None
100
  language_code: str
101
  created_at: Optional[datetime] = None
db/test_db.py CHANGED
@@ -32,84 +32,77 @@ class TestDb(unittest.TestCase):
32
 
33
  def setUp(self):
34
  """Clean up test data before each test."""
35
- db.delete_chapter_segments(db.PROVIDER_LOCAL, self.MANGA, self.CHAPTER)
36
 
37
  def tearDown(self):
38
- db.delete_chapter_segments(db.PROVIDER_LOCAL, self.MANGA, self.CHAPTER)
39
 
40
  def test_init_db(self):
41
  """init_db creates tables without error."""
42
  db.init_db()
43
 
44
- def test_save_and_get_segments(self):
45
- """save_page_translation stores segments; get_segments returns them."""
46
- db.save_page_translation(
47
- provider_id=db.PROVIDER_LOCAL,
48
  manga_title=self.MANGA,
49
  chapter_number=self.CHAPTER,
50
  page_number=self.PAGE,
51
- bubbles=_bubbles("こんにちは", "Hello"),
52
  language_code=self.LANG,
53
  )
54
- rows = db.get_segments(provider_id=db.PROVIDER_LOCAL, manga_title=self.MANGA, chapter_number=self.CHAPTER, page_number=self.PAGE)
55
  self.assertEqual(len(rows), 2)
56
  self.assertEqual(rows[0].translated_text, "Hello")
57
- self.assertEqual(rows[0].provider_id, db.PROVIDER_LOCAL)
58
  self.assertEqual(rows[0].manga_title, self.MANGA)
59
  self.assertEqual(rows[0].chapter_number, self.CHAPTER)
60
  self.assertEqual(rows[0].page_number, self.PAGE)
61
 
62
  def test_save_replaces_existing(self):
63
- """save_page_translation replaces existing segments for same page."""
64
- db.save_page_translation(
65
- provider_id=db.PROVIDER_LOCAL,
66
  manga_title=self.MANGA,
67
  chapter_number=self.CHAPTER,
68
  page_number=self.PAGE,
69
- bubbles=_bubbles("first", "First"),
70
  language_code=self.LANG,
71
  )
72
- db.save_page_translation(
73
- provider_id=db.PROVIDER_LOCAL,
74
  manga_title=self.MANGA,
75
  chapter_number=self.CHAPTER,
76
  page_number=self.PAGE,
77
- bubbles=_bubbles("second", "Second"),
78
  language_code=self.LANG,
79
  )
80
- rows = db.get_segments(provider_id=db.PROVIDER_LOCAL, manga_title=self.MANGA, chapter_number=self.CHAPTER, page_number=self.PAGE)
81
  self.assertEqual(len(rows), 2)
82
  self.assertEqual(rows[0].translated_text, "Second")
83
 
84
- def test_get_chapter_segments(self):
85
- """get_chapter_segments returns all segments for a chapter."""
86
- db.save_page_translation(
87
- provider_id=db.PROVIDER_LOCAL,
88
  manga_title=self.MANGA,
89
  chapter_number=self.CHAPTER,
90
  page_number=1,
91
- bubbles=_bubbles("a", "A"),
92
  language_code=self.LANG,
93
  )
94
- db.save_page_translation(
95
- provider_id=db.PROVIDER_LOCAL,
96
  manga_title=self.MANGA,
97
  chapter_number=self.CHAPTER,
98
  page_number=2,
99
- bubbles=_bubbles("b", "B"),
100
  language_code=self.LANG,
101
  )
102
- rows = db.get_chapter_segments(db.PROVIDER_LOCAL, self.MANGA, self.CHAPTER)
103
  self.assertEqual(len(rows), 4) # 2 bubbles per page, 2 pages
104
 
105
  def test_list_mangas(self):
106
  """list_mangas returns mangas; order_by and order_desc work."""
107
- db.save_page_translation(
108
- provider_id=db.PROVIDER_LOCAL,
109
  manga_title=self.MANGA,
110
  chapter_number=self.CHAPTER,
111
  page_number=self.PAGE,
112
- bubbles=_bubbles("x", "X"),
113
  language_code=self.LANG,
114
  )
115
  entries = db.list_mangas()
@@ -125,12 +118,11 @@ class TestDb(unittest.TestCase):
125
 
126
  def test_list_mangas_pagination(self):
127
  """list_mangas respects limit and offset."""
128
- db.save_page_translation(
129
- provider_id=db.PROVIDER_LOCAL,
130
  manga_title=self.MANGA,
131
  chapter_number=self.CHAPTER,
132
  page_number=self.PAGE,
133
- bubbles=_bubbles("x", "X"),
134
  language_code=self.LANG,
135
  )
136
  all_entries = db.list_mangas()
@@ -140,138 +132,117 @@ class TestDb(unittest.TestCase):
140
  self.assertEqual(len(offset_entries), 1)
141
 
142
  def test_list_chapters(self):
143
- """list_chapters returns chapters for a manga; optional provider_id filter."""
144
- db.save_page_translation(
145
- provider_id=db.PROVIDER_LOCAL,
146
  manga_title=self.MANGA,
147
  chapter_number=self.CHAPTER,
148
  page_number=self.PAGE,
149
- bubbles=_bubbles("x", "X"),
150
  language_code=self.LANG,
151
  )
152
  chapters = db.list_chapters(self.MANGA)
153
  found = [c for c in chapters if c.chapter_number == self.CHAPTER]
154
  self.assertGreater(len(found), 0)
155
  self.assertTrue(hasattr(found[0], "manga_title"))
156
- self.assertTrue(hasattr(found[0], "provider_id"))
157
  self.assertTrue(hasattr(found[0], "id"))
158
  self.assertTrue(hasattr(found[0], "chapter_number"))
159
  self.assertTrue(hasattr(found[0], "created_at"))
160
  self.assertTrue(hasattr(found[0], "updated_at"))
161
  self.assertEqual(found[0].manga_title, self.MANGA)
162
- self.assertEqual(found[0].provider_id, db.PROVIDER_LOCAL)
163
 
164
- chapters_with_provider = db.list_chapters(self.MANGA, provider_id=db.PROVIDER_LOCAL)
165
- self.assertGreater(len(chapters_with_provider), 0)
166
 
167
  def test_list_chapters_pagination(self):
168
  """list_chapters respects limit and offset."""
169
- db.save_page_translation(
170
- provider_id=db.PROVIDER_LOCAL,
171
  manga_title=self.MANGA,
172
  chapter_number=self.CHAPTER,
173
  page_number=self.PAGE,
174
- bubbles=_bubbles("x", "X"),
175
  language_code=self.LANG,
176
  )
177
- all_chapters = db.list_chapters(self.MANGA, provider_id=db.PROVIDER_LOCAL)
178
- limited = db.list_chapters(self.MANGA, provider_id=db.PROVIDER_LOCAL, limit=1)
179
  self.assertEqual(len(limited), 1)
180
- offset_chapters = db.list_chapters(self.MANGA, provider_id=db.PROVIDER_LOCAL, limit=1, offset=0)
181
  self.assertEqual(len(offset_chapters), 1)
182
 
183
- def test_get_segments_pagination(self):
184
- """get_segments respects limit and offset."""
185
- db.save_page_translation(
186
- provider_id=db.PROVIDER_LOCAL,
187
  manga_title=self.MANGA,
188
  chapter_number=self.CHAPTER,
189
  page_number=self.PAGE,
190
- bubbles=_bubbles("a", "A"),
191
  language_code=self.LANG,
192
  )
193
- all_rows = db.get_segments(provider_id=db.PROVIDER_LOCAL, manga_title=self.MANGA, chapter_number=self.CHAPTER)
194
  self.assertEqual(len(all_rows), 2)
195
- limited = db.get_segments(provider_id=db.PROVIDER_LOCAL, manga_title=self.MANGA, chapter_number=self.CHAPTER, limit=1)
196
  self.assertEqual(len(limited), 1)
197
- offset_rows = db.get_segments(provider_id=db.PROVIDER_LOCAL, manga_title=self.MANGA, chapter_number=self.CHAPTER, limit=1, offset=1)
198
  self.assertEqual(len(offset_rows), 1)
199
  self.assertEqual(offset_rows[0].translated_text, "A 2")
200
 
201
- def test_delete_page_segments(self):
202
- """delete_page_segments removes page and its segments."""
203
- db.save_page_translation(
204
- provider_id=db.PROVIDER_LOCAL,
205
  manga_title=self.MANGA,
206
  chapter_number=self.CHAPTER,
207
  page_number=self.PAGE,
208
- bubbles=_bubbles("x", "X"),
209
  language_code=self.LANG,
210
  )
211
- db.delete_page_segments(db.PROVIDER_LOCAL, self.MANGA, self.CHAPTER, self.PAGE)
212
- rows = db.get_segments(provider_id=db.PROVIDER_LOCAL, manga_title=self.MANGA, chapter_number=self.CHAPTER, page_number=self.PAGE)
213
  self.assertEqual(len(rows), 0)
214
 
215
- def test_delete_chapter_segments(self):
216
- """delete_chapter_segments removes chapter and all its pages/segments."""
217
- db.save_page_translation(
218
- provider_id=db.PROVIDER_LOCAL,
219
  manga_title=self.MANGA,
220
  chapter_number=self.CHAPTER,
221
  page_number=self.PAGE,
222
- bubbles=_bubbles("x", "X"),
223
  language_code=self.LANG,
224
  )
225
- db.delete_chapter_segments(db.PROVIDER_LOCAL, self.MANGA, self.CHAPTER)
226
- rows = db.get_chapter_segments(db.PROVIDER_LOCAL, self.MANGA, self.CHAPTER)
227
  self.assertEqual(len(rows), 0)
228
 
229
- def test_provider_id_validation_save(self):
230
- """save_page_translation raises ValueError for invalid provider_id."""
231
- with self.assertRaises(ValueError) as ctx:
232
- db.save_page_translation(
233
- provider_id="invalid_provider",
234
- manga_title=self.MANGA,
235
- chapter_number=self.CHAPTER,
236
- page_number=self.PAGE,
237
- bubbles=_bubbles("x", "X"),
238
- language_code=self.LANG,
239
- )
240
- self.assertIn("provider_id must be one of", str(ctx.exception))
241
-
242
- def test_provider_id_validation_get_chapter_segments(self):
243
- """get_chapter_segments raises ValueError for invalid provider_id."""
244
- with self.assertRaises(ValueError):
245
- db.get_chapter_segments("invalid_provider", self.MANGA, self.CHAPTER)
246
 
247
  def test_provider_id_validation_delete_page(self):
248
- """delete_page_segments raises ValueError for invalid provider_id."""
249
- with self.assertRaises(ValueError):
250
- db.delete_page_segments("invalid_provider", self.MANGA, self.CHAPTER, self.PAGE)
251
 
252
  def test_provider_id_validation_delete_chapter(self):
253
- """delete_chapter_segments raises ValueError for invalid provider_id."""
254
- with self.assertRaises(ValueError):
255
- db.delete_chapter_segments("invalid_provider", self.MANGA, self.CHAPTER)
256
-
257
- def test_provider_id_validation_get_segments(self):
258
- """get_segments raises ValueError when provider_id is invalid."""
259
- with self.assertRaises(ValueError):
260
- db.get_segments(provider_id="invalid_provider")
261
 
262
  def test_provider_mangadex(self):
263
- """PROVIDER_MANGADEX is accepted."""
264
- db.save_page_translation(
265
- provider_id=db.PROVIDER_MANGADEX,
266
  manga_title=self.MANGA,
267
  chapter_number=self.CHAPTER,
268
  page_number=self.PAGE,
269
- bubbles=_bubbles("m", "M"),
270
  language_code=self.LANG,
271
  )
272
- rows = db.get_segments(provider_id=db.PROVIDER_MANGADEX, manga_title=self.MANGA, chapter_number=self.CHAPTER)
273
  self.assertEqual(len(rows), 2)
274
- db.delete_chapter_segments(db.PROVIDER_MANGADEX, self.MANGA, self.CHAPTER)
275
 
276
 
277
  class TestDbNoUrl(unittest.TestCase):
 
32
 
33
  def setUp(self):
34
  """Clean up test data before each test."""
35
+ db.delete_chapter_panels(self.MANGA, self.CHAPTER)
36
 
37
  def tearDown(self):
38
+ db.delete_chapter_panels(self.MANGA, self.CHAPTER)
39
 
40
  def test_init_db(self):
41
  """init_db creates tables without error."""
42
  db.init_db()
43
 
44
+ def test_save_and_get_panels(self):
45
+ """save_panels_translation stores panels; get_panels returns them."""
46
+ db.save_panels_translation(
 
47
  manga_title=self.MANGA,
48
  chapter_number=self.CHAPTER,
49
  page_number=self.PAGE,
50
+ panels=_bubbles("こんにちは", "Hello"),
51
  language_code=self.LANG,
52
  )
53
+ rows = db.get_panels(manga_title=self.MANGA, chapter_number=self.CHAPTER, page_number=self.PAGE)
54
  self.assertEqual(len(rows), 2)
55
  self.assertEqual(rows[0].translated_text, "Hello")
 
56
  self.assertEqual(rows[0].manga_title, self.MANGA)
57
  self.assertEqual(rows[0].chapter_number, self.CHAPTER)
58
  self.assertEqual(rows[0].page_number, self.PAGE)
59
 
60
  def test_save_replaces_existing(self):
61
+ """save_panels_translation replaces existing panels for same page."""
62
+ db.save_panels_translation(
 
63
  manga_title=self.MANGA,
64
  chapter_number=self.CHAPTER,
65
  page_number=self.PAGE,
66
+ panels=_bubbles("first", "First"),
67
  language_code=self.LANG,
68
  )
69
+ db.save_panels_translation(
 
70
  manga_title=self.MANGA,
71
  chapter_number=self.CHAPTER,
72
  page_number=self.PAGE,
73
+ panels=_bubbles("second", "Second"),
74
  language_code=self.LANG,
75
  )
76
+ rows = db.get_panels(manga_title=self.MANGA, chapter_number=self.CHAPTER, page_number=self.PAGE)
77
  self.assertEqual(len(rows), 2)
78
  self.assertEqual(rows[0].translated_text, "Second")
79
 
80
+ def test_get_chapter_panels(self):
81
+ """get_chapter_panels returns all panels for a chapter."""
82
+ db.save_panels_translation(
 
83
  manga_title=self.MANGA,
84
  chapter_number=self.CHAPTER,
85
  page_number=1,
86
+ panels=_bubbles("a", "A"),
87
  language_code=self.LANG,
88
  )
89
+ db.save_panels_translation(
 
90
  manga_title=self.MANGA,
91
  chapter_number=self.CHAPTER,
92
  page_number=2,
93
+ panels=_bubbles("b", "B"),
94
  language_code=self.LANG,
95
  )
96
+ rows = db.get_chapter_panels(self.MANGA, self.CHAPTER)
97
  self.assertEqual(len(rows), 4) # 2 bubbles per page, 2 pages
98
 
99
  def test_list_mangas(self):
100
  """list_mangas returns mangas; order_by and order_desc work."""
101
+ db.save_panels_translation(
 
102
  manga_title=self.MANGA,
103
  chapter_number=self.CHAPTER,
104
  page_number=self.PAGE,
105
+ panels=_bubbles("x", "X"),
106
  language_code=self.LANG,
107
  )
108
  entries = db.list_mangas()
 
118
 
119
  def test_list_mangas_pagination(self):
120
  """list_mangas respects limit and offset."""
121
+ db.save_panels_translation(
 
122
  manga_title=self.MANGA,
123
  chapter_number=self.CHAPTER,
124
  page_number=self.PAGE,
125
+ panels=_bubbles("x", "X"),
126
  language_code=self.LANG,
127
  )
128
  all_entries = db.list_mangas()
 
132
  self.assertEqual(len(offset_entries), 1)
133
 
134
  def test_list_chapters(self):
135
+ """list_chapters returns chapters for a manga."""
136
+ db.save_panels_translation(
 
137
  manga_title=self.MANGA,
138
  chapter_number=self.CHAPTER,
139
  page_number=self.PAGE,
140
+ panels=_bubbles("x", "X"),
141
  language_code=self.LANG,
142
  )
143
  chapters = db.list_chapters(self.MANGA)
144
  found = [c for c in chapters if c.chapter_number == self.CHAPTER]
145
  self.assertGreater(len(found), 0)
146
  self.assertTrue(hasattr(found[0], "manga_title"))
147
+ # single-provider setup: no provider_id stored on chapters
148
  self.assertTrue(hasattr(found[0], "id"))
149
  self.assertTrue(hasattr(found[0], "chapter_number"))
150
  self.assertTrue(hasattr(found[0], "created_at"))
151
  self.assertTrue(hasattr(found[0], "updated_at"))
152
  self.assertEqual(found[0].manga_title, self.MANGA)
153
+ # provider removed
154
 
155
+ self.assertGreater(len(chapters), 0)
 
156
 
157
  def test_list_chapters_pagination(self):
158
  """list_chapters respects limit and offset."""
159
+ db.save_panels_translation(
 
160
  manga_title=self.MANGA,
161
  chapter_number=self.CHAPTER,
162
  page_number=self.PAGE,
163
+ panels=_bubbles("x", "X"),
164
  language_code=self.LANG,
165
  )
166
+ all_chapters = db.list_chapters(self.MANGA)
167
+ limited = db.list_chapters(self.MANGA, limit=1)
168
  self.assertEqual(len(limited), 1)
169
+ offset_chapters = db.list_chapters(self.MANGA, limit=1, offset=0)
170
  self.assertEqual(len(offset_chapters), 1)
171
 
172
+ def test_get_panels_pagination(self):
173
+ """get_panels respects limit and offset."""
174
+ db.save_panels_translation(
 
175
  manga_title=self.MANGA,
176
  chapter_number=self.CHAPTER,
177
  page_number=self.PAGE,
178
+ panels=_bubbles("a", "A"),
179
  language_code=self.LANG,
180
  )
181
+ all_rows = db.get_panels(manga_title=self.MANGA, chapter_number=self.CHAPTER)
182
  self.assertEqual(len(all_rows), 2)
183
+ limited = db.get_panels(manga_title=self.MANGA, chapter_number=self.CHAPTER, limit=1)
184
  self.assertEqual(len(limited), 1)
185
+ offset_rows = db.get_panels(manga_title=self.MANGA, chapter_number=self.CHAPTER, limit=1, offset=1)
186
  self.assertEqual(len(offset_rows), 1)
187
  self.assertEqual(offset_rows[0].translated_text, "A 2")
188
 
189
+ def test_delete_page_panels(self):
190
+ """delete_page_panels removes panels for one page."""
191
+ db.save_panels_translation(
 
192
  manga_title=self.MANGA,
193
  chapter_number=self.CHAPTER,
194
  page_number=self.PAGE,
195
+ panels=_bubbles("x", "X"),
196
  language_code=self.LANG,
197
  )
198
+ db.delete_page_panels(self.MANGA, self.CHAPTER, self.PAGE)
199
+ rows = db.get_panels(manga_title=self.MANGA, chapter_number=self.CHAPTER, page_number=self.PAGE)
200
  self.assertEqual(len(rows), 0)
201
 
202
+ def test_delete_chapter_panels(self):
203
+ """delete_chapter_panels removes chapter and all its panels."""
204
+ db.save_panels_translation(
 
205
  manga_title=self.MANGA,
206
  chapter_number=self.CHAPTER,
207
  page_number=self.PAGE,
208
+ panels=_bubbles("x", "X"),
209
  language_code=self.LANG,
210
  )
211
+ db.delete_chapter_panels(self.MANGA, self.CHAPTER)
212
+ rows = db.get_chapter_panels(self.MANGA, self.CHAPTER)
213
  self.assertEqual(len(rows), 0)
214
 
215
+ def test_save_requires_no_provider_id(self):
216
+ """save_panels_translation does not require a provider_id."""
217
+ db.save_panels_translation(
218
+ manga_title=self.MANGA,
219
+ chapter_number=self.CHAPTER,
220
+ page_number=self.PAGE,
221
+ panels=_bubbles("x", "X"),
222
+ language_code=self.LANG,
223
+ )
 
 
 
 
 
 
 
 
224
 
225
  def test_provider_id_validation_delete_page(self):
226
+ """delete_page_panels deletes without a provider_id."""
227
+ # No exception expected; function no longer takes a provider.
228
+ db.delete_page_panels(self.MANGA, self.CHAPTER, self.PAGE)
229
 
230
  def test_provider_id_validation_delete_chapter(self):
231
+ """delete_chapter_panels deletes without a provider_id."""
232
+ db.delete_chapter_panels(self.MANGA, self.CHAPTER)
 
 
 
 
 
 
233
 
234
  def test_provider_mangadex(self):
235
+ """Basic provider-less flow works."""
236
+ db.save_panels_translation(
 
237
  manga_title=self.MANGA,
238
  chapter_number=self.CHAPTER,
239
  page_number=self.PAGE,
240
+ panels=_bubbles("m", "M"),
241
  language_code=self.LANG,
242
  )
243
+ rows = db.get_panels(manga_title=self.MANGA, chapter_number=self.CHAPTER)
244
  self.assertEqual(len(rows), 2)
245
+ db.delete_chapter_panels(self.MANGA, self.CHAPTER)
246
 
247
 
248
  class TestDbNoUrl(unittest.TestCase):
db/test_functions/create_data.py CHANGED
@@ -3,27 +3,45 @@ Insert sample data into the database for testing.
3
  Run from backend: python -m db.test_functions.create_data
4
  Requires DATABASE_URL in backend/.env.
5
 
6
- Uses umbrella Manga + MangaSource + Chapters (per provider_id). Optional external_manga_id
7
- on save_page_translation links a provider catalog id (e.g. MangaDex UUID).
8
  """
9
 
10
  import db
11
- from db.const import PROVIDER_LOCAL, PROVIDER_MANGADEX
12
 
13
  # Demo external ids (MangaDex uses UUID strings; local can use legacy-* from DB or omit)
14
  NARUTO_MANGADEX_ID = "931b3112-7b75-43e4-a13f-908f0a251ae2" # example placeholder UUID
15
 
16
 
17
- def _bubbles(*pairs: tuple[str, str]) -> list[dict]:
18
- """Create fake text segments from (original, translated) pairs."""
 
 
 
 
 
 
 
 
 
 
 
19
  result = []
20
  for i, (orig, trans) in enumerate(pairs):
21
  result.append({
22
  "bubble_index": i,
23
- "x1": 10.0 + i * 5,
24
- "y1": 20.0 + i * 40,
25
- "x2": 100.0,
26
- "y2": 50.0 + i * 40,
 
 
 
 
 
 
 
 
27
  "original_text": orig,
28
  "translated_text": trans,
29
  })
@@ -33,39 +51,57 @@ def _bubbles(*pairs: tuple[str, str]) -> list[dict]:
33
  def seed():
34
  db.init_db()
35
 
36
- # Umbrella manga 1: One Piece — local files only (no API id → omit external_manga_id, DB uses legacy-*)
37
- db.save_page_translation(
38
- provider_id=PROVIDER_LOCAL,
39
  manga_title="One Piece",
40
  chapter_number=1.0,
41
  page_number=1,
42
- bubbles=_bubbles(("海賊王に俺はなる!", "I'm gonna be King of the Pirates!")),
 
 
 
 
 
43
  language_code="en",
44
  )
45
- db.save_page_translation(
46
- provider_id=PROVIDER_LOCAL,
47
  manga_title="One Piece",
48
  chapter_number=1.0,
49
  page_number=2,
50
- bubbles=_bubbles(("麦わらのルフィ", "Monkey D. Luffy"), ("ゼファ", "Zeff")),
 
 
 
 
 
 
51
  language_code="en",
52
  )
53
- db.save_page_translation(
54
- provider_id=PROVIDER_LOCAL,
55
  manga_title="One Piece",
56
  chapter_number=2.0,
57
  page_number=1,
58
- bubbles=_bubbles(("冒険の始まり", "The adventure begins")),
 
 
 
 
 
59
  language_code="en",
60
  )
61
 
62
- # Umbrella manga 2: Naruto — MangaDex catalog id stored in manga_source via external_manga_id
63
- db.save_page_translation(
64
- provider_id=PROVIDER_MANGADEX,
65
  manga_title="Naruto",
66
  chapter_number=1.0,
67
  page_number=1,
68
- bubbles=_bubbles(("忍たま乱太郎", "Ninja Academy"), ("うずまきナルト", "Uzumaki Naruto")),
 
 
 
 
 
 
69
  language_code="en",
70
  external_manga_id=NARUTO_MANGADEX_ID,
71
  )
@@ -79,26 +115,24 @@ def seed():
79
  print(f" id={m.id} title={m.manga_title!r} updated_at={m.updated_at}")
80
 
81
  # --- Chapters are scoped by provider: same title can exist per source ---
82
- print("\n=== list_chapters('One Piece', provider_id=local) ===")
83
- for c in db.list_chapters("One Piece", provider_id=PROVIDER_LOCAL):
84
- print(f" ch.id={c.id} provider={c.provider_id!r} ch={c.chapter_number} updated={c.updated_at}")
85
-
86
- print("\n=== list_chapters('Naruto', provider_id=mangadex) ===")
87
- for c in db.list_chapters("Naruto", provider_id=PROVIDER_MANGADEX):
88
- print(f" ch.id={c.id} provider={c.provider_id!r} ch={c.chapter_number} updated={c.updated_at}")
89
-
90
- # Resolve umbrella id from provider + external catalog id
91
- mid = db.resolve_manga_id_by_source(PROVIDER_MANGADEX, NARUTO_MANGADEX_ID)
92
- print("\n=== resolve_manga_id_by_source(mangadex, external_manga_id) ===")
93
- print(f" umbrella manga_id for Naruto sample UUID: {mid}")
94
-
95
- # Sample segment query (provider on chapter)
96
- segs = db.get_chapter_segments(PROVIDER_LOCAL, "One Piece", 1.0)
97
- print("\n=== get_chapter_segments(local, 'One Piece', 1.0) ===")
98
- print(f" segment count: {len(segs)}")
99
- if segs:
100
- s0 = segs[0]
101
- print(f" first: provider_id={s0.provider_id!r} page={s0.page_number} translated={s0.translated_text!r}")
102
 
103
 
104
  if __name__ == "__main__":
 
3
  Run from backend: python -m db.test_functions.create_data
4
  Requires DATABASE_URL in backend/.env.
5
 
6
+ Uses umbrella Manga + Chapters. Optional external_manga_id (MangaDex series id)
7
+ on save_panels_translation stores MangaDex series id.
8
  """
9
 
10
  import db
 
11
 
12
  # Demo external ids (MangaDex uses UUID strings; local can use legacy-* from DB or omit)
13
  NARUTO_MANGADEX_ID = "931b3112-7b75-43e4-a13f-908f0a251ae2" # example placeholder UUID
14
 
15
 
16
+ def _slug(title: str) -> str:
17
+ s = title.strip().lower().replace(" ", "_")
18
+ return "".join(ch for ch in s if ch.isalnum() or ch == "_")
19
+
20
+
21
+ def _bubbles(
22
+ manga_title: str,
23
+ chapter_number: float,
24
+ page_number: int,
25
+ *pairs: tuple[str, str],
26
+ ) -> list[dict]:
27
+ """Create fake panels from (original, translated) pairs."""
28
+ slug = _slug(manga_title)
29
  result = []
30
  for i, (orig, trans) in enumerate(pairs):
31
  result.append({
32
  "bubble_index": i,
33
+ # Page coordinate space (example values)
34
+ "width": 1080,
35
+ "height": 1522,
36
+ # Globally unique demo URLs (matches global uniqueness on panel_url).
37
+ "panel_url": (
38
+ "https://example.invalid/panels/"
39
+ f"{slug}/ch{chapter_number:g}/p{page_number}/b{i}.jpg"
40
+ ),
41
+ "x1": 120.0 + i * 18,
42
+ "y1": 140.0 + i * 55,
43
+ "x2": 420.0 + i * 18,
44
+ "y2": 260.0 + i * 55,
45
  "original_text": orig,
46
  "translated_text": trans,
47
  })
 
51
  def seed():
52
  db.init_db()
53
 
54
+ # Umbrella manga 1: One Piece — MangaDex provider (example seed)
55
+ db.save_panels_translation(
 
56
  manga_title="One Piece",
57
  chapter_number=1.0,
58
  page_number=1,
59
+ panels=_bubbles(
60
+ "One Piece",
61
+ 1.0,
62
+ 1,
63
+ ("海賊王に俺はなる!", "I'm gonna be King of the Pirates!"),
64
+ ),
65
  language_code="en",
66
  )
67
+ db.save_panels_translation(
 
68
  manga_title="One Piece",
69
  chapter_number=1.0,
70
  page_number=2,
71
+ panels=_bubbles(
72
+ "One Piece",
73
+ 1.0,
74
+ 2,
75
+ ("麦わらのルフィ", "Monkey D. Luffy"),
76
+ ("ゼファ", "Zeff"),
77
+ ),
78
  language_code="en",
79
  )
80
+ db.save_panels_translation(
 
81
  manga_title="One Piece",
82
  chapter_number=2.0,
83
  page_number=1,
84
+ panels=_bubbles(
85
+ "One Piece",
86
+ 2.0,
87
+ 1,
88
+ ("冒険の始まり", "The adventure begins"),
89
+ ),
90
  language_code="en",
91
  )
92
 
93
+ # Umbrella manga 2: Naruto — MangaDex series id stored on Manga via external_manga_id
94
+ db.save_panels_translation(
 
95
  manga_title="Naruto",
96
  chapter_number=1.0,
97
  page_number=1,
98
+ panels=_bubbles(
99
+ "Naruto",
100
+ 1.0,
101
+ 1,
102
+ ("忍たま乱太郎", "Ninja Academy"),
103
+ ("うずまきナルト", "Uzumaki Naruto"),
104
+ ),
105
  language_code="en",
106
  external_manga_id=NARUTO_MANGADEX_ID,
107
  )
 
115
  print(f" id={m.id} title={m.manga_title!r} updated_at={m.updated_at}")
116
 
117
  # --- Chapters are scoped by provider: same title can exist per source ---
118
+ print("\n=== list_chapters('One Piece') ===")
119
+ for c in db.list_chapters("One Piece"):
120
+ print(f" ch.id={c.id} ch={c.chapter_number} updated={c.updated_at}")
121
+
122
+ print("\n=== list_chapters('Naruto') ===")
123
+ for c in db.list_chapters("Naruto"):
124
+ print(f" ch.id={c.id} ch={c.chapter_number} updated={c.updated_at}")
125
+
126
+ print("\n=== MangaDex external_manga_id mapping ===")
127
+ print(" Stored on manga.mangadex_manga_id")
128
+
129
+ # Sample panel query
130
+ panels = db.get_chapter_panels("One Piece", 1.0)
131
+ print("\n=== get_chapter_panels('One Piece', 1.0) ===")
132
+ print(f" panel count: {len(panels)}")
133
+ if panels:
134
+ p0 = panels[0]
135
+ print(f" first: page={p0.page_number} translated={p0.translated_text!r}")
 
 
136
 
137
 
138
  if __name__ == "__main__":
db/test_functions/delete_data.py CHANGED
@@ -10,18 +10,18 @@ import db
10
  def clear_seed_data():
11
  """Remove the manga chapters inserted by create_data.py."""
12
  to_delete = [
13
- (db.PROVIDER_LOCAL, "One Piece", 1.0),
14
- (db.PROVIDER_LOCAL, "One Piece", 2.0),
15
- (db.PROVIDER_MANGADEX, "Naruto", 1.0),
16
  ]
17
- for provider_id, manga_title, chapter_number in to_delete:
18
- db.delete_chapter_segments(provider_id, manga_title, chapter_number)
19
- print(f"Deleted {provider_id} | {manga_title} ch.{chapter_number}")
20
  print("Seed data cleared.")
21
 
22
 
23
  def clear_all():
24
- """Delete all segments, pages, chapters, reading list, manga_source, and umbrella manga."""
25
  db.delete_all_manga()
26
  print("Cleared all manga and related data.")
27
 
 
10
  def clear_seed_data():
11
  """Remove the manga chapters inserted by create_data.py."""
12
  to_delete = [
13
+ ("One Piece", 1.0),
14
+ ("One Piece", 2.0),
15
+ ("Naruto", 1.0),
16
  ]
17
+ for manga_title, chapter_number in to_delete:
18
+ db.delete_chapter_panels(manga_title, chapter_number)
19
+ print(f"Deleted {manga_title} ch.{chapter_number}")
20
  print("Seed data cleared.")
21
 
22
 
23
  def clear_all():
24
+ """Delete all panels, chapters, reading list, and umbrella manga."""
25
  db.delete_all_manga()
26
  print("Cleared all manga and related data.")
27
 
db/test_functions/query_data.py CHANGED
@@ -11,7 +11,7 @@ def run():
11
  print("=== list_mangas (default: newest first) ===\n")
12
  mangas = db.list_mangas()
13
  for m in mangas:
14
- print(f" {m.provider_id} | {m.manga_title}")
15
  print(f"\n Total: {len(mangas)} manga(s)\n")
16
 
17
  if not mangas:
@@ -19,46 +19,64 @@ def run():
19
  return
20
 
21
  first_manga = mangas[0]
22
- provider_id = first_manga.provider_id
23
  manga_title = first_manga.manga_title
24
 
25
- chapters = db.list_chapters(manga_title, provider_id=provider_id)
26
  if not chapters:
27
  print(f"No chapters for {manga_title}. Run create_data to add pages.")
28
  return
29
  chapter_number = chapters[0].chapter_number
30
 
31
- print(f"=== list_chapters({manga_title!r}, provider_id={provider_id!r}) ===\n")
32
  for c in chapters[:5]:
33
  print(f" ch.{c.chapter_number} (id={c.id})")
34
  if len(chapters) > 5:
35
  print(f" ... and {len(chapters) - 5} more chapter(s)")
36
  print(f"\n Total: {len(chapters)} chapter(s)\n")
37
 
38
- print(f"=== get_chapter_segments({provider_id!r}, {manga_title!r}, {chapter_number}) ===\n")
39
- segments = db.get_chapter_segments(provider_id, manga_title, chapter_number)
40
- for i, s in enumerate(segments[:5]): # show first 5
41
- print(f" [{i+1}] page {s.page_number} seg {s.segment_index}: {s.original_text[:30]!r} → {s.translated_text[:30]!r}")
42
- if len(segments) > 5:
43
- print(f" ... and {len(segments) - 5} more segment(s)")
44
- print(f"\n Total: {len(segments)} segment(s) in chapter\n")
 
 
 
 
45
 
46
- print(f"=== get_segments (provider_id={provider_id!r}, manga_title={manga_title!r}, chapter_number={chapter_number}, page_number=1) ===\n")
47
- page_segments = db.get_segments(
48
- provider_id=provider_id,
 
 
 
 
 
 
 
 
 
 
 
 
49
  manga_title=manga_title,
50
  chapter_number=chapter_number,
51
  page_number=1,
52
  )
53
- for s in page_segments:
54
- print(f" seg {s.segment_index}: {s.translated_text!r}")
55
- print(f"\n Total: {len(page_segments)} segment(s) on page 1\n")
56
 
57
- print("=== get_segments (no filters: all segments) ===\n")
58
- all_segments = db.get_segments()
59
- for s in all_segments:
60
- print(f" {s.provider_id} | {s.manga_title} ch.{s.chapter_number} | {s.page_number} | {s.segment_index}: {s.translated_text!r}")
61
- print(f"\n Total: {len(all_segments)} segment(s)\n")
 
 
 
62
 
63
  if __name__ == "__main__":
64
  run()
 
11
  print("=== list_mangas (default: newest first) ===\n")
12
  mangas = db.list_mangas()
13
  for m in mangas:
14
+ print(f" {m.manga_title}")
15
  print(f"\n Total: {len(mangas)} manga(s)\n")
16
 
17
  if not mangas:
 
19
  return
20
 
21
  first_manga = mangas[0]
 
22
  manga_title = first_manga.manga_title
23
 
24
+ chapters = db.list_chapters(manga_title)
25
  if not chapters:
26
  print(f"No chapters for {manga_title}. Run create_data to add pages.")
27
  return
28
  chapter_number = chapters[0].chapter_number
29
 
30
+ print(f"=== list_chapters({manga_title!r}) ===\n")
31
  for c in chapters[:5]:
32
  print(f" ch.{c.chapter_number} (id={c.id})")
33
  if len(chapters) > 5:
34
  print(f" ... and {len(chapters) - 5} more chapter(s)")
35
  print(f"\n Total: {len(chapters)} chapter(s)\n")
36
 
37
+ print(f"=== get_chapter_panels({manga_title!r}, {chapter_number}) ===\n")
38
+ panels = db.get_chapter_panels(manga_title, chapter_number)
39
+ for i, s in enumerate(panels[:5]): # show first 5
40
+ print(
41
+ f" [{i+1}] page {s.page_number} bubble {s.bubble_index}: "
42
+ f"{s.original_text[:30]!r} {s.translated_text[:30]!r} "
43
+ f"(url={s.panel_url!r})"
44
+ )
45
+ if len(panels) > 5:
46
+ print(f" ... and {len(panels) - 5} more panel(s)")
47
+ print(f"\n Total: {len(panels)} panel(s) in chapter\n")
48
 
49
+ if panels and panels[0].panel_url:
50
+ url = panels[0].panel_url
51
+ print(f"=== get_panel_by_panel_url({url!r}) ===\n")
52
+ p = db.get_panel_by_panel_url(url)
53
+ if p:
54
+ print(
55
+ f" found: {p.manga_title} ch.{p.chapter_number} "
56
+ f"page={p.page_number} bubble={p.bubble_index} url={p.panel_url!r}"
57
+ )
58
+ else:
59
+ print(" not found")
60
+ print()
61
+
62
+ print(f"=== get_panels (manga_title={manga_title!r}, chapter_number={chapter_number}, page_number=1) ===\n")
63
+ page_panels = db.get_panels(
64
  manga_title=manga_title,
65
  chapter_number=chapter_number,
66
  page_number=1,
67
  )
68
+ for s in page_panels:
69
+ print(f" bubble {s.bubble_index}: {s.translated_text!r}")
70
+ print(f"\n Total: {len(page_panels)} panel(s) on page 1\n")
71
 
72
+ print("=== get_panels (no filters: all panels) ===\n")
73
+ all_panels = db.get_panels()
74
+ for s in all_panels:
75
+ print(
76
+ f" {s.manga_title} ch.{s.chapter_number} | {s.page_number} | "
77
+ f"{s.bubble_index}: {s.translated_text!r} (url={s.panel_url!r})"
78
+ )
79
+ print(f"\n Total: {len(all_panels)} panel(s)\n")
80
 
81
  if __name__ == "__main__":
82
  run()
main.py CHANGED
@@ -159,10 +159,10 @@ class TranslationRequest(BaseModel):
159
  async def translate_manga_panel(request: TranslationRequest):
160
  try:
161
  results = await processor.download_and_process(request.image_url, request.language)
162
- return {"status": "success", "data": results}
163
  except Exception as e:
164
  print(f"Translation Route Error: {e}")
165
-
166
  if __name__ == "__main__":
167
  # processor.process_image("./test_1.jpg", "")
168
  # show_boxes("./test_1.jpg")
 
159
  async def translate_manga_panel(request: TranslationRequest):
160
  try:
161
  results = await processor.download_and_process(request.image_url, request.language)
162
+ return {"status": "success", "data": results}
163
  except Exception as e:
164
  print(f"Translation Route Error: {e}")
165
+
166
  if __name__ == "__main__":
167
  # processor.process_image("./test_1.jpg", "")
168
  # show_boxes("./test_1.jpg")