diff --git a/backend/app/services/series_service.py b/backend/app/services/series_service.py new file mode 100644 index 0000000..bca77b3 --- /dev/null +++ b/backend/app/services/series_service.py @@ -0,0 +1,169 @@ +"""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, + ) + .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.sha256, r.mime), + } + 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) diff --git a/tests/test_series_service.py b/tests/test_series_service.py new file mode 100644 index 0000000..79f6810 --- /dev/null +++ b/tests/test_series_service.py @@ -0,0 +1,127 @@ +import pytest +from sqlalchemy import func, select + +from backend.app.models import ImageRecord, TagKind +from backend.app.models.series_page import SeriesPage +from backend.app.services.series_service import SeriesError, SeriesService +from backend.app.services.tag_service import TagService + +pytestmark = pytest.mark.integration + +_SEQ = 0 + + +async def _img(db): + global _SEQ + _SEQ += 1 + rec = ImageRecord( + path=f"/tmp/fc_ss_{_SEQ}.png", sha256=f"{_SEQ:064d}", + size_bytes=1, mime="image/png", origin="uploaded", + ) + db.add(rec) + await db.flush() + return rec.id + + +async def _order(db, sid): + rows = ( + await db.execute( + select(SeriesPage.image_id, SeriesPage.page_number) + .where(SeriesPage.series_tag_id == sid) + .order_by(SeriesPage.page_number) + ) + ).all() + return [r.image_id for r in rows] + + +@pytest.mark.asyncio +async def test_add_appends_in_order(db): + s = await TagService(db).find_or_create("S", TagKind.series) + i1, i2 = await _img(db), await _img(db) + n = await SeriesService(db).add_images(s.id, [i1, i2]) + assert n == 2 + assert await _order(db, s.id) == [i1, i2] + + +@pytest.mark.asyncio +async def test_add_moves_from_other_series(db): + svc = SeriesService(db) + a = await TagService(db).find_or_create("A", TagKind.series) + b = await TagService(db).find_or_create("B", TagKind.series) + i1 = await _img(db) + await svc.add_images(a.id, [i1]) + moved = await svc.add_images(b.id, [i1]) + assert moved == 1 + assert await _order(db, a.id) == [] + assert await _order(db, b.id) == [i1] + cnt = await db.scalar( + select(func.count()) + .select_from(SeriesPage).where(SeriesPage.image_id == i1) + ) + assert cnt == 1 + + +@pytest.mark.asyncio +async def test_add_idempotent_for_same_series(db): + s = await TagService(db).find_or_create("S2", TagKind.series) + i1 = await _img(db) + svc = SeriesService(db) + await svc.add_images(s.id, [i1]) + again = await svc.add_images(s.id, [i1]) + assert again == 0 + assert await _order(db, s.id) == [i1] + + +@pytest.mark.asyncio +async def test_remove(db): + s = await TagService(db).find_or_create("S3", TagKind.series) + i1, i2 = await _img(db), await _img(db) + svc = SeriesService(db) + await svc.add_images(s.id, [i1, i2]) + removed = await svc.remove_images(s.id, [i1]) + assert removed == 1 + assert await _order(db, s.id) == [i2] + + +@pytest.mark.asyncio +async def test_reorder_rewrites_and_rejects_mismatch(db): + s = await TagService(db).find_or_create("S4", TagKind.series) + i1, i2, i3 = await _img(db), await _img(db), await _img(db) + svc = SeriesService(db) + await svc.add_images(s.id, [i1, i2, i3]) + await svc.reorder(s.id, [i3, i1, i2]) + assert await _order(db, s.id) == [i3, i1, i2] + with pytest.raises(SeriesError): + await svc.reorder(s.id, [i1, i2]) + with pytest.raises(SeriesError): + await svc.reorder(s.id, [i1, i2, i3, 99999]) + + +@pytest.mark.asyncio +async def test_set_cover_makes_page_one_preserving_rest(db): + s = await TagService(db).find_or_create("S5", TagKind.series) + i1, i2, i3 = await _img(db), await _img(db), await _img(db) + svc = SeriesService(db) + await svc.add_images(s.id, [i1, i2, i3]) + await svc.set_cover(s.id, i3) + assert await _order(db, s.id) == [i3, i1, i2] + + +@pytest.mark.asyncio +async def test_list_pages_shape_and_guards(db): + svc = SeriesService(db) + s = await TagService(db).find_or_create("S6", TagKind.series) + i1 = await _img(db) + await svc.add_images(s.id, [i1]) + out = await svc.list_pages(s.id) + assert out["series"]["id"] == s.id + assert out["pages"][0]["image_id"] == i1 + assert out["pages"][0]["page_number"] == 1 + assert out["pages"][0]["thumbnail_url"] + g = await TagService(db).find_or_create("notser", TagKind.general) + with pytest.raises(SeriesError): + await svc.list_pages(g.id) + with pytest.raises(SeriesError): + await svc.add_images(g.id, [i1]) + with pytest.raises(SeriesError): + await svc.add_images(s.id, list(range(1, 502)))