Files
FabledCurator/backend/app/services/series_service.py
T
2026-05-17 16:28:20 -04:00

172 lines
6.0 KiB
Python

"""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,
)
.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),
"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)