db490e92df
The post-aware on-ramp + the data behind the missing Series browse view. - page_number_parser: conservative stated-page parser (pages 9-12 / page 5 / [3/8] / 3 of 8), keyword-gated to avoid false positives. Pure + unit-tested. - SeriesService.promote_post_to_series: a self-contained post becomes its own series — series tag named after the post, one chapter, the post's images as pages (ordered by capture order; stated pages parsed from title/description). - SeriesService.add_post_as_chapter: append a post as the next chapter of an existing series, titled after the post and slotted by parsed page number (a "pages 1-4" post lands ahead of the "pages 9-12" chapter). - SeriesService.list_series: browse cards — cover thumb, artist, chapter/page counts, gap flag, last-updated; sort recent|name|size + filter by artist. - API: GET /api/series, POST /api/series/from-post, POST /api/series/<id>/add-post. - Resolver uses ImageRecord.primary_post_id (same linkage the posts feed renders). Frontend (Add-to-series control + Series view + nav) lands next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
736 lines
28 KiB
Python
736 lines
28 KiB
Python
"""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 Artist, ImageRecord, Post, Tag, TagKind
|
|
from ..models.series_chapter import SeriesChapter
|
|
from ..models.series_page import SeriesPage
|
|
from .gallery_service import 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 _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)
|
|
|
|
# ---- 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, one chapter, the post's
|
|
images as its 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, end = rng if rng else (None, None)
|
|
ch = await self.create_chapter(
|
|
tag.id, stated_page_start=start, stated_page_end=end,
|
|
)
|
|
added = await self.add_images(
|
|
tag.id, image_ids, chapter_id=ch["id"],
|
|
stated_pages=self._stated_map(image_ids, start),
|
|
)
|
|
return {
|
|
"series_tag_id": tag.id, "name": tag.name,
|
|
"chapter_id": ch["id"], "added": added,
|
|
}
|
|
|
|
async def add_post_as_chapter(
|
|
self, series_tag_id: int, post_id: int
|
|
) -> dict:
|
|
"""Case 2: append a post as the next chapter of an existing series. The
|
|
chapter is titled after the post and slotted by parsed page number when
|
|
present (else appended at the end)."""
|
|
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")
|
|
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
|
|
start, end = rng if rng else (None, None)
|
|
ch = await self.create_chapter(
|
|
series_tag_id, title=post.post_title or None,
|
|
stated_page_start=start, stated_page_end=end,
|
|
)
|
|
added = await self.add_images(
|
|
series_tag_id, image_ids, chapter_id=ch["id"],
|
|
stated_pages=self._stated_map(image_ids, start),
|
|
)
|
|
if start is not None:
|
|
await self._place_chapter_by_stated(series_tag_id, ch["id"], start)
|
|
return {
|
|
"series_tag_id": series_tag_id, "chapter_id": ch["id"],
|
|
"added": added,
|
|
}
|
|
|
|
async def _place_chapter_by_stated(
|
|
self, series_tag_id: int, chapter_id: int, start: int
|
|
) -> None:
|
|
"""Move `chapter_id` to sit before the first chapter whose stated start
|
|
is higher — so a post stating pages 5-8 lands ahead of the 9-12 chapter."""
|
|
rows = (
|
|
await self.session.execute(
|
|
select(SeriesChapter.id, SeriesChapter.stated_page_start)
|
|
.where(SeriesChapter.series_tag_id == series_tag_id)
|
|
.order_by(SeriesChapter.chapter_number.asc())
|
|
)
|
|
).all()
|
|
current = [r.id for r in rows]
|
|
others = [r for r in rows if r.id != chapter_id]
|
|
insert_at = len(others)
|
|
for idx, r in enumerate(others):
|
|
if r.stated_page_start is not None and r.stated_page_start > start:
|
|
insert_at = idx
|
|
break
|
|
new_order = [r.id for r in others]
|
|
new_order.insert(insert_at, chapter_id)
|
|
if new_order != current:
|
|
await self.reorder_chapters(series_tag_id, new_order)
|
|
|
|
# ---- 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, chapter
|
|
+ page counts, a gap flag, and last-updated. sort ∈ recent|name|size."""
|
|
# Page counts + most-recent activity per series.
|
|
page_rows = (
|
|
await self.session.execute(
|
|
select(
|
|
SeriesPage.series_tag_id,
|
|
func.count().label("pages"),
|
|
).group_by(SeriesPage.series_tag_id)
|
|
)
|
|
).all()
|
|
page_count = {r.series_tag_id: r.pages for r in page_rows}
|
|
|
|
ch_rows = (
|
|
await self.session.execute(
|
|
select(
|
|
SeriesChapter.series_tag_id,
|
|
SeriesChapter.id,
|
|
SeriesChapter.chapter_number,
|
|
SeriesChapter.stated_page_start,
|
|
SeriesChapter.stated_page_end,
|
|
SeriesChapter.updated_at,
|
|
)
|
|
.order_by(
|
|
SeriesChapter.series_tag_id,
|
|
SeriesChapter.chapter_number.asc(),
|
|
)
|
|
)
|
|
).all()
|
|
chapters_by_series: dict[int, list] = {}
|
|
updated_by_series: dict[int, object] = {}
|
|
for r in ch_rows:
|
|
chapters_by_series.setdefault(r.series_tag_id, []).append(r)
|
|
prev = updated_by_series.get(r.series_tag_id)
|
|
if prev is None or (r.updated_at and r.updated_at > prev):
|
|
updated_by_series[r.series_tag_id] = r.updated_at
|
|
|
|
# Cover = the (chapter_number, page_number)-minimum page per 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=(
|
|
SeriesChapter.chapter_number.asc(),
|
|
SeriesPage.page_number.asc(),
|
|
),
|
|
)
|
|
.label("rn"),
|
|
)
|
|
.join(SeriesChapter, SeriesChapter.id == SeriesPage.chapter_id)
|
|
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
|
.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
|
|
chs = chapters_by_series.get(t.id, [])
|
|
gap = bool(
|
|
self._gaps(
|
|
[
|
|
{
|
|
"id": c.id,
|
|
"stated_page_start": c.stated_page_start,
|
|
"stated_page_end": c.stated_page_end,
|
|
}
|
|
for c in chs
|
|
]
|
|
)
|
|
)
|
|
artist = artist_by_id.get(cover.artist_id) if cover else None
|
|
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(chs),
|
|
"page_count": page_count.get(t.id, 0),
|
|
"has_gap": gap,
|
|
"updated_at": (
|
|
updated_by_series.get(t.id).isoformat()
|
|
if updated_by_series.get(t.id)
|
|
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
|