feat(series): chapter layer over series_page — backend (FC-6.1)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m5s

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:
2026-06-07 16:31:55 -04:00
parent 43b02d79a4
commit 1804a2c622
11 changed files with 909 additions and 43 deletions
+159 -1
View File
@@ -368,6 +368,31 @@ def _series_err(exc: SeriesError):
return jsonify({"error": msg}), status
def _opt_int(body, key: str):
"""(value, error) — value is None when absent, error is (json, status)."""
if not body or body.get(key) is None:
return None, None
try:
return int(body[key]), None
except (TypeError, ValueError):
return None, (jsonify({"error": f"{key} must be an integer"}), 400)
def _parse_int_list(body, key: str, *, max_ids: int = 500):
"""(list, error) for a required list of ints under `key`."""
if not body or key not in body:
return None, (jsonify({"error": f"{key} required"}), 400)
raw = body[key]
if not isinstance(raw, list) or not raw:
return None, (jsonify({"error": f"{key} must be a non-empty list"}), 400)
if len(raw) > max_ids:
return None, (jsonify({"error": f"too many ids (max {max_ids})"}), 400)
try:
return [int(x) for x in raw], None
except (TypeError, ValueError):
return None, (jsonify({"error": f"{key} must be integers"}), 400)
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
async def series_pages(tag_id: int):
async with get_session() as session:
@@ -384,9 +409,14 @@ async def series_add(tag_id: int):
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
chapter_id, cerr = _opt_int(body, "chapter_id")
if cerr:
return cerr
async with get_session() as session:
try:
n = await SeriesService(session).add_images(tag_id, ids)
n = await SeriesService(session).add_images(
tag_id, ids, chapter_id=chapter_id
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
@@ -439,3 +469,131 @@ async def series_cover(tag_id: int):
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
# ---- chapters (FC-6.1) ----------------------------------------------------
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
async def series_chapter_create(tag_id: int):
body = await request.get_json() or {}
title = body.get("title")
if title is not None and not isinstance(title, str):
return jsonify({"error": "title must be a string"}), 400
is_placeholder = bool(body.get("is_placeholder", False))
start, serr = _opt_int(body, "stated_page_start")
if serr:
return serr
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
async with get_session() as session:
try:
ch = await SeriesService(session).create_chapter(
tag_id,
title=title,
is_placeholder=is_placeholder,
stated_page_start=start,
stated_page_end=end,
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(ch)
@tags_bp.route("/series/<int:tag_id>/chapters/reorder", methods=["POST"])
async def series_chapter_reorder(tag_id: int):
body = await request.get_json()
ids, err = _parse_int_list(body, "chapter_ids")
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).reorder_chapters(tag_id, ids)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["PATCH"]
)
async def series_chapter_update(tag_id: int, chapter_id: int):
body = await request.get_json() or {}
kwargs: dict = {}
if "title" in body:
if body["title"] is not None and not isinstance(body["title"], str):
return jsonify({"error": "title must be a string"}), 400
kwargs.update(set_title=True, title=body["title"])
if "stated_page_start" in body:
start, serr = _opt_int(body, "stated_page_start")
if serr:
return serr
kwargs.update(set_start=True, stated_page_start=start)
if "stated_page_end" in body:
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
kwargs.update(set_end=True, stated_page_end=end)
async with get_session() as session:
try:
await SeriesService(session).update_chapter(
tag_id, chapter_id, **kwargs
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["DELETE"]
)
async def series_chapter_delete(tag_id: int, chapter_id: int):
async with get_session() as session:
try:
await SeriesService(session).delete_chapter(tag_id, chapter_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>/merge", methods=["POST"]
)
async def series_chapter_merge(tag_id: int, chapter_id: int):
body = await request.get_json()
target, terr = _opt_int(body, "target_chapter_id")
if terr:
return terr
if target is None:
return jsonify({"error": "target_chapter_id required"}), 400
async with get_session() as session:
try:
moved = await SeriesService(session).merge_chapter(
tag_id, chapter_id, target
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"moved_count": moved})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>/reorder", methods=["POST"]
)
async def series_chapter_reorder_pages(tag_id: int, chapter_id: int):
body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).reorder_pages(tag_id, chapter_id, ids)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
+2
View File
@@ -18,6 +18,7 @@ from .patreon_failed_media import PatreonFailedMedia
from .patreon_seen_media import PatreonSeenMedia
from .post import Post
from .post_attachment import PostAttachment
from .series_chapter import SeriesChapter
from .series_page import SeriesPage
from .source import Source
from .tag import Tag, TagKind, image_tag
@@ -39,6 +40,7 @@ __all__ = [
"PatreonSeenMedia",
"Post",
"PostAttachment",
"SeriesChapter",
"SeriesPage",
"ImageRecord",
"ImageProvenance",
+47
View File
@@ -0,0 +1,47 @@
"""SeriesChapter — an ordered chapter/part within a series.
A series IS a Tag(kind='series'); a chapter groups ordered SeriesPages under it.
Reading order is (chapter.chapter_number, series_page.page_number): chapter_number
sets the order of chapters, page_number orders pages within a chapter.
chapter_number is an ordering key only (not unique) — reorder rewrites 1..N
wholesale, mirroring series_page.page_number, so a reorder can't transiently
collide on a unique index.
A chapter may be a placeholder (is_placeholder=True) — a reserved empty slot for
a section the operator doesn't have yet; it holds no pages and shows as a gap in
the reader. stated_page_start/end carry the page range parsed from the source
post (FC-6.2), used to flag missing-page gaps; both are nullable when unknown.
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class SeriesChapter(Base):
__tablename__ = "series_chapter"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_number: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
is_placeholder: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false"
)
stated_page_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
stated_page_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
+13 -4
View File
@@ -1,9 +1,12 @@
"""SeriesPage — ordered image membership for a series-kind Tag.
A series IS a Tag with kind='series'; series_page gives it ordered pages.
An image belongs to at most one series (UNIQUE image_id). Cover = the
lowest page_number. page_number is an ordering key only (not unique) —
reorder rewrites 1..N wholesale.
A series IS a Tag with kind='series'; series_page gives it ordered pages,
grouped into chapters (FC-6). An image belongs to at most one series
(UNIQUE image_id) ⇒ at most one chapter. Reading order is
(chapter.chapter_number, series_page.page_number): page_number orders pages
WITHIN a chapter and is an ordering key only (not unique) — reorder rewrites
1..N wholesale. stated_page carries the page number parsed from the source
post (FC-6.2), nullable when unknown.
"""
from datetime import datetime
@@ -21,12 +24,18 @@ class SeriesPage(Base):
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_id: Mapped[int] = mapped_column(
ForeignKey("series_chapter.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
image_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
page_number: Mapped[int] = mapped_column(Integer, nullable=False)
stated_page: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+342 -34
View File
@@ -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)
+10
View File
@@ -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(