Files
FabledCurator/backend/app/services/series_service.py
T
bvandeusen db490e92df
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
feat(series): post→series flows + browse list — backend (FC-6.2)
The post-aware on-ramp + the data behind the missing Series browse view.

- page_number_parser: conservative stated-page parser (pages 9-12 / page 5 /
  [3/8] / 3 of 8), keyword-gated to avoid false positives. Pure + unit-tested.
- SeriesService.promote_post_to_series: a self-contained post becomes its own
  series — series tag named after the post, one chapter, the post's images as
  pages (ordered by capture order; stated pages parsed from title/description).
- SeriesService.add_post_as_chapter: append a post as the next chapter of an
  existing series, titled after the post and slotted by parsed page number
  (a "pages 1-4" post lands ahead of the "pages 9-12" chapter).
- SeriesService.list_series: browse cards — cover thumb, artist, chapter/page
  counts, gap flag, last-updated; sort recent|name|size + filter by artist.
- API: GET /api/series, POST /api/series/from-post, POST /api/series/<id>/add-post.
- Resolver uses ImageRecord.primary_post_id (same linkage the posts feed renders).

Frontend (Add-to-series control + Series view + nav) lands next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:34:11 -04:00

736 lines
28 KiB
Python

"""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.
"""
from sqlalchemy import and_, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, ImageRecord, Post, Tag, TagKind
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage
from .gallery_service import thumbnail_url
from .page_number_parser import parse_page_range
class SeriesError(ValueError):
"""Series validation failure. The API maps 'not a series'/'not found'
to 404 and everything else to 400."""
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:
raise SeriesError(f"Tag {series_tag_id} not found")
if tag.kind != TagKind.series:
raise SeriesError(f"Tag {series_tag_id} is not a series")
return tag
@staticmethod
def _clean_ids(ids: list[int]) -> list[int]:
if not ids:
raise SeriesError("ids must be a non-empty list")
if len(ids) > 500:
raise SeriesError("selection too large (max 500)")
seen: set[int] = set()
out: list[int] = []
for x in ids:
if x not in seen:
seen.add(x)
out.append(int(x))
return out
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.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,
)
.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},
"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],
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(
select(SeriesPage.image_id, SeriesPage.series_tag_id)
.where(SeriesPage.image_id.in_(ids))
)
).all()
)
to_add = [i for i in ids if existing.get(i) != series_tag_id]
if not to_add:
return 0
# 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))
)
max_pn = (
await self.session.scalar(
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)
]
)
)
return len(to_add)
async def remove_images(
self, series_tag_id: int, image_ids: list[int]
) -> int:
await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids)
res = await self.session.execute(
SeriesPage.__table__.delete().where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id.in_(ids),
)
)
)
return res.rowcount or 0
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._chapter_page_order(chapter_id))
if set(ordered) != current or len(ordered) != len(current):
raise SeriesError(
"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.chapter_id == chapter_id,
SeriesPage.image_id == iid,
)
)
.values(page_number=idx)
)
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)
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")
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)
# ---- post → series (FC-6.2) ------------------------------------------
async def _post_images_ordered(self, post_id: int) -> list[int]:
"""A post's images in capture order (ImageRecord.primary_post_id), the
same ordering the posts feed renders thumbnails in."""
rows = (
await self.session.execute(
select(ImageRecord.id)
.where(ImageRecord.primary_post_id == post_id)
.order_by(ImageRecord.id.asc())
)
).scalars().all()
return list(rows)
@staticmethod
def _stated_map(image_ids: list[int], start: int | None) -> dict | None:
"""Per-image stated_page = start, start+1, ... when the post stated a
starting page; None when it didn't (fall back to capture order)."""
if start is None:
return None
return {iid: start + i for i, iid in enumerate(image_ids)}
async def promote_post_to_series(self, post_id: int) -> dict:
"""Case 1: a self-contained multi-image post becomes its own series.
Creates a series tag named after the post, one chapter, the post's
images as its pages (stated pages parsed from the post when present)."""
from .tag_service import TagService
post = await self.session.get(Post, post_id)
if post is None:
raise SeriesError(f"post {post_id} not found")
image_ids = await self._post_images_ordered(post_id)
if not image_ids:
raise SeriesError("post has no images to make a series from")
name = (post.post_title or f"Series from post {post_id}").strip()[:200]
tag = await TagService(self.session).find_or_create(name, TagKind.series)
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
start, end = rng if rng else (None, None)
ch = await self.create_chapter(
tag.id, stated_page_start=start, stated_page_end=end,
)
added = await self.add_images(
tag.id, image_ids, chapter_id=ch["id"],
stated_pages=self._stated_map(image_ids, start),
)
return {
"series_tag_id": tag.id, "name": tag.name,
"chapter_id": ch["id"], "added": added,
}
async def add_post_as_chapter(
self, series_tag_id: int, post_id: int
) -> dict:
"""Case 2: append a post as the next chapter of an existing series. The
chapter is titled after the post and slotted by parsed page number when
present (else appended at the end)."""
await self._require_series(series_tag_id)
post = await self.session.get(Post, post_id)
if post is None:
raise SeriesError(f"post {post_id} not found")
image_ids = await self._post_images_ordered(post_id)
if not image_ids:
raise SeriesError("post has no images to add")
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
start, end = rng if rng else (None, None)
ch = await self.create_chapter(
series_tag_id, title=post.post_title or None,
stated_page_start=start, stated_page_end=end,
)
added = await self.add_images(
series_tag_id, image_ids, chapter_id=ch["id"],
stated_pages=self._stated_map(image_ids, start),
)
if start is not None:
await self._place_chapter_by_stated(series_tag_id, ch["id"], start)
return {
"series_tag_id": series_tag_id, "chapter_id": ch["id"],
"added": added,
}
async def _place_chapter_by_stated(
self, series_tag_id: int, chapter_id: int, start: int
) -> None:
"""Move `chapter_id` to sit before the first chapter whose stated start
is higher — so a post stating pages 5-8 lands ahead of the 9-12 chapter."""
rows = (
await self.session.execute(
select(SeriesChapter.id, SeriesChapter.stated_page_start)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
)
).all()
current = [r.id for r in rows]
others = [r for r in rows if r.id != chapter_id]
insert_at = len(others)
for idx, r in enumerate(others):
if r.stated_page_start is not None and r.stated_page_start > start:
insert_at = idx
break
new_order = [r.id for r in others]
new_order.insert(insert_at, chapter_id)
if new_order != current:
await self.reorder_chapters(series_tag_id, new_order)
# ---- browse list (FC-6.2) --------------------------------------------
async def list_series(
self, *, sort: str = "recent", artist_id: int | None = None
) -> list[dict]:
"""Series cards for the browse view: cover thumb, name, artist, chapter
+ page counts, a gap flag, and last-updated. sort ∈ recent|name|size."""
# Page counts + most-recent activity per series.
page_rows = (
await self.session.execute(
select(
SeriesPage.series_tag_id,
func.count().label("pages"),
).group_by(SeriesPage.series_tag_id)
)
).all()
page_count = {r.series_tag_id: r.pages for r in page_rows}
ch_rows = (
await self.session.execute(
select(
SeriesChapter.series_tag_id,
SeriesChapter.id,
SeriesChapter.chapter_number,
SeriesChapter.stated_page_start,
SeriesChapter.stated_page_end,
SeriesChapter.updated_at,
)
.order_by(
SeriesChapter.series_tag_id,
SeriesChapter.chapter_number.asc(),
)
)
).all()
chapters_by_series: dict[int, list] = {}
updated_by_series: dict[int, object] = {}
for r in ch_rows:
chapters_by_series.setdefault(r.series_tag_id, []).append(r)
prev = updated_by_series.get(r.series_tag_id)
if prev is None or (r.updated_at and r.updated_at > prev):
updated_by_series[r.series_tag_id] = r.updated_at
# Cover = the (chapter_number, page_number)-minimum page per series.
cover_q = (
select(
SeriesPage.series_tag_id,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.thumbnail_path,
ImageRecord.artist_id,
func.row_number()
.over(
partition_by=SeriesPage.series_tag_id,
order_by=(
SeriesChapter.chapter_number.asc(),
SeriesPage.page_number.asc(),
),
)
.label("rn"),
)
.join(SeriesChapter, SeriesChapter.id == SeriesPage.chapter_id)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.subquery()
)
cover_rows = (
await self.session.execute(
select(
cover_q.c.series_tag_id,
cover_q.c.sha256,
cover_q.c.mime,
cover_q.c.thumbnail_path,
cover_q.c.artist_id,
).where(cover_q.c.rn == 1)
)
).all()
cover_by_series = {r.series_tag_id: r for r in cover_rows}
# Artist names for the covers.
artist_ids = {
r.artist_id for r in cover_rows if r.artist_id is not None
}
artist_by_id: dict[int, object] = {}
if artist_ids:
arows = (
await self.session.execute(
select(Artist.id, Artist.name, Artist.slug)
.where(Artist.id.in_(artist_ids))
)
).all()
artist_by_id = {a.id: a for a in arows}
tags = (
await self.session.execute(
select(Tag.id, Tag.name).where(Tag.kind == TagKind.series)
)
).all()
out: list[dict] = []
for t in tags:
cover = cover_by_series.get(t.id)
if artist_id is not None and (
cover is None or cover.artist_id != artist_id
):
continue
chs = chapters_by_series.get(t.id, [])
gap = bool(
self._gaps(
[
{
"id": c.id,
"stated_page_start": c.stated_page_start,
"stated_page_end": c.stated_page_end,
}
for c in chs
]
)
)
artist = artist_by_id.get(cover.artist_id) if cover else None
out.append(
{
"id": t.id,
"name": t.name,
"cover_thumbnail_url": (
thumbnail_url(
cover.thumbnail_path, cover.sha256, cover.mime
)
if cover
else None
),
"artist_name": artist.name if artist else None,
"artist_slug": artist.slug if artist else None,
"chapter_count": len(chs),
"page_count": page_count.get(t.id, 0),
"has_gap": gap,
"updated_at": (
updated_by_series.get(t.id).isoformat()
if updated_by_series.get(t.id)
else None
),
}
)
if sort == "name":
out.sort(key=lambda s: s["name"].lower())
elif sort == "size":
out.sort(key=lambda s: s["page_count"], reverse=True)
else: # recent
out.sort(key=lambda s: s["updated_at"] or "", reverse=True)
return out