Files
bvandeusen 7c4b24c80d
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m17s
fix(images): percent-encode original-image URLs ('#' in paths 404'd)
An image whose on-disk path contains '#' (post folders like 'BLUE#59')
served its hash-named thumbnail fine but 404'd the original: the unencoded
'#' in image_url was parsed by the browser as a URL fragment, so
'#59/01_timelapse.jpg' never reached the /images route. Add a shared
image_url(path) helper that percent-encodes the path (safe='/') and route
the 3 raw builders (gallery detail + 2 in series) through it. Not a
cleanup-tool deletion — the file is on disk; only the URL was wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:36:44 -04:00

803 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 image_url, 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 34). 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": image_url(r.path),
"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": image_url(r.path),
}
)
# 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