feat(series): post→series flows + browse list — backend (FC-6.2)
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:
@@ -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 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user