"""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 ImageRecord, Tag, TagKind from ..models.series_chapter import SeriesChapter from ..models.series_page import SeriesPage from .gallery_service import thumbnail_url 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)