Files
FabledCurator/backend/app/services/series_service.py
T
bvandeusen 7bb765b6ed
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m17s
feat(series): pending staging for add-from-post (#789 Phase 2)
Add-from-post no longer appends straight into the run — it STAGES the post's
pages as pending (per-page status; page_number NULL), grouped by source post,
so the operator drops junk (text-free alts, bumpers) and places the keepers
into the sequence with clean series-global numbering.

- migration 0048: series_page.status ('placed' default | 'pending') + nullable
  page_number.
- series_service: placed/pending split everywhere (list_pages returns the
  placed run + a `pending` section grouped by source post; reorder/cover/
  list_series operate on placed only); add_post stages pending; new
  place_pending(image_ids, before_image_id=None) flips pending→placed spliced
  before a page (or appended) and renumbers; junk removal reuses remove_images.
- api/tags: /add-post now returns staged count; new POST /series/<id>/pending/
  place.
- frontend: PostSeriesMenu navigates to the series after staging; seriesManage
  store surfaces `pending` + placePending; SeriesManageView gains a pending
  tray (per-post groups, place-all / place-one / drop-junk).
- tests: pending staging, place (append + insert-before), ignore-already-
  placed, drop-junk, route guard; updated add_post + match-accept expectations.

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

782 lines
30 KiB
Python

"""Series = a series-kind Tag + ONE flat, series-global ordered run of pages,
with optional cosmetic chapter DIVIDERS over it (FC-6.x reframe, #789).
Reading order is series_page.page_number alone (rewritten 1..N wholesale on any
reorder; page_number is not unique, so a reorder never transiently collides). A
chapter is NOT a container — it is a labeled divider anchored to the page that
begins it (series_chapter.anchor_page_id); a page's chapter is derived as the
nearest preceding divider. An image lives in at most one series
(series_page.image_id is UNIQUE).
All mutations are Core/set-based and run in the request transaction.
"""
from sqlalchemy import and_, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
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 _page_order(self, series_tag_id: int) -> list[int]:
"""The series' PLACED image_ids in reading order (series-global
page_number). Pending (staged) pages are excluded — they have no order
yet."""
rows = (
await self.session.execute(
select(SeriesPage.image_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "placed",
)
)
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
)
).scalars().all()
return list(rows)
async def _page_id_for(self, series_tag_id: int, image_id: int) -> int:
"""series_page.id for an image in the series (raises if not a member)."""
pid = await self.session.scalar(
select(SeriesPage.id).where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == image_id,
)
)
)
if pid is None:
raise SeriesError(f"image {image_id} is not in this series")
return pid
async def _renumber(self, series_tag_id: int) -> None:
"""Compact page_number to 1..N preserving current order (set-based)."""
await self.session.execute(
text(
"""
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (
ORDER BY page_number, id
) AS rn
FROM series_page WHERE series_tag_id = :sid
)
UPDATE series_page sp SET page_number = ordered.rn
FROM ordered
WHERE sp.id = ordered.id AND sp.page_number <> ordered.rn
"""
),
{"sid": series_tag_id},
)
async def _require_divider(self, series_tag_id: int, divider_id: int):
row = (
await self.session.execute(
select(SeriesChapter).where(
and_(
SeriesChapter.id == divider_id,
SeriesChapter.series_tag_id == series_tag_id,
)
)
)
).scalar_one_or_none()
if row is None:
raise SeriesError(
f"chapter {divider_id} is not in series {series_tag_id}"
)
return row
# ---- read -------------------------------------------------------------
@staticmethod
def _part_gaps(dividers: list[dict]) -> list[dict]:
"""Missing-Part hints between consecutive dividers whose stated_part
numbers jump by more than 1 (e.g. Part 1 then Part 3). Only dividers
that carry a stated_part participate."""
out: list[dict] = []
prev = None
for d in dividers:
cur = d["stated_part"]
if cur is None:
continue
if prev is not None and cur > prev["stated_part"] + 1:
out.append(
{
"after_divider_id": prev["id"],
"start": prev["stated_part"] + 1,
"end": cur - 1,
}
)
prev = d
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(
SeriesPage.image_id,
SeriesPage.page_number,
SeriesPage.stated_page,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.path,
ImageRecord.thumbnail_path,
ImageRecord.primary_post_id,
Post.post_title,
)
.select_from(SeriesPage)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "placed",
)
)
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
)
).all()
pages = [
{
"image_id": r.image_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]}",
"source_post": (
{"id": r.primary_post_id, "title": r.post_title}
if r.primary_post_id is not None
else None
),
}
for r in rows
]
# Dividers, ordered by the global position of their anchor page.
drows = (
await self.session.execute(
select(
SeriesChapter.id,
SeriesChapter.title,
SeriesChapter.stated_part,
SeriesPage.image_id.label("anchor_image_id"),
SeriesPage.page_number,
)
.join(SeriesPage, SeriesPage.id == SeriesChapter.anchor_page_id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesPage.page_number.asc())
)
).all()
dividers = [
{
"id": r.id,
"anchor_image_id": r.anchor_image_id,
"page_number": r.page_number,
"title": r.title,
"stated_part": r.stated_part,
}
for r in drows
]
# Pending (staged-from-post) pages, grouped by their source post so the
# operator can drop junk and place the keepers into the run.
prows = (
await self.session.execute(
select(
SeriesPage.image_id,
SeriesPage.stated_page,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.path,
ImageRecord.thumbnail_path,
ImageRecord.primary_post_id,
Post.post_title,
)
.select_from(SeriesPage)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "pending",
)
)
.order_by(
ImageRecord.primary_post_id.asc().nulls_last(),
SeriesPage.id.asc(),
)
)
).all()
pending_groups: list[dict] = []
by_post: dict = {}
for r in prows:
grp = by_post.get(r.primary_post_id)
if grp is None:
grp = {
"post": (
{"id": r.primary_post_id, "title": r.post_title}
if r.primary_post_id is not None
else None
),
"pages": [],
}
by_post[r.primary_post_id] = grp
pending_groups.append(grp)
grp["pages"].append(
{
"image_id": r.image_id,
"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]}",
}
)
return {
"series": {"id": tag.id, "name": tag.name},
"pages": pages,
"dividers": dividers,
"pending": pending_groups,
"part_gaps": self._part_gaps(dividers),
}
# ---- pages ------------------------------------------------------------
async def add_images(
self,
series_tag_id: int,
image_ids: list[int],
stated_pages: dict[int, int] | None = None,
) -> int:
"""Append images as pages at the END of the series' flat run. 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)
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). A
# dropped page that anchored a divider in another series cascades it away.
await self.session.execute(
SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add))
)
max_pn = await self._max_placed_page_number(series_tag_id)
sp = stated_pages or {}
await self.session.execute(
pg_insert(SeriesPage).values(
[
{
"series_tag_id": series_tag_id,
"image_id": iid,
"status": "placed",
"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 _max_placed_page_number(self, series_tag_id: int) -> int:
return (
await self.session.scalar(
select(
func.coalesce(func.max(SeriesPage.page_number), 0)
).where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.status == "placed",
)
)
)
) or 0
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),
)
)
)
# Dividers anchored to a removed page cascade away (chapter merges into
# the preceding run); compact the remaining pages back to 1..N.
await self._renumber(series_tag_id)
return res.rowcount or 0
async def reorder(
self, series_tag_id: int, ordered_image_ids: list[int]
) -> None:
"""Series-wide reorder. ordered_image_ids must be exactly the series'
pages; page_number is rewritten 1..N in that order."""
await self._require_series(series_tag_id)
ordered = self._clean_ids(ordered_image_ids)
current = set(await self._page_order(series_tag_id))
if set(ordered) != current or len(ordered) != len(current):
raise SeriesError(
"ordered image_ids must exactly match the series' 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.image_id == iid,
)
)
.values(page_number=idx)
)
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
"""Cover = the first page. Move the image to the front of the series."""
await self._require_series(series_tag_id)
pages = await self._page_order(series_tag_id)
if image_id not in pages:
raise SeriesError(f"image {image_id} is not in this series")
if pages and pages[0] != image_id:
await self.reorder(
series_tag_id,
[image_id] + [p for p in pages if p != image_id],
)
# ---- chapter dividers -------------------------------------------------
async def create_divider(
self,
series_tag_id: int,
anchor_image_id: int,
*,
title: str | None = None,
stated_part: int | None = None,
) -> dict:
"""Place a chapter divider before the page for anchor_image_id ("a new
chapter starts here"). One divider per page (UNIQUE anchor)."""
await self._require_series(series_tag_id)
page_id = await self._page_id_for(series_tag_id, anchor_image_id)
try:
async with self.session.begin_nested():
res = await self.session.execute(
pg_insert(SeriesChapter)
.values(
series_tag_id=series_tag_id,
anchor_page_id=page_id,
title=title,
stated_part=stated_part,
)
.returning(SeriesChapter.id)
)
divider_id = res.scalar_one()
except IntegrityError as exc:
raise SeriesError(
"a chapter divider already starts at that page"
) from exc
return {"id": divider_id, "anchor_image_id": anchor_image_id}
async def update_divider(
self,
series_tag_id: int,
divider_id: int,
*,
title: str | None = None,
stated_part: int | None = None,
anchor_image_id: int | None = None,
set_title: bool = False,
set_part: bool = False,
set_anchor: bool = False,
) -> None:
"""Partial divider edit. set_* flags say which fields to write (so None
can be written explicitly, e.g. clearing a stated_part). set_anchor moves
the divider to sit before a different page."""
await self._require_series(series_tag_id)
await self._require_divider(series_tag_id, divider_id)
values: dict = {}
if set_title:
values["title"] = title
if set_part:
values["stated_part"] = stated_part
if set_anchor:
if anchor_image_id is None:
raise SeriesError("anchor_image_id required to move a divider")
values["anchor_page_id"] = await self._page_id_for(
series_tag_id, anchor_image_id
)
if not values:
return
try:
async with self.session.begin_nested():
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == divider_id)
.values(**values)
)
except IntegrityError as exc:
raise SeriesError(
"a chapter divider already starts at that page"
) from exc
async def delete_divider(
self, series_tag_id: int, divider_id: int
) -> None:
"""Remove a divider — the chapter merges into the run above it. Pages
are untouched (dividers own nothing)."""
await self._require_series(series_tag_id)
await self._require_divider(series_tag_id, divider_id)
await self.session.execute(
SeriesChapter.__table__.delete().where(
SeriesChapter.id == divider_id
)
)
# ---- 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 and the post's images as its
flat 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 = rng[0] if rng else None
added = await self.add_images(
tag.id, image_ids, stated_pages=self._stated_map(image_ids, start),
)
return {
"series_tag_id": tag.id, "name": tag.name, "added": added,
}
async def add_post(self, series_tag_id: int, post_id: int) -> dict:
"""Case 2: STAGE a post's images as PENDING pages of an existing series
(#789 P2). They don't enter the ordered run yet — the operator drops junk
(text-free alts, bumpers) and places the keepers via place_pending, so
the series-global numbering stays clean. Stated pages are parsed from the
post when present."""
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")
# Skip images already in THIS series (placed or pending); move any in
# another series here as pending (image_id is UNIQUE).
existing = dict(
(
await self.session.execute(
select(SeriesPage.image_id, SeriesPage.series_tag_id)
.where(SeriesPage.image_id.in_(image_ids))
)
).all()
)
to_stage = [i for i in image_ids if existing.get(i) != series_tag_id]
if not to_stage:
return {"series_tag_id": series_tag_id, "staged": 0}
await self.session.execute(
SeriesPage.__table__.delete().where(
SeriesPage.image_id.in_(to_stage)
)
)
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
sp = self._stated_map(image_ids, rng[0] if rng else None) or {}
await self.session.execute(
pg_insert(SeriesPage).values(
[
{
"series_tag_id": series_tag_id,
"image_id": iid,
"status": "pending",
"page_number": None,
"stated_page": sp.get(iid),
}
for iid in to_stage
]
)
)
return {"series_tag_id": series_tag_id, "staged": len(to_stage)}
async def place_pending(
self,
series_tag_id: int,
image_ids: list[int],
before_image_id: int | None = None,
) -> int:
"""Move staged (pending) pages into the placed run — spliced in just
before `before_image_id` (a placed page), or appended at the end when
it's None. Page numbers are then rewritten 1..N across the run."""
await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids)
rows = dict(
(
await self.session.execute(
select(SeriesPage.image_id, SeriesPage.status).where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id.in_(ids),
)
)
)
).all()
)
staged = [i for i in ids if rows.get(i) == "pending"]
if not staged:
return 0
placed = await self._page_order(series_tag_id)
if before_image_id is not None and before_image_id not in placed:
raise SeriesError(
"before_image_id is not a placed page of this series"
)
new_order: list[int] = []
for iid in placed:
if iid == before_image_id:
new_order.extend(staged)
new_order.append(iid)
if before_image_id is None:
new_order.extend(staged)
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id.in_(staged),
)
)
.values(status="placed")
)
for idx, iid in enumerate(new_order, start=1):
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == iid,
)
)
.values(page_number=idx)
)
return len(staged)
# ---- 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, divider
+ page counts, a missing-Part flag, and last-updated. sort ∈
recent|name|size."""
# Page counts + most-recent page activity per series.
page_rows = (
await self.session.execute(
select(
SeriesPage.series_tag_id,
func.count().label("pages"),
func.max(SeriesPage.updated_at).label("updated_at"),
)
.where(SeriesPage.status == "placed")
.group_by(SeriesPage.series_tag_id)
)
).all()
page_count = {r.series_tag_id: r.pages for r in page_rows}
updated_by_series = {r.series_tag_id: r.updated_at for r in page_rows}
# Dividers per series (for count + missing-Part gap), ordered by the
# global position of their anchor page.
drows = (
await self.session.execute(
select(
SeriesChapter.series_tag_id,
SeriesChapter.id,
SeriesChapter.stated_part,
SeriesPage.page_number,
)
.join(SeriesPage, SeriesPage.id == SeriesChapter.anchor_page_id)
.order_by(
SeriesChapter.series_tag_id, SeriesPage.page_number.asc()
)
)
).all()
dividers_by_series: dict[int, list] = {}
for r in drows:
dividers_by_series.setdefault(r.series_tag_id, []).append(r)
# Cover = the first page (min page_number) of each 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=(
SeriesPage.page_number.asc(), SeriesPage.id.asc()
),
)
.label("rn"),
)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(SeriesPage.status == "placed")
.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
divs = dividers_by_series.get(t.id, [])
gap = bool(
self._part_gaps(
[{"id": d.id, "stated_part": d.stated_part} for d in divs]
)
)
artist = artist_by_id.get(cover.artist_id) if cover else None
updated = updated_by_series.get(t.id)
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(divs),
"page_count": page_count.get(t.id, 0),
"has_gap": gap,
"updated_at": updated.isoformat() if updated 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