feat(series): post→series flows + browse list — backend (FC-6.2)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s

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>
This commit is contained in:
2026-06-07 18:34:11 -04:00
parent 8ad40da145
commit db490e92df
5 changed files with 507 additions and 1 deletions
+56
View File
@@ -597,3 +597,59 @@ async def series_chapter_reorder_pages(tag_id: int, chapter_id: int):
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
# ---- browse list + post→series flows (FC-6.2) -----------------------------
@tags_bp.route("/series", methods=["GET"])
async def series_list():
args = request.args
sort = args.get("sort", "recent")
if sort not in ("recent", "name", "size"):
return jsonify({"error": "sort must be recent|name|size"}), 400
artist_id = None
if args.get("artist_id") is not None:
try:
artist_id = int(args["artist_id"])
except ValueError:
return jsonify({"error": "artist_id must be an integer"}), 400
async with get_session() as session:
rows = await SeriesService(session).list_series(
sort=sort, artist_id=artist_id
)
return jsonify({"series": rows})
@tags_bp.route("/series/from-post", methods=["POST"])
async def series_from_post():
body = await request.get_json()
post_id, err = _opt_int(body, "post_id")
if err:
return err
if post_id is None:
return jsonify({"error": "post_id required"}), 400
async with get_session() as session:
try:
out = await SeriesService(session).promote_post_to_series(post_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(out)
@tags_bp.route("/series/<int:tag_id>/add-post", methods=["POST"])
async def series_add_post(tag_id: int):
body = await request.get_json()
post_id, err = _opt_int(body, "post_id")
if err:
return err
if post_id is None:
return jsonify({"error": "post_id required"}), 400
async with get_session() as session:
try:
out = await SeriesService(session).add_post_as_chapter(tag_id, post_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(out)
@@ -0,0 +1,41 @@
"""Parse a stated page number/range out of a post's title/description (FC-6.2).
Artists often state where an installment sits in a series — "pages 9-12",
"Page 5", "[3/8]". We use that to order chapters and flag missing-page gaps.
This is best-effort: a confident match wins, otherwise we return None and the
caller falls back to capture/post-date order. Keep it conservative — a wrong
page number is worse than no page number — so matches require an explicit
page keyword (page/pg/pp) or a bracketed N/M fraction, never a bare number.
Supported forms (case-insensitive):
range "pages 9-12", "pg 912", "pp. 9 - 12" -> (9, 12)
fraction "page 3 of 8", "pg 3/8", "[3/8]", "(3/8)" -> (3, 3)
single "page 5", "pg 5", "pp 5" -> (5, 5)
"""
import re
# Page keyword: page/pages/pg/pgs/pp/pp. (NOT a bare "p" — too many false hits.)
_KW = r"(?:pages?|pgs?|pp\.?)"
_DASH = r"[-–—]"
_RANGE = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\s*{_DASH}\s*(\d{{1,4}})", re.I)
_OF = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\s*(?:of|/)\s*\d{{1,4}}\b", re.I)
_BRACKET = re.compile(r"[\[(]\s*(\d{1,4})\s*/\s*\d{1,4}\s*[\])]")
_SINGLE = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\b", re.I)
def parse_page_range(text: str | None) -> tuple[int, int] | None:
"""Return (start, end) or None. start <= end; a single page yields (n, n)."""
if not text:
return None
m = _RANGE.search(text)
if m:
a, b = int(m.group(1)), int(m.group(2))
return (a, b) if a <= b else (b, a)
for rx in (_OF, _BRACKET, _SINGLE):
m = rx.search(text)
if m:
n = int(m.group(1))
return (n, n)
return None
+256 -1
View File
@@ -11,10 +11,11 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ImageRecord, Tag, TagKind
from ..models 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):
@@ -478,3 +479,257 @@ class SeriesService:
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
+39
View File
@@ -0,0 +1,39 @@
"""Pure unit tests for the stated-page parser (FC-6.2)."""
from backend.app.services.page_number_parser import parse_page_range
def test_explicit_ranges():
assert parse_page_range("pages 9-12") == (9, 12)
assert parse_page_range("pg 912") == (9, 12) # en-dash
assert parse_page_range("pp. 9 - 12") == (9, 12)
assert parse_page_range("Chapter 2 (pages 1-4)") == (1, 4)
def test_reversed_range_is_normalized():
assert parse_page_range("pages 12-9") == (9, 12)
def test_single_page():
assert parse_page_range("Page 5") == (5, 5)
assert parse_page_range("pg 5") == (5, 5)
def test_fraction_forms_take_the_numerator():
assert parse_page_range("[3/8]") == (3, 3)
assert parse_page_range("(3/8)") == (3, 3)
assert parse_page_range("page 3 of 8") == (3, 3)
assert parse_page_range("pg 3/8") == (3, 3)
def test_no_page_keyword_is_none():
# Bare numbers / non-page words must not match (false positives are worse).
assert parse_page_range("My Comic Part Three") is None
assert parse_page_range("Episode 5") is None
assert parse_page_range("Released 2024") is None
assert parse_page_range("Part 3") is None
def test_empty_and_none():
assert parse_page_range("") is None
assert parse_page_range(None) is None
+115
View File
@@ -0,0 +1,115 @@
"""FC-6.2 — promote a post to a series + add a post as a chapter + browse list."""
import pytest
from backend.app.models import Artist, ImageRecord, Post, TagKind
from backend.app.services.series_service import SeriesService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
_SEQ = 0
async def _artist(db, name):
a = Artist(name=name, slug=name.lower().replace(" ", "-"))
db.add(a)
await db.flush()
return a
async def _post(db, artist, title, ext):
p = Post(artist_id=artist.id, external_post_id=ext, post_title=title)
db.add(p)
await db.flush()
return p
async def _post_images(db, post, artist, n):
global _SEQ
ids = []
for _ in range(n):
_SEQ += 1
rec = ImageRecord(
path=f"/tmp/fc_ps_{_SEQ}.png", sha256=f"{_SEQ:064d}",
size_bytes=1, mime="image/png", origin="downloaded",
primary_post_id=post.id, artist_id=artist.id,
)
db.add(rec)
await db.flush()
ids.append(rec.id)
return ids
@pytest.mark.asyncio
async def test_promote_post_to_series(db):
svc = SeriesService(db)
artist = await _artist(db, "Bikupan")
post = await _post(db, artist, "My Comic pages 1-3", "pp1")
imgs = await _post_images(db, post, artist, 3)
out = await svc.promote_post_to_series(post.id)
assert out["added"] == 3
assert out["name"] == "My Comic pages 1-3"
data = await svc.list_pages(out["series_tag_id"])
assert len(data["chapters"]) == 1
ch = data["chapters"][0]
assert ch["stated_page_start"] == 1
assert ch["stated_page_end"] == 3
assert [p["image_id"] for p in ch["pages"]] == imgs # capture order
assert [p["stated_page"] for p in ch["pages"]] == [1, 2, 3]
@pytest.mark.asyncio
async def test_promote_requires_images(db):
svc = SeriesService(db)
artist = await _artist(db, "Empty Artist")
post = await _post(db, artist, "No images", "pp2")
from backend.app.services.series_service import SeriesError
with pytest.raises(SeriesError):
await svc.promote_post_to_series(post.id)
@pytest.mark.asyncio
async def test_add_post_as_chapter_slots_by_stated_page(db):
svc = SeriesService(db)
artist = await _artist(db, "Story Artist")
sid = (await TagService(db).find_or_create("Story", TagKind.series)).id
# An existing chapter stating pages 9-12.
await svc.create_chapter(sid, stated_page_start=9, stated_page_end=12)
# A post stating pages 1-4 should slot BEFORE the 9-12 chapter.
post = await _post(db, artist, "Story pages 1-4", "pp3")
await _post_images(db, post, artist, 4)
out = await svc.add_post_as_chapter(sid, post.id)
assert out["added"] == 4
data = await svc.list_pages(sid)
chapters = data["chapters"]
assert chapters[0]["stated_page_start"] == 1 # new one is first
assert chapters[1]["stated_page_start"] == 9
# gap 5-8 flagged between them
assert data["gaps"][0]["start"] == 5
assert data["gaps"][0]["end"] == 8
@pytest.mark.asyncio
async def test_list_series_cards(db):
svc = SeriesService(db)
artist = await _artist(db, "Card Artist")
post = await _post(db, artist, "Card Comic pages 1-2", "pp4")
await _post_images(db, post, artist, 2)
out = await svc.promote_post_to_series(post.id)
rows = await svc.list_series()
card = next(r for r in rows if r["id"] == out["series_tag_id"])
assert card["name"] == "Card Comic pages 1-2"
assert card["chapter_count"] == 1
assert card["page_count"] == 2
assert card["artist_name"] == "Card Artist"
assert card["cover_thumbnail_url"]
assert card["has_gap"] is False
# artist filter keeps it; a different artist id drops it.
assert any(r["id"] == out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id))
assert all(r["id"] != out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id + 99999))