"""Series = a series-kind Tag + ordered series_page membership. 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_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 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("image_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 _member_order(self, series_tag_id: int) -> list[int]: rows = ( await self.session.execute( select(SeriesPage.image_id) .where(SeriesPage.series_tag_id == series_tag_id) .order_by(SeriesPage.page_number.asc()) ) ).scalars().all() return list(rows) 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, 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()) ) ).all() 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 ], } async def add_images( self, series_tag_id: int, image_ids: list[int] ) -> int: 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() ) # 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"). 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.series_tag_id == series_tag_id) ) ) or 0 await self.session.execute( pg_insert(SeriesPage).values( [ { "series_tag_id": series_tag_id, "image_id": iid, "page_number": max_pn + offset, } 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( self, series_tag_id: int, ordered_image_ids: list[int] ) -> None: await self._require_series(series_tag_id) ordered = self._clean_ids(ordered_image_ids) current = set(await self._member_order(series_tag_id)) if set(ordered) != current or len(ordered) != len(current): raise SeriesError( "ordered image_ids must exactly match series membership" ) 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: await self._require_series(series_tag_id) order = await self._member_order(series_tag_id) if image_id not in order: 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)