"""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, 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 image_url, 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().nulls_last(), 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 _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 @staticmethod def _page_gaps(pages: list[dict]) -> list[dict]: """Missing-number gaps between consecutive NUMBERED placed pages — one entry per gap (e.g. pages 2 then 5 → one gap 3–4). The UI renders each as a single block whose edges accept a dropped page. Unnumbered pages (at the tail) don't participate.""" out: list[dict] = [] prev = None for p in pages: n = p["page_number"] if n is None: continue if prev is not None and n > prev["page_number"] + 1: out.append( { "after_image_id": prev["image_id"], "before_image_id": p["image_id"], "from": prev["page_number"] + 1, "to": n - 1, } ) prev = p 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().nulls_last(), 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": image_url(r.path), "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 ), "start_page": 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": image_url(r.path), } ) # Group start page = the post's parsed starting page (stored on the # staged pages); surfaced as the default for "Place from page N". if r.stated_page is not None: cur = grp["start_page"] grp["start_page"] = ( r.stated_page if cur is None else min(cur, r.stated_page) ) return { "series": {"id": tag.id, "name": tag.name}, "pages": pages, "gaps": self._page_gaps(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). Page numbers are operator-set and sparse, so we do # NOT renumber — removing a page just leaves a gap. return res.rowcount or 0 async def set_page_number( self, series_tag_id: int, image_id: int, page_number: int | None ) -> None: """Set one placed page's number — the operator's value: sparse, gaps allowed, or None to leave it unnumbered (sorts to the end). No other page is touched; reading order follows the numbers.""" await self._require_series(series_tag_id) res = await self.session.execute( update(SeriesPage) .where( and_( SeriesPage.series_tag_id == series_tag_id, SeriesPage.image_id == image_id, SeriesPage.status == "placed", ) ) .values(page_number=page_number) ) if (res.rowcount or 0) == 0: raise SeriesError( f"image {image_id} is not a placed page of this series" ) async def set_cover(self, series_tag_id: int, image_id: int) -> None: """Cover = the lowest-numbered page. Renumber the chosen image to sit just before the current minimum so it sorts first.""" await self._require_series(series_tag_id) await self._page_id_for(series_tag_id, image_id) # membership guard min_other = await self.session.scalar( select(func.min(SeriesPage.page_number)).where( and_( SeriesPage.series_tag_id == series_tag_id, SeriesPage.status == "placed", SeriesPage.image_id != image_id, ) ) ) new_pn = (min_other - 1) if min_other is not None else 1 await self.session.execute( update(SeriesPage) .where( and_( SeriesPage.series_tag_id == series_tag_id, SeriesPage.image_id == image_id, ) ) .values(page_number=new_pn) ) # ---- 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) ) ) # Store the post's parsed START page on every staged page (constant), so # the group's start survives junk removal and seeds "Place from page N". rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}") start = rng[0] if rng else None await self.session.execute( pg_insert(SeriesPage).values( [ { "series_tag_id": series_tag_id, "image_id": iid, "status": "pending", "page_number": None, "stated_page": start, } 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], start_page: int | None = None, ) -> int: """Place staged (pending) pages into the run, numbering them sequentially from `start_page` in the given order (start_page, start_page+1, …). With start_page None they're placed unnumbered (sorting to the tail). Drop the junk BEFORE placing so the numbers line up; only pending pages are touched, and other pages keep their numbers.""" 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() ) # Preserve the caller's order, restricted to actually-pending pages. staged = [i for i in ids if rows.get(i) == "pending"] if not staged: return 0 for offset, iid in enumerate(staged): await self.session.execute( update(SeriesPage) .where( and_( SeriesPage.series_tag_id == series_tag_id, SeriesPage.image_id == iid, ) ) .values( status="placed", page_number=( start_page + offset if start_page is not None else None ), ) ) 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