013b9d7f06
Replaces the auto-renumbered 1..N position key with operator-OWNED page numbers: sparse, gaps allowed, editable, never auto-renumbered. Order follows the numbers; unnumbered pages sort to the tail. This is the fix for the model that clobbered hand-set numbers on the flatten — numbers are now data, not a derived sequence. - series_service: drop the renumber-on-reorder/remove; order by page_number NULLS LAST; new set_page_number(image_id, n|None); list_pages returns `gaps` (one entry per missing-number run) + each pending group's parsed `start_page`; set_cover renumbers below the current min; place_pending(image_ids, start_page) numbers placed pages sequentially from the start (drop junk first → numbers line up); add_post stamps the parsed start on staged pages. - api/tags: POST /series/<id>/pages/number (set one page's number); /pending/ place takes start_page; removed /reorder. - frontend: per-card editable number input; one gap block per gap with drop-on-edge to assign the adjacent number (middle → type); append drop zone; pending tray gets a "from page N" field + "Place from page N". - tests reworked: sparse numbers + gaps, place-from-start, set-page-number route. No migration; nothing destructive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
803 lines
30 KiB
Python
803 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, 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().nulls_last(),
|
||
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 _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
|
||
|
||
@staticmethod
|
||
def _page_gaps(pages: list[dict]) -> list[dict]:
|
||
"""Missing-number gaps between consecutive NUMBERED placed pages — one
|
||
entry per gap (e.g. pages 2 then 5 → one gap 3–4). The UI renders each as
|
||
a single block whose edges accept a dropped page. Unnumbered pages (at
|
||
the tail) don't participate."""
|
||
out: list[dict] = []
|
||
prev = None
|
||
for p in pages:
|
||
n = p["page_number"]
|
||
if n is None:
|
||
continue
|
||
if prev is not None and n > prev["page_number"] + 1:
|
||
out.append(
|
||
{
|
||
"after_image_id": prev["image_id"],
|
||
"before_image_id": p["image_id"],
|
||
"from": prev["page_number"] + 1,
|
||
"to": n - 1,
|
||
}
|
||
)
|
||
prev = p
|
||
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().nulls_last(),
|
||
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
|
||
),
|
||
"start_page": 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]}",
|
||
}
|
||
)
|
||
# Group start page = the post's parsed starting page (stored on the
|
||
# staged pages); surfaced as the default for "Place from page N".
|
||
if r.stated_page is not None:
|
||
cur = grp["start_page"]
|
||
grp["start_page"] = (
|
||
r.stated_page if cur is None else min(cur, r.stated_page)
|
||
)
|
||
|
||
return {
|
||
"series": {"id": tag.id, "name": tag.name},
|
||
"pages": pages,
|
||
"gaps": self._page_gaps(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). Page numbers are operator-set and sparse, so we do
|
||
# NOT renumber — removing a page just leaves a gap.
|
||
return res.rowcount or 0
|
||
|
||
async def set_page_number(
|
||
self, series_tag_id: int, image_id: int, page_number: int | None
|
||
) -> None:
|
||
"""Set one placed page's number — the operator's value: sparse, gaps
|
||
allowed, or None to leave it unnumbered (sorts to the end). No other page
|
||
is touched; reading order follows the numbers."""
|
||
await self._require_series(series_tag_id)
|
||
res = await self.session.execute(
|
||
update(SeriesPage)
|
||
.where(
|
||
and_(
|
||
SeriesPage.series_tag_id == series_tag_id,
|
||
SeriesPage.image_id == image_id,
|
||
SeriesPage.status == "placed",
|
||
)
|
||
)
|
||
.values(page_number=page_number)
|
||
)
|
||
if (res.rowcount or 0) == 0:
|
||
raise SeriesError(
|
||
f"image {image_id} is not a placed page of this series"
|
||
)
|
||
|
||
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
|
||
"""Cover = the lowest-numbered page. Renumber the chosen image to sit
|
||
just before the current minimum so it sorts first."""
|
||
await self._require_series(series_tag_id)
|
||
await self._page_id_for(series_tag_id, image_id) # membership guard
|
||
min_other = await self.session.scalar(
|
||
select(func.min(SeriesPage.page_number)).where(
|
||
and_(
|
||
SeriesPage.series_tag_id == series_tag_id,
|
||
SeriesPage.status == "placed",
|
||
SeriesPage.image_id != image_id,
|
||
)
|
||
)
|
||
)
|
||
new_pn = (min_other - 1) if min_other is not None else 1
|
||
await self.session.execute(
|
||
update(SeriesPage)
|
||
.where(
|
||
and_(
|
||
SeriesPage.series_tag_id == series_tag_id,
|
||
SeriesPage.image_id == image_id,
|
||
)
|
||
)
|
||
.values(page_number=new_pn)
|
||
)
|
||
|
||
# ---- 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)
|
||
)
|
||
)
|
||
# Store the post's parsed START page on every staged page (constant), so
|
||
# the group's start survives junk removal and seeds "Place from page N".
|
||
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
|
||
start = rng[0] if rng else None
|
||
await self.session.execute(
|
||
pg_insert(SeriesPage).values(
|
||
[
|
||
{
|
||
"series_tag_id": series_tag_id,
|
||
"image_id": iid,
|
||
"status": "pending",
|
||
"page_number": None,
|
||
"stated_page": start,
|
||
}
|
||
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],
|
||
start_page: int | None = None,
|
||
) -> int:
|
||
"""Place staged (pending) pages into the run, numbering them sequentially
|
||
from `start_page` in the given order (start_page, start_page+1, …). With
|
||
start_page None they're placed unnumbered (sorting to the tail). Drop the
|
||
junk BEFORE placing so the numbers line up; only pending pages are
|
||
touched, and other pages keep their numbers."""
|
||
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()
|
||
)
|
||
# Preserve the caller's order, restricted to actually-pending pages.
|
||
staged = [i for i in ids if rows.get(i) == "pending"]
|
||
if not staged:
|
||
return 0
|
||
for offset, iid in enumerate(staged):
|
||
await self.session.execute(
|
||
update(SeriesPage)
|
||
.where(
|
||
and_(
|
||
SeriesPage.series_tag_id == series_tag_id,
|
||
SeriesPage.image_id == iid,
|
||
)
|
||
)
|
||
.values(
|
||
status="placed",
|
||
page_number=(
|
||
start_page + offset if start_page is not None else None
|
||
),
|
||
)
|
||
)
|
||
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
|