feat(series): chapter layer over series_page — backend (FC-6.1)
Adds an ordered chapter layer to series. Reading order becomes (series_chapter.chapter_number, series_page.page_number); a chapter may be a placeholder reserving a slot, and carries an optional parsed stated-page range used to flag missing-page gaps. An image still lives in at most one series ⇒ one chapter (image_id stays UNIQUE). - models: series_chapter; series_page gains chapter_id (NOT NULL, cascade) + stated_page. Migration 0040 backfills every existing series into one auto-chapter holding its current flat pages — no data loss. - SeriesService: chapter CRUD (create/update/reorder/delete/merge), page→chapter assignment, reorder_pages, chapter-aware set_cover; list_pages now returns chapters[] + gaps[] alongside a back-compat flat pages[]. Legacy series-wide reorder operates on the single default chapter and rejects multi-chapter series. - API: chapter endpoints under /api/series/<tag>/chapters; POST pages accepts an optional chapter_id. - TagService.merge now repoints series_chapter too, so a merged series' chapters (and their pages) survive the source tag's deletion instead of cascading away. - Tests: new chapter suite; updated the 4 direct SeriesPage(...) constructions to supply chapter_id. Frontend (chapter-aware manage view + reader) lands next; until then the existing UI keeps working via the flat pages[] + single default chapter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
"""Series = a series-kind Tag + ordered series_page membership.
|
||||
"""Series = a series-kind Tag + ordered chapters, each holding ordered pages.
|
||||
|
||||
Reading order is (series_chapter.chapter_number, series_page.page_number). A
|
||||
chapter may be a placeholder (no pages) reserving a slot. An image lives in at
|
||||
most one series ⇒ one chapter (series_page.image_id is UNIQUE).
|
||||
|
||||
All mutations are Core/set-based and run in the request transaction.
|
||||
"""
|
||||
@@ -8,6 +12,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import ImageRecord, Tag, TagKind
|
||||
from ..models.series_chapter import SeriesChapter
|
||||
from ..models.series_page import SeriesPage
|
||||
from .gallery_service import thumbnail_url
|
||||
|
||||
@@ -21,6 +26,8 @@ class SeriesService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
# ---- guards / helpers -------------------------------------------------
|
||||
|
||||
async def _require_series(self, series_tag_id: int) -> Tag:
|
||||
tag = await self.session.get(Tag, series_tag_id)
|
||||
if tag is None:
|
||||
@@ -32,7 +39,7 @@ class SeriesService:
|
||||
@staticmethod
|
||||
def _clean_ids(ids: list[int]) -> list[int]:
|
||||
if not ids:
|
||||
raise SeriesError("image_ids must be a non-empty list")
|
||||
raise SeriesError("ids must be a non-empty list")
|
||||
if len(ids) > 500:
|
||||
raise SeriesError("selection too large (max 500)")
|
||||
seen: set[int] = set()
|
||||
@@ -43,51 +50,172 @@ class SeriesService:
|
||||
out.append(int(x))
|
||||
return out
|
||||
|
||||
async def _member_order(self, series_tag_id: int) -> list[int]:
|
||||
async def _require_chapter(self, series_tag_id: int, chapter_id: int):
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(SeriesChapter).where(
|
||||
and_(
|
||||
SeriesChapter.id == chapter_id,
|
||||
SeriesChapter.series_tag_id == series_tag_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise SeriesError(
|
||||
f"chapter {chapter_id} is not in series {series_tag_id}"
|
||||
)
|
||||
return row
|
||||
|
||||
async def _ensure_default_chapter(self, series_tag_id: int) -> int:
|
||||
"""Lowest-numbered chapter of the series, creating a first chapter if
|
||||
the series has none (a fresh series, or the legacy single-chapter path)."""
|
||||
existing = await self.session.scalar(
|
||||
select(SeriesChapter.id)
|
||||
.where(SeriesChapter.series_tag_id == series_tag_id)
|
||||
.order_by(SeriesChapter.chapter_number.asc())
|
||||
.limit(1)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
res = await self.session.execute(
|
||||
pg_insert(SeriesChapter)
|
||||
.values(series_tag_id=series_tag_id, chapter_number=1)
|
||||
.returning(SeriesChapter.id)
|
||||
)
|
||||
return res.scalar_one()
|
||||
|
||||
async def _chapter_page_order(self, chapter_id: int) -> list[int]:
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(SeriesPage.image_id)
|
||||
.where(SeriesPage.series_tag_id == series_tag_id)
|
||||
.where(SeriesPage.chapter_id == chapter_id)
|
||||
.order_by(SeriesPage.page_number.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
async def _chapter_order(self, series_tag_id: int) -> list[int]:
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(SeriesChapter.id)
|
||||
.where(SeriesChapter.series_tag_id == series_tag_id)
|
||||
.order_by(SeriesChapter.chapter_number.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
# ---- read -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _gaps(chapters: list[dict]) -> list[dict]:
|
||||
"""Missing-page gaps between consecutive chapters with stated ranges."""
|
||||
out: list[dict] = []
|
||||
prev = None
|
||||
for ch in chapters:
|
||||
start = ch["stated_page_start"]
|
||||
if (
|
||||
prev is not None
|
||||
and prev["stated_page_end"] is not None
|
||||
and start is not None
|
||||
and start > prev["stated_page_end"] + 1
|
||||
):
|
||||
out.append(
|
||||
{
|
||||
"after_chapter_id": prev["id"],
|
||||
"start": prev["stated_page_end"] + 1,
|
||||
"end": start - 1,
|
||||
}
|
||||
)
|
||||
prev = ch
|
||||
return out
|
||||
|
||||
async def list_pages(self, series_tag_id: int) -> dict:
|
||||
tag = await self._require_series(series_tag_id)
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(
|
||||
SeriesChapter.id.label("chapter_id"),
|
||||
SeriesChapter.chapter_number,
|
||||
SeriesChapter.title,
|
||||
SeriesChapter.is_placeholder,
|
||||
SeriesChapter.stated_page_start,
|
||||
SeriesChapter.stated_page_end,
|
||||
SeriesPage.image_id,
|
||||
SeriesPage.page_number,
|
||||
SeriesPage.stated_page,
|
||||
ImageRecord.sha256,
|
||||
ImageRecord.mime,
|
||||
ImageRecord.path,
|
||||
ImageRecord.thumbnail_path,
|
||||
)
|
||||
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
||||
.where(SeriesPage.series_tag_id == series_tag_id)
|
||||
.order_by(SeriesPage.page_number.asc())
|
||||
.select_from(SeriesChapter)
|
||||
.outerjoin(SeriesPage, SeriesPage.chapter_id == SeriesChapter.id)
|
||||
.outerjoin(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
||||
.where(SeriesChapter.series_tag_id == series_tag_id)
|
||||
.order_by(
|
||||
SeriesChapter.chapter_number.asc(),
|
||||
SeriesPage.page_number.asc(),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
chapters: list[dict] = []
|
||||
flat: list[dict] = []
|
||||
by_id: dict[int, dict] = {}
|
||||
for r in rows:
|
||||
ch = by_id.get(r.chapter_id)
|
||||
if ch is None:
|
||||
ch = {
|
||||
"id": r.chapter_id,
|
||||
"chapter_number": r.chapter_number,
|
||||
"title": r.title,
|
||||
"is_placeholder": r.is_placeholder,
|
||||
"stated_page_start": r.stated_page_start,
|
||||
"stated_page_end": r.stated_page_end,
|
||||
"pages": [],
|
||||
}
|
||||
by_id[r.chapter_id] = ch
|
||||
chapters.append(ch)
|
||||
if r.image_id is None:
|
||||
continue # placeholder / empty chapter
|
||||
page = {
|
||||
"image_id": r.image_id,
|
||||
"chapter_id": r.chapter_id,
|
||||
"page_number": r.page_number,
|
||||
"stated_page": r.stated_page,
|
||||
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
|
||||
}
|
||||
ch["pages"].append(page)
|
||||
flat.append(page)
|
||||
|
||||
return {
|
||||
"series": {"id": tag.id, "name": tag.name},
|
||||
"pages": [
|
||||
{
|
||||
"image_id": r.image_id,
|
||||
"page_number": r.page_number,
|
||||
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"chapters": chapters,
|
||||
"pages": flat, # back-compat: flat reading order across chapters
|
||||
"gaps": self._gaps(chapters),
|
||||
}
|
||||
|
||||
# ---- pages ------------------------------------------------------------
|
||||
|
||||
async def add_images(
|
||||
self, series_tag_id: int, image_ids: list[int]
|
||||
self,
|
||||
series_tag_id: int,
|
||||
image_ids: list[int],
|
||||
chapter_id: int | None = None,
|
||||
stated_pages: dict[int, int] | None = None,
|
||||
) -> int:
|
||||
"""Append images as pages of a chapter (the series' default chapter when
|
||||
chapter_id is None). Images already in THIS series are left untouched;
|
||||
images in another series are moved here (image_id is UNIQUE)."""
|
||||
await self._require_series(series_tag_id)
|
||||
ids = self._clean_ids(image_ids)
|
||||
if chapter_id is None:
|
||||
chapter_id = await self._ensure_default_chapter(series_tag_id)
|
||||
else:
|
||||
await self._require_chapter(series_tag_id, chapter_id)
|
||||
|
||||
existing = dict(
|
||||
(
|
||||
await self.session.execute(
|
||||
@@ -96,29 +224,30 @@ class SeriesService:
|
||||
)
|
||||
).all()
|
||||
)
|
||||
# Already in THIS series → leave untouched (no churn).
|
||||
to_add = [i for i in ids if existing.get(i) != series_tag_id]
|
||||
if not to_add:
|
||||
return 0
|
||||
# Remove any prior membership for the to_add images (the "move").
|
||||
# Move: drop any prior membership for these images (UNIQUE image_id).
|
||||
await self.session.execute(
|
||||
SeriesPage.__table__.delete().where(
|
||||
SeriesPage.image_id.in_(to_add)
|
||||
)
|
||||
SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add))
|
||||
)
|
||||
max_pn = (
|
||||
await self.session.scalar(
|
||||
select(func.coalesce(func.max(SeriesPage.page_number), 0))
|
||||
.where(SeriesPage.series_tag_id == series_tag_id)
|
||||
select(func.coalesce(func.max(SeriesPage.page_number), 0)).where(
|
||||
SeriesPage.chapter_id == chapter_id
|
||||
)
|
||||
)
|
||||
) or 0
|
||||
sp = stated_pages or {}
|
||||
await self.session.execute(
|
||||
pg_insert(SeriesPage).values(
|
||||
[
|
||||
{
|
||||
"series_tag_id": series_tag_id,
|
||||
"chapter_id": chapter_id,
|
||||
"image_id": iid,
|
||||
"page_number": max_pn + offset,
|
||||
"stated_page": sp.get(iid),
|
||||
}
|
||||
for offset, iid in enumerate(to_add, start=1)
|
||||
]
|
||||
@@ -141,32 +270,211 @@ class SeriesService:
|
||||
)
|
||||
return res.rowcount or 0
|
||||
|
||||
async def reorder(
|
||||
self, series_tag_id: int, ordered_image_ids: list[int]
|
||||
async def reorder_pages(
|
||||
self, series_tag_id: int, chapter_id: int, ordered_image_ids: list[int]
|
||||
) -> None:
|
||||
await self._require_series(series_tag_id)
|
||||
await self._require_chapter(series_tag_id, chapter_id)
|
||||
ordered = self._clean_ids(ordered_image_ids)
|
||||
current = set(await self._member_order(series_tag_id))
|
||||
current = set(await self._chapter_page_order(chapter_id))
|
||||
if set(ordered) != current or len(ordered) != len(current):
|
||||
raise SeriesError(
|
||||
"ordered image_ids must exactly match series membership"
|
||||
"ordered image_ids must exactly match the chapter's pages"
|
||||
)
|
||||
for idx, iid in enumerate(ordered, start=1):
|
||||
await self.session.execute(
|
||||
update(SeriesPage)
|
||||
.where(
|
||||
and_(
|
||||
SeriesPage.series_tag_id == series_tag_id,
|
||||
SeriesPage.chapter_id == chapter_id,
|
||||
SeriesPage.image_id == iid,
|
||||
)
|
||||
)
|
||||
.values(page_number=idx)
|
||||
)
|
||||
|
||||
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
|
||||
async def reorder(
|
||||
self, series_tag_id: int, ordered_image_ids: list[int]
|
||||
) -> None:
|
||||
"""Legacy series-wide reorder — reorders the default chapter. Valid only
|
||||
for single-chapter series (the only shape the pre-chapter UI produced);
|
||||
multi-chapter series must use reorder_pages with an explicit chapter."""
|
||||
await self._require_series(series_tag_id)
|
||||
order = await self._member_order(series_tag_id)
|
||||
if image_id not in order:
|
||||
chapters = await self._chapter_order(series_tag_id)
|
||||
if len(chapters) > 1:
|
||||
raise SeriesError(
|
||||
"series has multiple chapters; reorder a specific chapter"
|
||||
)
|
||||
chapter_id = (
|
||||
chapters[0]
|
||||
if chapters
|
||||
else await self._ensure_default_chapter(series_tag_id)
|
||||
)
|
||||
await self.reorder_pages(series_tag_id, chapter_id, ordered_image_ids)
|
||||
|
||||
# ---- chapters ---------------------------------------------------------
|
||||
|
||||
async def create_chapter(
|
||||
self,
|
||||
series_tag_id: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
is_placeholder: bool = False,
|
||||
stated_page_start: int | None = None,
|
||||
stated_page_end: int | None = None,
|
||||
) -> dict:
|
||||
await self._require_series(series_tag_id)
|
||||
max_cn = (
|
||||
await self.session.scalar(
|
||||
select(
|
||||
func.coalesce(func.max(SeriesChapter.chapter_number), 0)
|
||||
).where(SeriesChapter.series_tag_id == series_tag_id)
|
||||
)
|
||||
) or 0
|
||||
res = await self.session.execute(
|
||||
pg_insert(SeriesChapter)
|
||||
.values(
|
||||
series_tag_id=series_tag_id,
|
||||
chapter_number=max_cn + 1,
|
||||
title=title,
|
||||
is_placeholder=is_placeholder,
|
||||
stated_page_start=stated_page_start,
|
||||
stated_page_end=stated_page_end,
|
||||
)
|
||||
.returning(SeriesChapter.id, SeriesChapter.chapter_number)
|
||||
)
|
||||
row = res.one()
|
||||
return {"id": row.id, "chapter_number": row.chapter_number}
|
||||
|
||||
async def update_chapter(
|
||||
self,
|
||||
series_tag_id: int,
|
||||
chapter_id: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
stated_page_start: int | None = None,
|
||||
stated_page_end: int | None = None,
|
||||
set_title: bool = False,
|
||||
set_start: bool = False,
|
||||
set_end: bool = False,
|
||||
) -> None:
|
||||
"""Partial chapter edit. The set_* flags say which fields to write (so
|
||||
None can be written explicitly, e.g. clearing a stated page)."""
|
||||
await self._require_series(series_tag_id)
|
||||
await self._require_chapter(series_tag_id, chapter_id)
|
||||
values: dict = {}
|
||||
if set_title:
|
||||
values["title"] = title
|
||||
if set_start:
|
||||
values["stated_page_start"] = stated_page_start
|
||||
if set_end:
|
||||
values["stated_page_end"] = stated_page_end
|
||||
if not values:
|
||||
return
|
||||
await self.session.execute(
|
||||
update(SeriesChapter)
|
||||
.where(SeriesChapter.id == chapter_id)
|
||||
.values(**values)
|
||||
)
|
||||
|
||||
async def _renumber_chapters(self, series_tag_id: int) -> None:
|
||||
for idx, cid in enumerate(
|
||||
await self._chapter_order(series_tag_id), start=1
|
||||
):
|
||||
await self.session.execute(
|
||||
update(SeriesChapter)
|
||||
.where(SeriesChapter.id == cid)
|
||||
.values(chapter_number=idx)
|
||||
)
|
||||
|
||||
async def reorder_chapters(
|
||||
self, series_tag_id: int, ordered_chapter_ids: list[int]
|
||||
) -> None:
|
||||
await self._require_series(series_tag_id)
|
||||
ordered = self._clean_ids(ordered_chapter_ids)
|
||||
current = set(await self._chapter_order(series_tag_id))
|
||||
if set(ordered) != current or len(ordered) != len(current):
|
||||
raise SeriesError(
|
||||
"ordered chapter_ids must exactly match the series' chapters"
|
||||
)
|
||||
for idx, cid in enumerate(ordered, start=1):
|
||||
await self.session.execute(
|
||||
update(SeriesChapter)
|
||||
.where(SeriesChapter.id == cid)
|
||||
.values(chapter_number=idx)
|
||||
)
|
||||
|
||||
async def delete_chapter(self, series_tag_id: int, chapter_id: int) -> None:
|
||||
await self._require_series(series_tag_id)
|
||||
await self._require_chapter(series_tag_id, chapter_id)
|
||||
# Pages cascade-delete with the chapter (FK ondelete=CASCADE).
|
||||
await self.session.execute(
|
||||
SeriesChapter.__table__.delete().where(
|
||||
SeriesChapter.id == chapter_id
|
||||
)
|
||||
)
|
||||
await self._renumber_chapters(series_tag_id)
|
||||
|
||||
async def merge_chapter(
|
||||
self, series_tag_id: int, source_chapter_id: int, target_chapter_id: int
|
||||
) -> int:
|
||||
"""Move source chapter's pages onto the end of the target chapter, then
|
||||
delete the now-empty source chapter. Returns the number of pages moved."""
|
||||
await self._require_series(series_tag_id)
|
||||
if source_chapter_id == target_chapter_id:
|
||||
raise SeriesError("cannot merge a chapter into itself")
|
||||
await self._require_chapter(series_tag_id, source_chapter_id)
|
||||
await self._require_chapter(series_tag_id, target_chapter_id)
|
||||
moving = await self._chapter_page_order(source_chapter_id)
|
||||
max_pn = (
|
||||
await self.session.scalar(
|
||||
select(func.coalesce(func.max(SeriesPage.page_number), 0)).where(
|
||||
SeriesPage.chapter_id == target_chapter_id
|
||||
)
|
||||
)
|
||||
) or 0
|
||||
for offset, iid in enumerate(moving, start=1):
|
||||
await self.session.execute(
|
||||
update(SeriesPage)
|
||||
.where(
|
||||
and_(
|
||||
SeriesPage.chapter_id == source_chapter_id,
|
||||
SeriesPage.image_id == iid,
|
||||
)
|
||||
)
|
||||
.values(chapter_id=target_chapter_id, page_number=max_pn + offset)
|
||||
)
|
||||
await self.session.execute(
|
||||
SeriesChapter.__table__.delete().where(
|
||||
SeriesChapter.id == source_chapter_id
|
||||
)
|
||||
)
|
||||
await self._renumber_chapters(series_tag_id)
|
||||
return len(moving)
|
||||
|
||||
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
|
||||
"""Cover = the (chapter_number=1, page_number=1) image. Bring the image's
|
||||
chapter to the front and the image to the front of its chapter."""
|
||||
await self._require_series(series_tag_id)
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(SeriesPage.chapter_id)
|
||||
.where(
|
||||
and_(
|
||||
SeriesPage.series_tag_id == series_tag_id,
|
||||
SeriesPage.image_id == image_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise SeriesError(f"image {image_id} is not in this series")
|
||||
new_order = [image_id] + [i for i in order if i != image_id]
|
||||
await self.reorder(series_tag_id, new_order)
|
||||
chapter_id = row
|
||||
chapters = await self._chapter_order(series_tag_id)
|
||||
if chapters and chapters[0] != chapter_id:
|
||||
new_chapters = [chapter_id] + [c for c in chapters if c != chapter_id]
|
||||
await self.reorder_chapters(series_tag_id, new_chapters)
|
||||
pages = await self._chapter_page_order(chapter_id)
|
||||
if pages and pages[0] != image_id:
|
||||
new_pages = [image_id] + [p for p in pages if p != image_id]
|
||||
await self.reorder_pages(series_tag_id, chapter_id, new_pages)
|
||||
|
||||
@@ -515,8 +515,18 @@ class TagService:
|
||||
)
|
||||
|
||||
async def _repoint_series_pages(self, src: int, tgt: int) -> None:
|
||||
from ..models.series_chapter import SeriesChapter
|
||||
from ..models.series_page import SeriesPage
|
||||
|
||||
# Move the chapters first so the pages' chapter_id FK stays valid: a
|
||||
# chapter left pointing at src would cascade-delete (with its pages) when
|
||||
# src is removed. chapter_number may now collide across the merged set —
|
||||
# acceptable (it's an ordering key, not unique).
|
||||
await self.session.execute(
|
||||
update(SeriesChapter)
|
||||
.where(SeriesChapter.series_tag_id == src)
|
||||
.values(series_tag_id=tgt)
|
||||
)
|
||||
# image_id is UNIQUE across series_page and src != tgt, so an
|
||||
# image in src's series cannot already be in tgt's — no collision.
|
||||
await self.session.execute(
|
||||
|
||||
Reference in New Issue
Block a user