From db490e92df55138c58e2a1cb5ab2b0920d2d1717 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 7 Jun 2026 18:34:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(series):=20post=E2=86=92series=20flows=20+?= =?UTF-8?q?=20browse=20list=20=E2=80=94=20backend=20(FC-6.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) --- backend/app/api/tags.py | 56 +++++ backend/app/services/page_number_parser.py | 41 ++++ backend/app/services/series_service.py | 257 ++++++++++++++++++++- tests/test_page_number_parser.py | 39 ++++ tests/test_series_from_post.py | 115 +++++++++ 5 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/page_number_parser.py create mode 100644 tests/test_page_number_parser.py create mode 100644 tests/test_series_from_post.py diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index d77dca2..cfbe4b7 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -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//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) diff --git a/backend/app/services/page_number_parser.py b/backend/app/services/page_number_parser.py new file mode 100644 index 0000000..2c0c348 --- /dev/null +++ b/backend/app/services/page_number_parser.py @@ -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 9–12", "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 diff --git a/backend/app/services/series_service.py b/backend/app/services/series_service.py index 4ff19e9..7c943a1 100644 --- a/backend/app/services/series_service.py +++ b/backend/app/services/series_service.py @@ -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 diff --git a/tests/test_page_number_parser.py b/tests/test_page_number_parser.py new file mode 100644 index 0000000..f016c0d --- /dev/null +++ b/tests/test_page_number_parser.py @@ -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 9–12") == (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 diff --git a/tests/test_series_from_post.py b/tests/test_series_from_post.py new file mode 100644 index 0000000..abd63f8 --- /dev/null +++ b/tests/test_series_from_post.py @@ -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))