7bb765b6ed
Add-from-post no longer appends straight into the run — it STAGES the post's
pages as pending (per-page status; page_number NULL), grouped by source post,
so the operator drops junk (text-free alts, bumpers) and places the keepers
into the sequence with clean series-global numbering.
- migration 0048: series_page.status ('placed' default | 'pending') + nullable
page_number.
- series_service: placed/pending split everywhere (list_pages returns the
placed run + a `pending` section grouped by source post; reorder/cover/
list_series operate on placed only); add_post stages pending; new
place_pending(image_ids, before_image_id=None) flips pending→placed spliced
before a page (or appended) and renumbers; junk removal reuses remove_images.
- api/tags: /add-post now returns staged count; new POST /series/<id>/pending/
place.
- frontend: PostSeriesMenu navigates to the series after staging; seriesManage
store surfaces `pending` + placePending; SeriesManageView gains a pending
tray (per-post groups, place-all / place-one / drop-junk).
- tests: pending staging, place (append + insert-before), ignore-already-
placed, drop-junk, route guard; updated add_post + match-accept expectations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
782 lines
30 KiB
Python
782 lines
30 KiB
Python
"""Series = a series-kind Tag + ONE flat, series-global ordered run of pages,
|
|
with optional cosmetic chapter DIVIDERS over it (FC-6.x reframe, #789).
|
|
|
|
Reading order is series_page.page_number alone (rewritten 1..N wholesale on any
|
|
reorder; page_number is not unique, so a reorder never transiently collides). A
|
|
chapter is NOT a container — it is a labeled divider anchored to the page that
|
|
begins it (series_chapter.anchor_page_id); a page's chapter is derived as the
|
|
nearest preceding divider. An image lives in at most one series
|
|
(series_page.image_id is UNIQUE).
|
|
|
|
All mutations are Core/set-based and run in the request transaction.
|
|
"""
|
|
|
|
from sqlalchemy import and_, func, select, text, update
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.exc import IntegrityError
|
|
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 _page_order(self, series_tag_id: int) -> list[int]:
|
|
"""The series' PLACED image_ids in reading order (series-global
|
|
page_number). Pending (staged) pages are excluded — they have no order
|
|
yet."""
|
|
rows = (
|
|
await self.session.execute(
|
|
select(SeriesPage.image_id)
|
|
.where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.status == "placed",
|
|
)
|
|
)
|
|
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
|
|
)
|
|
).scalars().all()
|
|
return list(rows)
|
|
|
|
async def _page_id_for(self, series_tag_id: int, image_id: int) -> int:
|
|
"""series_page.id for an image in the series (raises if not a member)."""
|
|
pid = await self.session.scalar(
|
|
select(SeriesPage.id).where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.image_id == image_id,
|
|
)
|
|
)
|
|
)
|
|
if pid is None:
|
|
raise SeriesError(f"image {image_id} is not in this series")
|
|
return pid
|
|
|
|
async def _renumber(self, series_tag_id: int) -> None:
|
|
"""Compact page_number to 1..N preserving current order (set-based)."""
|
|
await self.session.execute(
|
|
text(
|
|
"""
|
|
WITH ordered AS (
|
|
SELECT id, ROW_NUMBER() OVER (
|
|
ORDER BY page_number, id
|
|
) AS rn
|
|
FROM series_page WHERE series_tag_id = :sid
|
|
)
|
|
UPDATE series_page sp SET page_number = ordered.rn
|
|
FROM ordered
|
|
WHERE sp.id = ordered.id AND sp.page_number <> ordered.rn
|
|
"""
|
|
),
|
|
{"sid": series_tag_id},
|
|
)
|
|
|
|
async def _require_divider(self, series_tag_id: int, divider_id: int):
|
|
row = (
|
|
await self.session.execute(
|
|
select(SeriesChapter).where(
|
|
and_(
|
|
SeriesChapter.id == divider_id,
|
|
SeriesChapter.series_tag_id == series_tag_id,
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
raise SeriesError(
|
|
f"chapter {divider_id} is not in series {series_tag_id}"
|
|
)
|
|
return row
|
|
|
|
# ---- read -------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _part_gaps(dividers: list[dict]) -> list[dict]:
|
|
"""Missing-Part hints between consecutive dividers whose stated_part
|
|
numbers jump by more than 1 (e.g. Part 1 then Part 3). Only dividers
|
|
that carry a stated_part participate."""
|
|
out: list[dict] = []
|
|
prev = None
|
|
for d in dividers:
|
|
cur = d["stated_part"]
|
|
if cur is None:
|
|
continue
|
|
if prev is not None and cur > prev["stated_part"] + 1:
|
|
out.append(
|
|
{
|
|
"after_divider_id": prev["id"],
|
|
"start": prev["stated_part"] + 1,
|
|
"end": cur - 1,
|
|
}
|
|
)
|
|
prev = d
|
|
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(
|
|
SeriesPage.image_id,
|
|
SeriesPage.page_number,
|
|
SeriesPage.stated_page,
|
|
ImageRecord.sha256,
|
|
ImageRecord.mime,
|
|
ImageRecord.path,
|
|
ImageRecord.thumbnail_path,
|
|
ImageRecord.primary_post_id,
|
|
Post.post_title,
|
|
)
|
|
.select_from(SeriesPage)
|
|
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
|
.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
|
|
.where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.status == "placed",
|
|
)
|
|
)
|
|
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
|
|
)
|
|
).all()
|
|
pages = [
|
|
{
|
|
"image_id": r.image_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]}",
|
|
"source_post": (
|
|
{"id": r.primary_post_id, "title": r.post_title}
|
|
if r.primary_post_id is not None
|
|
else None
|
|
),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
# Dividers, ordered by the global position of their anchor page.
|
|
drows = (
|
|
await self.session.execute(
|
|
select(
|
|
SeriesChapter.id,
|
|
SeriesChapter.title,
|
|
SeriesChapter.stated_part,
|
|
SeriesPage.image_id.label("anchor_image_id"),
|
|
SeriesPage.page_number,
|
|
)
|
|
.join(SeriesPage, SeriesPage.id == SeriesChapter.anchor_page_id)
|
|
.where(SeriesChapter.series_tag_id == series_tag_id)
|
|
.order_by(SeriesPage.page_number.asc())
|
|
)
|
|
).all()
|
|
dividers = [
|
|
{
|
|
"id": r.id,
|
|
"anchor_image_id": r.anchor_image_id,
|
|
"page_number": r.page_number,
|
|
"title": r.title,
|
|
"stated_part": r.stated_part,
|
|
}
|
|
for r in drows
|
|
]
|
|
|
|
# Pending (staged-from-post) pages, grouped by their source post so the
|
|
# operator can drop junk and place the keepers into the run.
|
|
prows = (
|
|
await self.session.execute(
|
|
select(
|
|
SeriesPage.image_id,
|
|
SeriesPage.stated_page,
|
|
ImageRecord.sha256,
|
|
ImageRecord.mime,
|
|
ImageRecord.path,
|
|
ImageRecord.thumbnail_path,
|
|
ImageRecord.primary_post_id,
|
|
Post.post_title,
|
|
)
|
|
.select_from(SeriesPage)
|
|
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
|
.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
|
|
.where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.status == "pending",
|
|
)
|
|
)
|
|
.order_by(
|
|
ImageRecord.primary_post_id.asc().nulls_last(),
|
|
SeriesPage.id.asc(),
|
|
)
|
|
)
|
|
).all()
|
|
pending_groups: list[dict] = []
|
|
by_post: dict = {}
|
|
for r in prows:
|
|
grp = by_post.get(r.primary_post_id)
|
|
if grp is None:
|
|
grp = {
|
|
"post": (
|
|
{"id": r.primary_post_id, "title": r.post_title}
|
|
if r.primary_post_id is not None
|
|
else None
|
|
),
|
|
"pages": [],
|
|
}
|
|
by_post[r.primary_post_id] = grp
|
|
pending_groups.append(grp)
|
|
grp["pages"].append(
|
|
{
|
|
"image_id": r.image_id,
|
|
"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]}",
|
|
}
|
|
)
|
|
|
|
return {
|
|
"series": {"id": tag.id, "name": tag.name},
|
|
"pages": pages,
|
|
"dividers": dividers,
|
|
"pending": pending_groups,
|
|
"part_gaps": self._part_gaps(dividers),
|
|
}
|
|
|
|
# ---- pages ------------------------------------------------------------
|
|
|
|
async def add_images(
|
|
self,
|
|
series_tag_id: int,
|
|
image_ids: list[int],
|
|
stated_pages: dict[int, int] | None = None,
|
|
) -> int:
|
|
"""Append images as pages at the END of the series' flat run. 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)
|
|
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). A
|
|
# dropped page that anchored a divider in another series cascades it away.
|
|
await self.session.execute(
|
|
SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add))
|
|
)
|
|
max_pn = await self._max_placed_page_number(series_tag_id)
|
|
sp = stated_pages or {}
|
|
await self.session.execute(
|
|
pg_insert(SeriesPage).values(
|
|
[
|
|
{
|
|
"series_tag_id": series_tag_id,
|
|
"image_id": iid,
|
|
"status": "placed",
|
|
"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 _max_placed_page_number(self, series_tag_id: int) -> int:
|
|
return (
|
|
await self.session.scalar(
|
|
select(
|
|
func.coalesce(func.max(SeriesPage.page_number), 0)
|
|
).where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.status == "placed",
|
|
)
|
|
)
|
|
)
|
|
) or 0
|
|
|
|
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),
|
|
)
|
|
)
|
|
)
|
|
# Dividers anchored to a removed page cascade away (chapter merges into
|
|
# the preceding run); compact the remaining pages back to 1..N.
|
|
await self._renumber(series_tag_id)
|
|
return res.rowcount or 0
|
|
|
|
async def reorder(
|
|
self, series_tag_id: int, ordered_image_ids: list[int]
|
|
) -> None:
|
|
"""Series-wide reorder. ordered_image_ids must be exactly the series'
|
|
pages; page_number is rewritten 1..N in that order."""
|
|
await self._require_series(series_tag_id)
|
|
ordered = self._clean_ids(ordered_image_ids)
|
|
current = set(await self._page_order(series_tag_id))
|
|
if set(ordered) != current or len(ordered) != len(current):
|
|
raise SeriesError(
|
|
"ordered image_ids must exactly match the series' pages"
|
|
)
|
|
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:
|
|
"""Cover = the first page. Move the image to the front of the series."""
|
|
await self._require_series(series_tag_id)
|
|
pages = await self._page_order(series_tag_id)
|
|
if image_id not in pages:
|
|
raise SeriesError(f"image {image_id} is not in this series")
|
|
if pages and pages[0] != image_id:
|
|
await self.reorder(
|
|
series_tag_id,
|
|
[image_id] + [p for p in pages if p != image_id],
|
|
)
|
|
|
|
# ---- chapter dividers -------------------------------------------------
|
|
|
|
async def create_divider(
|
|
self,
|
|
series_tag_id: int,
|
|
anchor_image_id: int,
|
|
*,
|
|
title: str | None = None,
|
|
stated_part: int | None = None,
|
|
) -> dict:
|
|
"""Place a chapter divider before the page for anchor_image_id ("a new
|
|
chapter starts here"). One divider per page (UNIQUE anchor)."""
|
|
await self._require_series(series_tag_id)
|
|
page_id = await self._page_id_for(series_tag_id, anchor_image_id)
|
|
try:
|
|
async with self.session.begin_nested():
|
|
res = await self.session.execute(
|
|
pg_insert(SeriesChapter)
|
|
.values(
|
|
series_tag_id=series_tag_id,
|
|
anchor_page_id=page_id,
|
|
title=title,
|
|
stated_part=stated_part,
|
|
)
|
|
.returning(SeriesChapter.id)
|
|
)
|
|
divider_id = res.scalar_one()
|
|
except IntegrityError as exc:
|
|
raise SeriesError(
|
|
"a chapter divider already starts at that page"
|
|
) from exc
|
|
return {"id": divider_id, "anchor_image_id": anchor_image_id}
|
|
|
|
async def update_divider(
|
|
self,
|
|
series_tag_id: int,
|
|
divider_id: int,
|
|
*,
|
|
title: str | None = None,
|
|
stated_part: int | None = None,
|
|
anchor_image_id: int | None = None,
|
|
set_title: bool = False,
|
|
set_part: bool = False,
|
|
set_anchor: bool = False,
|
|
) -> None:
|
|
"""Partial divider edit. set_* flags say which fields to write (so None
|
|
can be written explicitly, e.g. clearing a stated_part). set_anchor moves
|
|
the divider to sit before a different page."""
|
|
await self._require_series(series_tag_id)
|
|
await self._require_divider(series_tag_id, divider_id)
|
|
values: dict = {}
|
|
if set_title:
|
|
values["title"] = title
|
|
if set_part:
|
|
values["stated_part"] = stated_part
|
|
if set_anchor:
|
|
if anchor_image_id is None:
|
|
raise SeriesError("anchor_image_id required to move a divider")
|
|
values["anchor_page_id"] = await self._page_id_for(
|
|
series_tag_id, anchor_image_id
|
|
)
|
|
if not values:
|
|
return
|
|
try:
|
|
async with self.session.begin_nested():
|
|
await self.session.execute(
|
|
update(SeriesChapter)
|
|
.where(SeriesChapter.id == divider_id)
|
|
.values(**values)
|
|
)
|
|
except IntegrityError as exc:
|
|
raise SeriesError(
|
|
"a chapter divider already starts at that page"
|
|
) from exc
|
|
|
|
async def delete_divider(
|
|
self, series_tag_id: int, divider_id: int
|
|
) -> None:
|
|
"""Remove a divider — the chapter merges into the run above it. Pages
|
|
are untouched (dividers own nothing)."""
|
|
await self._require_series(series_tag_id)
|
|
await self._require_divider(series_tag_id, divider_id)
|
|
await self.session.execute(
|
|
SeriesChapter.__table__.delete().where(
|
|
SeriesChapter.id == divider_id
|
|
)
|
|
)
|
|
|
|
# ---- 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 and the post's images as its
|
|
flat 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 = rng[0] if rng else None
|
|
added = await self.add_images(
|
|
tag.id, image_ids, stated_pages=self._stated_map(image_ids, start),
|
|
)
|
|
return {
|
|
"series_tag_id": tag.id, "name": tag.name, "added": added,
|
|
}
|
|
|
|
async def add_post(self, series_tag_id: int, post_id: int) -> dict:
|
|
"""Case 2: STAGE a post's images as PENDING pages of an existing series
|
|
(#789 P2). They don't enter the ordered run yet — the operator drops junk
|
|
(text-free alts, bumpers) and places the keepers via place_pending, so
|
|
the series-global numbering stays clean. Stated pages are parsed from the
|
|
post when present."""
|
|
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")
|
|
# Skip images already in THIS series (placed or pending); move any in
|
|
# another series here as pending (image_id is UNIQUE).
|
|
existing = dict(
|
|
(
|
|
await self.session.execute(
|
|
select(SeriesPage.image_id, SeriesPage.series_tag_id)
|
|
.where(SeriesPage.image_id.in_(image_ids))
|
|
)
|
|
).all()
|
|
)
|
|
to_stage = [i for i in image_ids if existing.get(i) != series_tag_id]
|
|
if not to_stage:
|
|
return {"series_tag_id": series_tag_id, "staged": 0}
|
|
await self.session.execute(
|
|
SeriesPage.__table__.delete().where(
|
|
SeriesPage.image_id.in_(to_stage)
|
|
)
|
|
)
|
|
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
|
|
sp = self._stated_map(image_ids, rng[0] if rng else None) or {}
|
|
await self.session.execute(
|
|
pg_insert(SeriesPage).values(
|
|
[
|
|
{
|
|
"series_tag_id": series_tag_id,
|
|
"image_id": iid,
|
|
"status": "pending",
|
|
"page_number": None,
|
|
"stated_page": sp.get(iid),
|
|
}
|
|
for iid in to_stage
|
|
]
|
|
)
|
|
)
|
|
return {"series_tag_id": series_tag_id, "staged": len(to_stage)}
|
|
|
|
async def place_pending(
|
|
self,
|
|
series_tag_id: int,
|
|
image_ids: list[int],
|
|
before_image_id: int | None = None,
|
|
) -> int:
|
|
"""Move staged (pending) pages into the placed run — spliced in just
|
|
before `before_image_id` (a placed page), or appended at the end when
|
|
it's None. Page numbers are then rewritten 1..N across the run."""
|
|
await self._require_series(series_tag_id)
|
|
ids = self._clean_ids(image_ids)
|
|
rows = dict(
|
|
(
|
|
await self.session.execute(
|
|
select(SeriesPage.image_id, SeriesPage.status).where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.image_id.in_(ids),
|
|
)
|
|
)
|
|
)
|
|
).all()
|
|
)
|
|
staged = [i for i in ids if rows.get(i) == "pending"]
|
|
if not staged:
|
|
return 0
|
|
placed = await self._page_order(series_tag_id)
|
|
if before_image_id is not None and before_image_id not in placed:
|
|
raise SeriesError(
|
|
"before_image_id is not a placed page of this series"
|
|
)
|
|
new_order: list[int] = []
|
|
for iid in placed:
|
|
if iid == before_image_id:
|
|
new_order.extend(staged)
|
|
new_order.append(iid)
|
|
if before_image_id is None:
|
|
new_order.extend(staged)
|
|
await self.session.execute(
|
|
update(SeriesPage)
|
|
.where(
|
|
and_(
|
|
SeriesPage.series_tag_id == series_tag_id,
|
|
SeriesPage.image_id.in_(staged),
|
|
)
|
|
)
|
|
.values(status="placed")
|
|
)
|
|
for idx, iid in enumerate(new_order, 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)
|
|
)
|
|
return len(staged)
|
|
|
|
# ---- 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, divider
|
|
+ page counts, a missing-Part flag, and last-updated. sort ∈
|
|
recent|name|size."""
|
|
# Page counts + most-recent page activity per series.
|
|
page_rows = (
|
|
await self.session.execute(
|
|
select(
|
|
SeriesPage.series_tag_id,
|
|
func.count().label("pages"),
|
|
func.max(SeriesPage.updated_at).label("updated_at"),
|
|
)
|
|
.where(SeriesPage.status == "placed")
|
|
.group_by(SeriesPage.series_tag_id)
|
|
)
|
|
).all()
|
|
page_count = {r.series_tag_id: r.pages for r in page_rows}
|
|
updated_by_series = {r.series_tag_id: r.updated_at for r in page_rows}
|
|
|
|
# Dividers per series (for count + missing-Part gap), ordered by the
|
|
# global position of their anchor page.
|
|
drows = (
|
|
await self.session.execute(
|
|
select(
|
|
SeriesChapter.series_tag_id,
|
|
SeriesChapter.id,
|
|
SeriesChapter.stated_part,
|
|
SeriesPage.page_number,
|
|
)
|
|
.join(SeriesPage, SeriesPage.id == SeriesChapter.anchor_page_id)
|
|
.order_by(
|
|
SeriesChapter.series_tag_id, SeriesPage.page_number.asc()
|
|
)
|
|
)
|
|
).all()
|
|
dividers_by_series: dict[int, list] = {}
|
|
for r in drows:
|
|
dividers_by_series.setdefault(r.series_tag_id, []).append(r)
|
|
|
|
# Cover = the first page (min page_number) of each 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=(
|
|
SeriesPage.page_number.asc(), SeriesPage.id.asc()
|
|
),
|
|
)
|
|
.label("rn"),
|
|
)
|
|
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
|
.where(SeriesPage.status == "placed")
|
|
.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
|
|
divs = dividers_by_series.get(t.id, [])
|
|
gap = bool(
|
|
self._part_gaps(
|
|
[{"id": d.id, "stated_part": d.stated_part} for d in divs]
|
|
)
|
|
)
|
|
artist = artist_by_id.get(cover.artist_id) if cover else None
|
|
updated = updated_by_series.get(t.id)
|
|
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(divs),
|
|
"page_count": page_count.get(t.id, 0),
|
|
"has_gap": gap,
|
|
"updated_at": updated.isoformat() if updated 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
|