Merge pull request 'FC-6 series authoring + backup/NFS hardening + UX fixes' (#83) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m43s
CI / integration (push) Successful in 3m6s

This commit was merged in pull request #83.
This commit is contained in:
2026-06-07 19:31:04 -04:00
40 changed files with 3313 additions and 176 deletions
+108
View File
@@ -0,0 +1,108 @@
"""series chapters: chapter layer over series_page (FC-6.1)
Revision ID: 0040
Revises: 0039
Create Date: 2026-06-07
A series (Tag kind='series') gains an ordered chapter layer. Reading order
becomes (series_chapter.chapter_number, series_page.page_number). Every existing
series is backfilled into a single auto-chapter (chapter_number=1) holding its
current flat pages, so no data is lost and the old flat ordering is preserved.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0040"
down_revision: Union[str, None] = "0039"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"series_chapter",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"series_tag_id",
sa.Integer,
sa.ForeignKey("tag.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("chapter_number", sa.Integer, nullable=False),
sa.Column("title", sa.Text, nullable=True),
sa.Column(
"is_placeholder", sa.Boolean, nullable=False, server_default="false"
),
sa.Column("stated_page_start", sa.Integer, nullable=True),
sa.Column("stated_page_end", sa.Integer, nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
op.create_index(
"ix_series_chapter_series_tag_id", "series_chapter", ["series_tag_id"]
)
# New columns on series_page; chapter_id starts nullable so we can backfill.
op.add_column(
"series_page", sa.Column("chapter_id", sa.Integer, nullable=True)
)
op.add_column(
"series_page", sa.Column("stated_page", sa.Integer, nullable=True)
)
conn = op.get_bind()
# One auto-chapter per existing series (any series_tag_id present in pages).
conn.execute(
sa.text(
"INSERT INTO series_chapter "
"(series_tag_id, chapter_number, is_placeholder, created_at, updated_at) "
"SELECT DISTINCT series_tag_id, 1, false, now(), now() "
"FROM series_page"
)
)
# Point every existing page at its series' auto-chapter.
conn.execute(
sa.text(
"UPDATE series_page sp "
"SET chapter_id = sc.id "
"FROM series_chapter sc "
"WHERE sc.series_tag_id = sp.series_tag_id"
)
)
# Now lock chapter_id down: NOT NULL + FK (cascade) + index.
op.alter_column("series_page", "chapter_id", nullable=False)
op.create_foreign_key(
"fk_series_page_chapter_id",
"series_page",
"series_chapter",
["chapter_id"],
["id"],
ondelete="CASCADE",
)
op.create_index(
"ix_series_page_chapter_id", "series_page", ["chapter_id"]
)
def downgrade() -> None:
op.drop_index("ix_series_page_chapter_id", table_name="series_page")
op.drop_constraint(
"fk_series_page_chapter_id", "series_page", type_="foreignkey"
)
op.drop_column("series_page", "stated_page")
op.drop_column("series_page", "chapter_id")
op.drop_index("ix_series_chapter_series_tag_id", table_name="series_chapter")
op.drop_table("series_chapter")
@@ -0,0 +1,98 @@
"""series suggestions: assisted-continuation matcher (FC-6.3)
Revision ID: 0041
Revises: 0040
Create Date: 2026-06-07
A confirm-only queue of "this post may continue this series" hints, plus two
import_settings knobs (enable + score threshold) for the matcher.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0041"
down_revision: Union[str, None] = "0040"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"series_suggestion",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"post_id",
sa.Integer,
sa.ForeignKey("post.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"series_tag_id",
sa.Integer,
sa.ForeignKey("tag.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("score", sa.Float, nullable=False),
sa.Column("signals", sa.JSON, nullable=True),
sa.Column(
"status", sa.String(16), nullable=False, server_default="pending"
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint(
"post_id", "series_tag_id", name="uq_series_suggestion_post_series"
),
)
op.create_index(
"ix_series_suggestion_post_id", "series_suggestion", ["post_id"]
)
op.create_index(
"ix_series_suggestion_series_tag_id",
"series_suggestion",
["series_tag_id"],
)
op.create_index(
"ix_series_suggestion_status", "series_suggestion", ["status"]
)
op.add_column(
"import_settings",
sa.Column(
"series_suggest_enabled",
sa.Boolean,
nullable=False,
server_default=sa.true(),
),
)
op.add_column(
"import_settings",
sa.Column(
"series_suggest_threshold",
sa.Float,
nullable=False,
server_default="0.5",
),
)
def downgrade() -> None:
op.drop_column("import_settings", "series_suggest_threshold")
op.drop_column("import_settings", "series_suggest_enabled")
op.drop_index("ix_series_suggestion_status", table_name="series_suggestion")
op.drop_index(
"ix_series_suggestion_series_tag_id", table_name="series_suggestion"
)
op.drop_index("ix_series_suggestion_post_id", table_name="series_suggestion")
op.drop_table("series_suggestion")
+17
View File
@@ -25,6 +25,8 @@ _EDITABLE_FIELDS = (
"download_schedule_default_seconds",
"download_event_retention_days",
"download_failure_warning_threshold",
"series_suggest_enabled",
"series_suggest_threshold",
)
@@ -46,6 +48,8 @@ async def get_import_settings():
"download_schedule_default_seconds": row.download_schedule_default_seconds,
"download_event_retention_days": row.download_event_retention_days,
"download_failure_warning_threshold": row.download_failure_warning_threshold,
"series_suggest_enabled": row.series_suggest_enabled,
"series_suggest_threshold": row.series_suggest_threshold,
})
@@ -96,6 +100,19 @@ async def update_import_settings():
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 100:
return _bad_int("download_failure_warning_threshold", 1, 100)
if "series_suggest_enabled" in body and not isinstance(
body["series_suggest_enabled"], bool
):
return jsonify(
{"error": "series_suggest_enabled must be a boolean"}
), 400
if "series_suggest_threshold" in body:
v = body["series_suggest_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify(
{"error": "series_suggest_threshold must be a number in [0, 1]"}
), 400
async with get_session() as session:
row = await ImportSettings.load(session)
for field in _EDITABLE_FIELDS:
+256 -1
View File
@@ -8,6 +8,7 @@ from ..extensions import get_session
from ..models import Tag, TagKind
from ..models.tag_allowlist import TagAllowlist
from ..services.bulk_tag_service import BulkTagService
from ..services.series_match_service import SeriesMatchService
from ..services.series_service import SeriesError, SeriesService
from ..services.tag_directory_service import TagDirectoryService
from ..services.tag_service import (
@@ -368,6 +369,31 @@ def _series_err(exc: SeriesError):
return jsonify({"error": msg}), status
def _opt_int(body, key: str):
"""(value, error) — value is None when absent, error is (json, status)."""
if not body or body.get(key) is None:
return None, None
try:
return int(body[key]), None
except (TypeError, ValueError):
return None, (jsonify({"error": f"{key} must be an integer"}), 400)
def _parse_int_list(body, key: str, *, max_ids: int = 500):
"""(list, error) for a required list of ints under `key`."""
if not body or key not in body:
return None, (jsonify({"error": f"{key} required"}), 400)
raw = body[key]
if not isinstance(raw, list) or not raw:
return None, (jsonify({"error": f"{key} must be a non-empty list"}), 400)
if len(raw) > max_ids:
return None, (jsonify({"error": f"too many ids (max {max_ids})"}), 400)
try:
return [int(x) for x in raw], None
except (TypeError, ValueError):
return None, (jsonify({"error": f"{key} must be integers"}), 400)
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
async def series_pages(tag_id: int):
async with get_session() as session:
@@ -384,9 +410,14 @@ async def series_add(tag_id: int):
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
chapter_id, cerr = _opt_int(body, "chapter_id")
if cerr:
return cerr
async with get_session() as session:
try:
n = await SeriesService(session).add_images(tag_id, ids)
n = await SeriesService(session).add_images(
tag_id, ids, chapter_id=chapter_id
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
@@ -439,3 +470,227 @@ async def series_cover(tag_id: int):
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
# ---- chapters (FC-6.1) ----------------------------------------------------
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
async def series_chapter_create(tag_id: int):
body = await request.get_json() or {}
title = body.get("title")
if title is not None and not isinstance(title, str):
return jsonify({"error": "title must be a string"}), 400
is_placeholder = bool(body.get("is_placeholder", False))
start, serr = _opt_int(body, "stated_page_start")
if serr:
return serr
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
async with get_session() as session:
try:
ch = await SeriesService(session).create_chapter(
tag_id,
title=title,
is_placeholder=is_placeholder,
stated_page_start=start,
stated_page_end=end,
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(ch)
@tags_bp.route("/series/<int:tag_id>/chapters/reorder", methods=["POST"])
async def series_chapter_reorder(tag_id: int):
body = await request.get_json()
ids, err = _parse_int_list(body, "chapter_ids")
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).reorder_chapters(tag_id, ids)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["PATCH"]
)
async def series_chapter_update(tag_id: int, chapter_id: int):
body = await request.get_json() or {}
kwargs: dict = {}
if "title" in body:
if body["title"] is not None and not isinstance(body["title"], str):
return jsonify({"error": "title must be a string"}), 400
kwargs.update(set_title=True, title=body["title"])
if "stated_page_start" in body:
start, serr = _opt_int(body, "stated_page_start")
if serr:
return serr
kwargs.update(set_start=True, stated_page_start=start)
if "stated_page_end" in body:
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
kwargs.update(set_end=True, stated_page_end=end)
async with get_session() as session:
try:
await SeriesService(session).update_chapter(
tag_id, chapter_id, **kwargs
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["DELETE"]
)
async def series_chapter_delete(tag_id: int, chapter_id: int):
async with get_session() as session:
try:
await SeriesService(session).delete_chapter(tag_id, chapter_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>/merge", methods=["POST"]
)
async def series_chapter_merge(tag_id: int, chapter_id: int):
body = await request.get_json()
target, terr = _opt_int(body, "target_chapter_id")
if terr:
return terr
if target is None:
return jsonify({"error": "target_chapter_id required"}), 400
async with get_session() as session:
try:
moved = await SeriesService(session).merge_chapter(
tag_id, chapter_id, target
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"moved_count": moved})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>/reorder", methods=["POST"]
)
async def series_chapter_reorder_pages(tag_id: int, chapter_id: int):
body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).reorder_pages(tag_id, chapter_id, ids)
except SeriesError as exc:
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)
# ---- suggestion queue (FC-6.3) --------------------------------------------
@tags_bp.route("/series/suggestions", methods=["GET"])
async def series_suggestions_list():
async with get_session() as session:
rows = await SeriesMatchService(session).list_pending()
return jsonify({"suggestions": rows})
@tags_bp.route("/series/suggestions/<int:sid>/accept", methods=["POST"])
async def series_suggestion_accept(sid: int):
async with get_session() as session:
try:
out = await SeriesMatchService(session).accept(sid)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(out)
@tags_bp.route("/series/suggestions/<int:sid>/dismiss", methods=["POST"])
async def series_suggestion_dismiss(sid: int):
async with get_session() as session:
try:
await SeriesMatchService(session).dismiss(sid)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route("/series/suggestions/rescan", methods=["POST"])
async def series_suggestions_rescan():
from ..tasks.admin import rescan_series_suggestions_task
res = rescan_series_suggestions_task.delay()
return jsonify({"task_id": res.id})
+4
View File
@@ -18,7 +18,9 @@ from .patreon_failed_media import PatreonFailedMedia
from .patreon_seen_media import PatreonSeenMedia
from .post import Post
from .post_attachment import PostAttachment
from .series_chapter import SeriesChapter
from .series_page import SeriesPage
from .series_suggestion import SeriesSuggestion
from .source import Source
from .tag import Tag, TagKind, image_tag
from .tag_alias import TagAlias
@@ -39,7 +41,9 @@ __all__ = [
"PatreonSeenMedia",
"Post",
"PostAttachment",
"SeriesChapter",
"SeriesPage",
"SeriesSuggestion",
"ImageRecord",
"ImageProvenance",
"Tag",
+9
View File
@@ -64,6 +64,15 @@ class ImportSettings(Base):
Integer, nullable=False, default=3,
)
# FC-6.3 series continuation matcher. enabled gates the rescan; threshold is
# the weighted-score cut-off (0..1) above which a pending suggestion is made.
series_suggest_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
)
series_suggest_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.5,
)
@classmethod
async def load(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via an async session."""
+47
View File
@@ -0,0 +1,47 @@
"""SeriesChapter — an ordered chapter/part within a series.
A series IS a Tag(kind='series'); a chapter groups ordered SeriesPages under it.
Reading order is (chapter.chapter_number, series_page.page_number): chapter_number
sets the order of chapters, page_number orders pages within a chapter.
chapter_number is an ordering key only (not unique) — reorder rewrites 1..N
wholesale, mirroring series_page.page_number, so a reorder can't transiently
collide on a unique index.
A chapter may be a placeholder (is_placeholder=True) — a reserved empty slot for
a section the operator doesn't have yet; it holds no pages and shows as a gap in
the reader. stated_page_start/end carry the page range parsed from the source
post (FC-6.2), used to flag missing-page gaps; both are nullable when unknown.
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class SeriesChapter(Base):
__tablename__ = "series_chapter"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_number: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
is_placeholder: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false"
)
stated_page_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
stated_page_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
+13 -4
View File
@@ -1,9 +1,12 @@
"""SeriesPage — ordered image membership for a series-kind Tag.
A series IS a Tag with kind='series'; series_page gives it ordered pages.
An image belongs to at most one series (UNIQUE image_id). Cover = the
lowest page_number. page_number is an ordering key only (not unique) —
reorder rewrites 1..N wholesale.
A series IS a Tag with kind='series'; series_page gives it ordered pages,
grouped into chapters (FC-6). An image belongs to at most one series
(UNIQUE image_id) ⇒ at most one chapter. Reading order is
(chapter.chapter_number, series_page.page_number): page_number orders pages
WITHIN a chapter and is an ordering key only (not unique) — reorder rewrites
1..N wholesale. stated_page carries the page number parsed from the source
post (FC-6.2), nullable when unknown.
"""
from datetime import datetime
@@ -21,12 +24,18 @@ class SeriesPage(Base):
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_id: Mapped[int] = mapped_column(
ForeignKey("series_chapter.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
image_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
page_number: Mapped[int] = mapped_column(Integer, nullable=False)
stated_page: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+55
View File
@@ -0,0 +1,55 @@
"""SeriesSuggestion — a confirm-only "this post may continue this series" hint.
The matcher (FC-6.3) scores a (post, candidate series) pair from several weighted
signals and, above the configured threshold, records a pending suggestion. The
operator confirms (→ the post is added as a chapter) or dismisses it; FC never
files a post into a series on its own. status is a plain string (no Postgres
ENUM — see the check-existing-enums lesson): pending | added | dismissed.
"""
from datetime import datetime
from sqlalchemy import (
JSON,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class SeriesSuggestion(Base):
__tablename__ = "series_suggestion"
__table_args__ = (
UniqueConstraint(
"post_id", "series_tag_id", name="uq_series_suggestion_post_series"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
score: Mapped[float] = mapped_column(Float, nullable=False)
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="pending", index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
+56 -11
View File
@@ -15,7 +15,10 @@ lifecycle + soft/hard time limits + retention bookkeeping.
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
from datetime import UTC, datetime
from pathlib import Path
@@ -26,6 +29,35 @@ _BACKUPS_DIRNAME = "_backups"
# blocking syscall ignores that signal. These bound the worst case.
_DB_SUBPROCESS_TIMEOUT_S = 12 * 60 # 12 min (Celery soft is 10 min)
_IMAGES_SUBPROCESS_TIMEOUT_S = 7 * 60 * 60 # 7 hr (Celery soft is 6 hr)
# Grace after SIGKILL to reap the child. If it can't be reaped in this window
# (an uninterruptible NFS D-state — the failure mode that wedged the
# concurrency-1 maintenance lane for hours, operator-flagged 2026-06-07), we
# STOP waiting and fail fast, freeing the worker slot. The orphan is reaped by
# the OS once its blocking syscall clears.
_KILL_REAP_GRACE_S = 10
def _run_bounded(cmd: list[str], timeout: int) -> None:
"""subprocess.run(check=True, timeout) whose reaper can't itself hang.
subprocess.run's timeout path SIGKILLs the child then blocks in wait() to
reap it — but a process stuck in uninterruptible I/O (NFS) can't be reaped,
so wait() blocks for hours. Here we bound the post-kill reap and re-raise
TimeoutExpired regardless, so the caller fails fast instead of wedging."""
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
out, err = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
try:
proc.communicate(timeout=_KILL_REAP_GRACE_S)
except subprocess.TimeoutExpired:
pass # unkillable (D-state) — abandon the reap, fail fast
raise
if proc.returncode != 0:
raise subprocess.CalledProcessError(
proc.returncode, cmd, output=out, stderr=err
)
def _libpq_url(sa_url: str) -> str:
@@ -84,14 +116,25 @@ def backup_db(
ts = _now_ts()
out_dir = _backups_dir(images_root)
sql_path = out_dir / f"fc_db_{ts}.sql"
subprocess.run(
[
"pg_dump", "--no-owner", "--no-acl",
"-f", str(sql_path), _libpq_url(db_url),
],
capture_output=True, check=True,
timeout=_DB_SUBPROCESS_TIMEOUT_S,
)
# Dump to LOCAL disk first, then move the finished file to the (NFS) backups
# dir. pg_dump's long phase is then a DB-socket wait + local writes — both
# killable — instead of an NFS write that can hang uninterruptibly. Only the
# final move touches NFS, and it's a bounded single-file step.
fd, tmp_name = tempfile.mkstemp(prefix="fc_db_", suffix=".sql")
os.close(fd)
tmp_path = Path(tmp_name)
try:
_run_bounded(
[
"pg_dump", "--no-owner", "--no-acl",
"-f", str(tmp_path), _libpq_url(db_url),
],
_DB_SUBPROCESS_TIMEOUT_S,
)
shutil.move(str(tmp_path), str(sql_path))
finally:
if tmp_path.exists():
tmp_path.unlink(missing_ok=True)
manifest_path = _write_manifest(
out_dir, kind="db", ts=ts, tag=tag, triggered_by=triggered_by,
artifact_path=sql_path,
@@ -114,15 +157,17 @@ def backup_images(
ts = _now_ts()
out_dir = _backups_dir(images_root)
tar_path = out_dir / f"fc_images_{ts}.tar.zst"
subprocess.run(
# No local-temp here (the archive is hundreds of GB — it can't stage in
# /tmp), but bounded-kill still applies so a tar wedged on NFS fails fast
# rather than holding the lane for hours.
_run_bounded(
[
"tar", "--zstd", "-cf", str(tar_path),
"-C", str(images_root.parent), images_root.name,
f"--exclude={images_root.name}/_backups",
f"--exclude={images_root.name}/_quarantine",
],
capture_output=True, check=True,
timeout=_IMAGES_SUBPROCESS_TIMEOUT_S,
_IMAGES_SUBPROCESS_TIMEOUT_S,
)
manifest_path = _write_manifest(
out_dir, kind="images", ts=ts, tag=tag, triggered_by=triggered_by,
@@ -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
@@ -0,0 +1,337 @@
"""FC-6.3 — assisted continuation matcher.
Scores a (post, candidate series) pair from several weighted signals; above the
configured threshold it records a *pending* SeriesSuggestion. Confirm-only — the
operator accepts (post becomes a chapter) or dismisses. No single signal gates;
the score is an additive weighted sum, each signal a 0..1 strength.
The learned "title pattern" isn't persisted — it's derived on the fly from the
post titles already in a series, so it sharpens automatically as more posts are
confirmed into the series.
"""
import re
import time
from sqlalchemy import and_, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ImageRecord, Post, Tag, TagKind
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage
from ..models.series_suggestion import SeriesSuggestion
from ..models.tag import image_tag
from .page_number_parser import parse_page_range
from .series_service import SeriesError, SeriesService
# Additive signal weights (sum to 1.0 → max score 1.0). Kept as constants;
# only the on/off + threshold are operator-tunable (sensitivity is the knob
# that matters; per-signal weights are an over-tune for v1).
WEIGHTS = {"title": 0.40, "artist": 0.20, "pages": 0.25, "tags": 0.15}
_DISTINCTIVE_KINDS = (TagKind.character, TagKind.series, TagKind.fandom)
_MAX_CANDIDATES = 50
# Strip installment markers so titles collapse to their stable stem.
_PAGE_TOKEN = re.compile(r"\b(?:pages?|pgs?|pp\.?)\s*\d+(?:\s*[-–—/]\s*\d+)?", re.I)
_BRACKET_NUM = re.compile(r"[\[(]\s*\d+\s*(?:/\s*\d+)?\s*[\])]")
_TRAILING_NUM = re.compile(r"[\s\-_#]*\d+\s*$")
def normalize_title(title: str | None) -> str:
if not title:
return ""
t = _PAGE_TOKEN.sub("", title)
t = _BRACKET_NUM.sub("", t)
t = _TRAILING_NUM.sub("", t)
return " ".join(t.split()).strip().lower()
def _common_prefix(strings: list[str]) -> str:
strings = [s for s in strings if s]
if not strings:
return ""
pre = strings[0]
for s in strings[1:]:
i = 0
while i < len(pre) and i < len(s) and pre[i] == s[i]:
i += 1
pre = pre[:i]
if not pre:
break
return pre.strip()
def title_signal(series_titles: list[str], post_title: str | None) -> float:
"""Overlap of the post title against the series' title stem (the longest
common prefix of its known titles, page/installment markers removed)."""
norm = [normalize_title(t) for t in series_titles]
norm = [t for t in norm if t]
pt = normalize_title(post_title)
if not norm or not pt:
return 0.0
stem = _common_prefix(norm)
if len(stem) < 3:
stem = max(norm, key=len)
i = 0
while i < len(stem) and i < len(pt) and stem[i] == pt[i]:
i += 1
return min(1.0, i / max(len(stem), 4))
def pages_signal(series_max_stated_end: int | None, post_start: int | None) -> float:
"""1.0 when the post's first page continues right after the series' last
stated page; partial for a near-continuation; 0 otherwise."""
if series_max_stated_end is None or post_start is None:
return 0.0
diff = post_start - series_max_stated_end
if diff == 1:
return 1.0
if 2 <= diff <= 3:
return 0.5
return 0.0
def tags_signal(shared_distinctive: int) -> float:
if shared_distinctive <= 0:
return 0.0
return min(1.0, shared_distinctive / 3.0)
def weighted_score(signals: dict) -> float:
return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4)
class SeriesMatchService:
def __init__(self, session: AsyncSession):
self.session = session
async def _post_image_ids(self, post_id: int) -> list[int]:
rows = (
await self.session.execute(
select(ImageRecord.id).where(ImageRecord.primary_post_id == post_id)
)
).scalars().all()
return list(rows)
async def _series_image_ids(self, series_tag_id: int) -> set[int]:
rows = (
await self.session.execute(
select(SeriesPage.image_id).where(
SeriesPage.series_tag_id == series_tag_id
)
)
).scalars().all()
return set(rows)
async def _series_post_titles(self, series_tag_id: int) -> list[str]:
rows = (
await self.session.execute(
select(Post.post_title)
.select_from(SeriesPage)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.join(Post, Post.id == ImageRecord.primary_post_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
Post.post_title.isnot(None),
)
)
.distinct()
)
).scalars().all()
return [t for t in rows if t]
async def _series_max_stated_end(self, series_tag_id: int) -> int | None:
return await self.session.scalar(
select(func.max(SeriesChapter.stated_page_end)).where(
SeriesChapter.series_tag_id == series_tag_id
)
)
async def _distinctive_tags(self, image_ids: list[int] | set[int]) -> set[int]:
ids = list(image_ids)
if not ids:
return set()
rows = (
await self.session.execute(
select(image_tag.c.tag_id)
.join(Tag, Tag.id == image_tag.c.tag_id)
.where(
and_(
image_tag.c.image_record_id.in_(ids),
Tag.kind.in_(_DISTINCTIVE_KINDS),
)
)
.distinct()
)
).scalars().all()
return set(rows)
async def _candidate_series(self, artist_id: int) -> list[int]:
"""Series that already contain a page by this artist — cross-artist
series are rare, so same-artist is the candidate bound."""
rows = (
await self.session.execute(
select(SeriesPage.series_tag_id)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(ImageRecord.artist_id == artist_id)
.distinct()
)
).scalars().all()
return list(rows)
async def _decided_series(self, post_id: int) -> set[int]:
rows = (
await self.session.execute(
select(SeriesSuggestion.series_tag_id).where(
and_(
SeriesSuggestion.post_id == post_id,
SeriesSuggestion.status.in_(["added", "dismissed"]),
)
)
)
).scalars().all()
return set(rows)
async def match_post(self, post_id: int, *, threshold: float) -> int:
"""Score a post against its artist's series; upsert pending suggestions
for those at/above threshold. Returns the number written."""
post = await self.session.get(Post, post_id)
if post is None or post.artist_id is None:
return 0
post_images = await self._post_image_ids(post_id)
if not post_images:
return 0
post_image_set = set(post_images)
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
post_start = rng[0] if rng else None
post_dtags = await self._distinctive_tags(post_images)
decided = await self._decided_series(post_id)
candidates = await self._candidate_series(post.artist_id)
made = 0
for sid in candidates[:_MAX_CANDIDATES]:
if sid in decided:
continue
s_images = await self._series_image_ids(sid)
if post_image_set & s_images:
continue # the post is already (partly) in this series
signals = {
"title": title_signal(
await self._series_post_titles(sid), post.post_title
),
"artist": 1.0, # candidates are same-artist by construction
"pages": pages_signal(
await self._series_max_stated_end(sid), post_start
),
"tags": tags_signal(
len(post_dtags & await self._distinctive_tags(s_images))
),
}
score = weighted_score(signals)
if score < threshold:
continue
await self.session.execute(
pg_insert(SeriesSuggestion)
.values(
post_id=post_id, series_tag_id=sid,
score=score, signals=signals, status="pending",
)
.on_conflict_do_update(
constraint="uq_series_suggestion_post_series",
set_={"score": score, "signals": signals, "status": "pending"},
where=SeriesSuggestion.status == "pending",
)
)
made += 1
return made
# ---- queue ops --------------------------------------------------------
async def list_pending(self) -> list[dict]:
rows = (
await self.session.execute(
select(
SeriesSuggestion.id,
SeriesSuggestion.post_id,
SeriesSuggestion.series_tag_id,
SeriesSuggestion.score,
SeriesSuggestion.signals,
Post.post_title,
Post.external_post_id,
Tag.name.label("series_name"),
)
.join(Post, Post.id == SeriesSuggestion.post_id)
.join(Tag, Tag.id == SeriesSuggestion.series_tag_id)
.where(SeriesSuggestion.status == "pending")
.order_by(SeriesSuggestion.score.desc())
)
).all()
return [
{
"id": r.id,
"post_id": r.post_id,
"series_tag_id": r.series_tag_id,
"series_name": r.series_name,
"post_title": r.post_title or f"Post {r.external_post_id}",
"score": r.score,
"signals": r.signals or {},
}
for r in rows
]
async def accept(self, suggestion_id: int) -> dict:
s = await self.session.get(SeriesSuggestion, suggestion_id)
if s is None:
raise SeriesError(f"suggestion {suggestion_id} not found")
if s.status != "pending":
raise SeriesError(f"suggestion {suggestion_id} is already {s.status}")
out = await SeriesService(self.session).add_post_as_chapter(
s.series_tag_id, s.post_id
)
s.status = "added"
self.session.add(s)
return out
async def dismiss(self, suggestion_id: int) -> None:
s = await self.session.get(SeriesSuggestion, suggestion_id)
if s is None:
raise SeriesError(f"suggestion {suggestion_id} not found")
if s.status == "pending":
s.status = "dismissed"
self.session.add(s)
async def rescan(
self, *, threshold: float, time_budget_seconds: float | None = None,
after_post_id: int = 0,
) -> dict:
"""Score every post (id > after_post_id) against its artist's series,
time-boxed + resumable like the other long maintenance sweeps."""
post_ids = (
await self.session.execute(
select(Post.id)
.where(Post.id > after_post_id)
.order_by(Post.id.asc())
)
).scalars().all()
summary = {
"scanned": 0, "suggested": 0,
"partial": False, "resume_after_id": after_post_id,
}
start = time.monotonic()
for pid in post_ids:
summary["scanned"] += 1
summary["resume_after_id"] = pid
summary["suggested"] += await self.match_post(pid, threshold=threshold)
await self.session.commit() # commit per post so progress survives
if (
time_budget_seconds is not None
and time.monotonic() - start >= time_budget_seconds
):
summary["partial"] = True
break
else:
summary["partial"] = False
return summary
+598 -35
View File
@@ -1,4 +1,8 @@
"""Series = a series-kind Tag + ordered series_page membership.
"""Series = a series-kind Tag + ordered chapters, each holding ordered pages.
Reading order is (series_chapter.chapter_number, series_page.page_number). A
chapter may be a placeholder (no pages) reserving a slot. An image lives in at
most one series ⇒ one chapter (series_page.image_id is UNIQUE).
All mutations are Core/set-based and run in the request transaction.
"""
@@ -7,9 +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):
@@ -21,6 +27,8 @@ 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:
@@ -32,7 +40,7 @@ class SeriesService:
@staticmethod
def _clean_ids(ids: list[int]) -> list[int]:
if not ids:
raise SeriesError("image_ids must be a non-empty list")
raise SeriesError("ids must be a non-empty list")
if len(ids) > 500:
raise SeriesError("selection too large (max 500)")
seen: set[int] = set()
@@ -43,51 +51,172 @@ class SeriesService:
out.append(int(x))
return out
async def _member_order(self, series_tag_id: int) -> list[int]:
async def _require_chapter(self, series_tag_id: int, chapter_id: int):
row = (
await self.session.execute(
select(SeriesChapter).where(
and_(
SeriesChapter.id == chapter_id,
SeriesChapter.series_tag_id == series_tag_id,
)
)
)
).scalar_one_or_none()
if row is None:
raise SeriesError(
f"chapter {chapter_id} is not in series {series_tag_id}"
)
return row
async def _ensure_default_chapter(self, series_tag_id: int) -> int:
"""Lowest-numbered chapter of the series, creating a first chapter if
the series has none (a fresh series, or the legacy single-chapter path)."""
existing = await self.session.scalar(
select(SeriesChapter.id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
.limit(1)
)
if existing is not None:
return existing
res = await self.session.execute(
pg_insert(SeriesChapter)
.values(series_tag_id=series_tag_id, chapter_number=1)
.returning(SeriesChapter.id)
)
return res.scalar_one()
async def _chapter_page_order(self, chapter_id: int) -> list[int]:
rows = (
await self.session.execute(
select(SeriesPage.image_id)
.where(SeriesPage.series_tag_id == series_tag_id)
.where(SeriesPage.chapter_id == chapter_id)
.order_by(SeriesPage.page_number.asc())
)
).scalars().all()
return list(rows)
async def _chapter_order(self, series_tag_id: int) -> list[int]:
rows = (
await self.session.execute(
select(SeriesChapter.id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
)
).scalars().all()
return list(rows)
# ---- read -------------------------------------------------------------
@staticmethod
def _gaps(chapters: list[dict]) -> list[dict]:
"""Missing-page gaps between consecutive chapters with stated ranges."""
out: list[dict] = []
prev = None
for ch in chapters:
start = ch["stated_page_start"]
if (
prev is not None
and prev["stated_page_end"] is not None
and start is not None
and start > prev["stated_page_end"] + 1
):
out.append(
{
"after_chapter_id": prev["id"],
"start": prev["stated_page_end"] + 1,
"end": start - 1,
}
)
prev = ch
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(
SeriesChapter.id.label("chapter_id"),
SeriesChapter.chapter_number,
SeriesChapter.title,
SeriesChapter.is_placeholder,
SeriesChapter.stated_page_start,
SeriesChapter.stated_page_end,
SeriesPage.image_id,
SeriesPage.page_number,
SeriesPage.stated_page,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.path,
ImageRecord.thumbnail_path,
)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(SeriesPage.series_tag_id == series_tag_id)
.order_by(SeriesPage.page_number.asc())
.select_from(SeriesChapter)
.outerjoin(SeriesPage, SeriesPage.chapter_id == SeriesChapter.id)
.outerjoin(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(
SeriesChapter.chapter_number.asc(),
SeriesPage.page_number.asc(),
)
)
).all()
chapters: list[dict] = []
flat: list[dict] = []
by_id: dict[int, dict] = {}
for r in rows:
ch = by_id.get(r.chapter_id)
if ch is None:
ch = {
"id": r.chapter_id,
"chapter_number": r.chapter_number,
"title": r.title,
"is_placeholder": r.is_placeholder,
"stated_page_start": r.stated_page_start,
"stated_page_end": r.stated_page_end,
"pages": [],
}
by_id[r.chapter_id] = ch
chapters.append(ch)
if r.image_id is None:
continue # placeholder / empty chapter
page = {
"image_id": r.image_id,
"chapter_id": r.chapter_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]}",
}
ch["pages"].append(page)
flat.append(page)
return {
"series": {"id": tag.id, "name": tag.name},
"pages": [
{
"image_id": r.image_id,
"page_number": r.page_number,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
}
for r in rows
],
"chapters": chapters,
"pages": flat, # back-compat: flat reading order across chapters
"gaps": self._gaps(chapters),
}
# ---- pages ------------------------------------------------------------
async def add_images(
self, series_tag_id: int, image_ids: list[int]
self,
series_tag_id: int,
image_ids: list[int],
chapter_id: int | None = None,
stated_pages: dict[int, int] | None = None,
) -> int:
"""Append images as pages of a chapter (the series' default chapter when
chapter_id is None). 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)
if chapter_id is None:
chapter_id = await self._ensure_default_chapter(series_tag_id)
else:
await self._require_chapter(series_tag_id, chapter_id)
existing = dict(
(
await self.session.execute(
@@ -96,29 +225,30 @@ class SeriesService:
)
).all()
)
# Already in THIS series → leave untouched (no churn).
to_add = [i for i in ids if existing.get(i) != series_tag_id]
if not to_add:
return 0
# Remove any prior membership for the to_add images (the "move").
# Move: drop any prior membership for these images (UNIQUE image_id).
await self.session.execute(
SeriesPage.__table__.delete().where(
SeriesPage.image_id.in_(to_add)
)
SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add))
)
max_pn = (
await self.session.scalar(
select(func.coalesce(func.max(SeriesPage.page_number), 0))
.where(SeriesPage.series_tag_id == series_tag_id)
select(func.coalesce(func.max(SeriesPage.page_number), 0)).where(
SeriesPage.chapter_id == chapter_id
)
)
) or 0
sp = stated_pages or {}
await self.session.execute(
pg_insert(SeriesPage).values(
[
{
"series_tag_id": series_tag_id,
"chapter_id": chapter_id,
"image_id": iid,
"page_number": max_pn + offset,
"stated_page": sp.get(iid),
}
for offset, iid in enumerate(to_add, start=1)
]
@@ -141,32 +271,465 @@ class SeriesService:
)
return res.rowcount or 0
async def reorder(
self, series_tag_id: int, ordered_image_ids: list[int]
async def reorder_pages(
self, series_tag_id: int, chapter_id: int, ordered_image_ids: list[int]
) -> None:
await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id)
ordered = self._clean_ids(ordered_image_ids)
current = set(await self._member_order(series_tag_id))
current = set(await self._chapter_page_order(chapter_id))
if set(ordered) != current or len(ordered) != len(current):
raise SeriesError(
"ordered image_ids must exactly match series membership"
"ordered image_ids must exactly match the chapter's pages"
)
for idx, iid in enumerate(ordered, start=1):
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.chapter_id == chapter_id,
SeriesPage.image_id == iid,
)
)
.values(page_number=idx)
)
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
async def reorder(
self, series_tag_id: int, ordered_image_ids: list[int]
) -> None:
"""Legacy series-wide reorder — reorders the default chapter. Valid only
for single-chapter series (the only shape the pre-chapter UI produced);
multi-chapter series must use reorder_pages with an explicit chapter."""
await self._require_series(series_tag_id)
order = await self._member_order(series_tag_id)
if image_id not in order:
chapters = await self._chapter_order(series_tag_id)
if len(chapters) > 1:
raise SeriesError(
"series has multiple chapters; reorder a specific chapter"
)
chapter_id = (
chapters[0]
if chapters
else await self._ensure_default_chapter(series_tag_id)
)
await self.reorder_pages(series_tag_id, chapter_id, ordered_image_ids)
# ---- chapters ---------------------------------------------------------
async def create_chapter(
self,
series_tag_id: int,
*,
title: str | None = None,
is_placeholder: bool = False,
stated_page_start: int | None = None,
stated_page_end: int | None = None,
) -> dict:
await self._require_series(series_tag_id)
max_cn = (
await self.session.scalar(
select(
func.coalesce(func.max(SeriesChapter.chapter_number), 0)
).where(SeriesChapter.series_tag_id == series_tag_id)
)
) or 0
res = await self.session.execute(
pg_insert(SeriesChapter)
.values(
series_tag_id=series_tag_id,
chapter_number=max_cn + 1,
title=title,
is_placeholder=is_placeholder,
stated_page_start=stated_page_start,
stated_page_end=stated_page_end,
)
.returning(SeriesChapter.id, SeriesChapter.chapter_number)
)
row = res.one()
return {"id": row.id, "chapter_number": row.chapter_number}
async def update_chapter(
self,
series_tag_id: int,
chapter_id: int,
*,
title: str | None = None,
stated_page_start: int | None = None,
stated_page_end: int | None = None,
set_title: bool = False,
set_start: bool = False,
set_end: bool = False,
) -> None:
"""Partial chapter edit. The set_* flags say which fields to write (so
None can be written explicitly, e.g. clearing a stated page)."""
await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id)
values: dict = {}
if set_title:
values["title"] = title
if set_start:
values["stated_page_start"] = stated_page_start
if set_end:
values["stated_page_end"] = stated_page_end
if not values:
return
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == chapter_id)
.values(**values)
)
async def _renumber_chapters(self, series_tag_id: int) -> None:
for idx, cid in enumerate(
await self._chapter_order(series_tag_id), start=1
):
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == cid)
.values(chapter_number=idx)
)
async def reorder_chapters(
self, series_tag_id: int, ordered_chapter_ids: list[int]
) -> None:
await self._require_series(series_tag_id)
ordered = self._clean_ids(ordered_chapter_ids)
current = set(await self._chapter_order(series_tag_id))
if set(ordered) != current or len(ordered) != len(current):
raise SeriesError(
"ordered chapter_ids must exactly match the series' chapters"
)
for idx, cid in enumerate(ordered, start=1):
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == cid)
.values(chapter_number=idx)
)
async def delete_chapter(self, series_tag_id: int, chapter_id: int) -> None:
await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id)
# Pages cascade-delete with the chapter (FK ondelete=CASCADE).
await self.session.execute(
SeriesChapter.__table__.delete().where(
SeriesChapter.id == chapter_id
)
)
await self._renumber_chapters(series_tag_id)
async def merge_chapter(
self, series_tag_id: int, source_chapter_id: int, target_chapter_id: int
) -> int:
"""Move source chapter's pages onto the end of the target chapter, then
delete the now-empty source chapter. Returns the number of pages moved."""
await self._require_series(series_tag_id)
if source_chapter_id == target_chapter_id:
raise SeriesError("cannot merge a chapter into itself")
await self._require_chapter(series_tag_id, source_chapter_id)
await self._require_chapter(series_tag_id, target_chapter_id)
moving = await self._chapter_page_order(source_chapter_id)
max_pn = (
await self.session.scalar(
select(func.coalesce(func.max(SeriesPage.page_number), 0)).where(
SeriesPage.chapter_id == target_chapter_id
)
)
) or 0
for offset, iid in enumerate(moving, start=1):
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.chapter_id == source_chapter_id,
SeriesPage.image_id == iid,
)
)
.values(chapter_id=target_chapter_id, page_number=max_pn + offset)
)
await self.session.execute(
SeriesChapter.__table__.delete().where(
SeriesChapter.id == source_chapter_id
)
)
await self._renumber_chapters(series_tag_id)
return len(moving)
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
"""Cover = the (chapter_number=1, page_number=1) image. Bring the image's
chapter to the front and the image to the front of its chapter."""
await self._require_series(series_tag_id)
row = (
await self.session.execute(
select(SeriesPage.chapter_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == image_id,
)
)
)
).scalar_one_or_none()
if row is None:
raise SeriesError(f"image {image_id} is not in this series")
new_order = [image_id] + [i for i in order if i != image_id]
await self.reorder(series_tag_id, new_order)
chapter_id = row
chapters = await self._chapter_order(series_tag_id)
if chapters and chapters[0] != chapter_id:
new_chapters = [chapter_id] + [c for c in chapters if c != chapter_id]
await self.reorder_chapters(series_tag_id, new_chapters)
pages = await self._chapter_page_order(chapter_id)
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
+10
View File
@@ -515,8 +515,18 @@ class TagService:
)
async def _repoint_series_pages(self, src: int, tgt: int) -> None:
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage
# Move the chapters first so the pages' chapter_id FK stays valid: a
# chapter left pointing at src would cascade-delete (with its pages) when
# src is removed. chapter_number may now collide across the merged set —
# acceptable (it's an ordering key, not unique).
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.series_tag_id == src)
.values(series_tag_id=tgt)
)
# image_id is UNIQUE across series_page and src != tgt, so an
# image in src's series cannot already be in tgt's — no collision.
await self.session.execute(
+51
View File
@@ -147,3 +147,54 @@ def normalize_tags_task(self) -> dict:
)
normalize_tags_task.delay()
return summary
# Time-box one rescan chunk well under the soft limit and re-enqueue from the
# cursor — scoring every post against its artist's series is O(posts) and grows
# with the library (FC-6.3). Mirrors normalize_tags_task.
_SERIES_RESCAN_CHUNK_SECONDS = 600
@celery.task(
name="backend.app.tasks.admin.rescan_series_suggestions_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def rescan_series_suggestions_task(self, after_post_id: int = 0) -> dict:
"""Score posts against their artist's series and write pending suggestions
(FC-6.3). Settings-gated; time-boxed + self-resuming from a post-id cursor.
Per-task async engine (NullPool) under its own asyncio loop, like normalize."""
import asyncio
from ..models import ImportSettings
from ..services.series_match_service import SeriesMatchService
from ._async_session import async_session_factory
async def _run() -> dict:
async_factory, async_engine = async_session_factory()
try:
async with async_factory() as session:
settings = await ImportSettings.load(session)
if not settings.series_suggest_enabled:
return {"skipped": "series suggestions disabled"}
threshold = settings.series_suggest_threshold
return await SeriesMatchService(session).rescan(
threshold=threshold,
time_budget_seconds=_SERIES_RESCAN_CHUNK_SECONDS,
after_post_id=after_post_id,
)
finally:
await async_engine.dispose()
summary = asyncio.run(_run())
if summary.get("partial") and summary.get("resume_after_id", 0) > after_post_id:
log.info(
"rescan_series_suggestions chunk done (%d scanned, %d suggested, "
"resume after %s) — re-enqueuing",
summary.get("scanned", 0), summary.get("suggested", 0),
summary["resume_after_id"],
)
rescan_series_suggestions_task.delay(summary["resume_after_id"])
return summary
+16 -7
View File
@@ -83,8 +83,13 @@ TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
# small buffer) so the sweep never flags in-flight work.
#
# Backups: images backup has time_limit=23400s (6.5h). 7h covers it
# with a 30-min buffer; db backup at 12 min hard limit fits trivially.
# with a 30-min buffer.
BACKUP_STALL_THRESHOLD_MINUTES = 7 * 60
# DB backup/restore is seconds-to-minutes (35-min hard limit). It must NOT share
# the images' 7h window — a DB backup wedged on NFS would otherwise sit "running"
# for 7 hours holding the concurrency-1 maintenance_long lane (operator-flagged
# 2026-06-07). 40 min gives a small buffer over the hard limit.
BACKUP_DB_STALL_THRESHOLD_MINUTES = 40
# Library audit: scan_library_for_rule has time_limit=7500s (2h5m).
# 2h15m gives a 10-min buffer.
LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES = 135
@@ -619,16 +624,20 @@ def recover_stalled_backup_runs() -> int:
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=BACKUP_STALL_THRESHOLD_MINUTES)
msg = (
f"stranded by recovery sweep (no terminal status after "
f"{BACKUP_STALL_THRESHOLD_MINUTES // 60}h)"
)
db_cutoff = now - timedelta(minutes=BACKUP_DB_STALL_THRESHOLD_MINUTES)
slow_cutoff = now - timedelta(minutes=BACKUP_STALL_THRESHOLD_MINUTES)
msg = "stranded by recovery sweep (no terminal status within the stall window)"
with SessionLocal() as session:
result = session.execute(
update(BackupRun)
.where(BackupRun.status.in_(["running", "restoring"]))
.where(BackupRun.started_at < cutoff)
# db backups/restores are fast (40-min window); images run hours (7h).
.where(
or_(
and_(BackupRun.kind == "db", BackupRun.started_at < db_cutoff),
and_(BackupRun.kind != "db", BackupRun.started_at < slow_cutoff),
)
)
.values(status="error", finished_at=now, error=msg)
)
session.commit()
+11 -5
View File
@@ -21,17 +21,23 @@ services:
postgres:
image: pgvector/pgvector:pg16
# Docker's default /dev/shm is 64MB; VACUUM (ANALYZE) and parallel queries
# allocate larger shared-memory segments and fail with
# "could not resize shared memory segment ... No space left on device"
# (operator-flagged 2026-06-07, vacuum_analyze on import_task needed 67MB).
shm_size: 512m
environment:
POSTGRES_USER: ${DB_USER:-fabledcurator}
POSTGRES_PASSWORD: ${DB_PASSWORD:-fabledcurator_dev}
POSTGRES_DB: ${DB_NAME:-fabledcurator}
volumes:
- postgres_data:/var/lib/postgresql/data
# Docker's default /dev/shm is 64MB; VACUUM (ANALYZE) + parallel queries
# allocate a POSIX dynamic-shared-memory segment in /dev/shm and fail with
# "could not resize shared memory segment ... No space left on device"
# (operator-flagged 2026-06-07, vacuum_analyze on import_task needed 67MB).
# NOTE: a `shm_size:` key is SILENTLY IGNORED under Docker Swarm
# (`docker stack deploy` — the prod deployment here), so size /dev/shm via
# a tmpfs mount instead — honored by both Swarm and plain Compose.
- type: tmpfs
target: /dev/shm
tmpfs:
size: 536870912 # 512 MB
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fabledcurator}"]
interval: 10s
+14 -6
View File
@@ -19,7 +19,7 @@
:item-title="(f) => f.name"
:item-value="(f) => f.id"
label="Fandom" clearable density="compact"
@keydown.enter="onSearchEnter"
@keydown.enter.capture="onSearchEnter"
@keydown.tab="onSearchTab"
/>
<v-divider class="my-3" />
@@ -92,12 +92,20 @@ function onConfirm () {
if (f) emit('confirm', f)
}
// Enter on the search field. When the menu is open Vuetify is picking the
// highlighted item (it sets selectedId + closes the menu) — don't also accept
// on that keystroke. With the menu closed and a value chosen, Enter accepts.
function onSearchEnter () {
// Enter on the search field — bound in the CAPTURE phase so this runs BEFORE
// Vuetify's own input keydown handler (which opens the menu on Enter). When the
// menu is open, let Vuetify pick the highlighted item (it sets selectedId +
// closes the menu). When it's closed and a fandom is already chosen, accept it
// and stop the event so Vuetify never (re)opens the menu — that re-open was the
// bug: Enter popped the dropdown instead of submitting (operator-flagged
// 2026-06-07).
function onSearchEnter (e) {
if (menuOpen.value) return
if (selectedId.value != null) onConfirm()
if (selectedId.value != null) {
e.preventDefault()
e.stopPropagation()
onConfirm()
}
}
// Tab from the search field goes to the "new fandom" text field (operator-
@@ -15,6 +15,7 @@
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
</span>
<v-spacer />
<PostSeriesMenu :post="post" />
<v-btn
v-if="post.post_url"
:href="post.post_url" target="_blank" rel="noopener"
@@ -101,6 +102,7 @@ import { useModalStore } from '../../stores/modal.js'
import { usePostsStore } from '../../stores/posts.js'
import { toPlainText } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostSeriesMenu from './PostSeriesMenu.vue'
const props = defineProps({
post: { type: Object, required: true },
@@ -0,0 +1,123 @@
<template>
<span class="fc-post-series">
<v-btn
size="x-small" variant="text" prepend-icon="mdi-book-plus-outline"
append-icon="mdi-menu-down"
:aria-label="`Add post ${post.id} to a series`"
@click.stop="menuOpen = !menuOpen"
>Series</v-btn>
<v-menu v-model="menuOpen" activator="parent" :open-on-click="false">
<v-list density="compact">
<v-list-item :disabled="busy" @click="onPromote">
<template #prepend>
<v-icon size="small">mdi-book-plus</v-icon>
</template>
<v-list-item-title>New series from this post</v-list-item-title>
</v-list-item>
<v-list-item @click="openPicker">
<template #prepend>
<v-icon size="small">mdi-playlist-plus</v-icon>
</template>
<v-list-item-title>Add to existing series</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-dialog v-model="pickerOpen" max-width="460">
<v-card>
<v-card-title>Add to series</v-card-title>
<v-card-text>
<p class="text-caption mb-2">
Appends this post as the next chapter of the chosen series.
</p>
<v-autocomplete
v-model="picked"
:items="hits"
:item-title="(s) => s.name"
:item-value="(s) => s.id"
:loading="searching"
label="Series" density="compact" autofocus clearable
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="pickerOpen = false">Cancel</v-btn>
<v-btn
color="primary" rounded="pill"
:disabled="picked == null || busy" @click="onAddExisting"
>Add</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</span>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { toast } from '../../utils/toast.js'
const props = defineProps({ post: { type: Object, required: true } })
const api = useApi()
const router = useRouter()
const menuOpen = ref(false)
const busy = ref(false)
const pickerOpen = ref(false)
const picked = ref(null)
const hits = ref([])
const searching = ref(false)
async function onPromote() {
menuOpen.value = false
busy.value = true
try {
const out = await api.post('/api/series/from-post', {
body: { post_id: props.post.id }
})
toast({ text: `Series created: ${out.name}`, type: 'success' })
router.push({ name: 'series-manage', params: { tagId: out.series_tag_id } })
} catch (e) {
toast({ text: `Could not create series: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
}
async function openPicker() {
menuOpen.value = false
picked.value = null
hits.value = []
pickerOpen.value = true
// Load the full series list so the picker is browsable (the tag autocomplete
// returns nothing for an empty query — the #712 trap). v-autocomplete filters
// client-side by name as the operator types.
searching.value = true
try {
const body = await api.get('/api/series', { params: { sort: 'name' } })
hits.value = body.series || []
} catch (e) {
toast({ text: `Could not load series: ${e.message}`, type: 'error' })
} finally {
searching.value = false
}
}
async function onAddExisting() {
if (picked.value == null) return
busy.value = true
try {
await api.post(`/api/series/${picked.value}/add-post`, {
body: { post_id: props.post.id }
})
pickerOpen.value = false
toast({ text: 'Added to series', type: 'success' })
} catch (e) {
toast({ text: `Could not add to series: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
}
</script>
@@ -181,7 +181,7 @@
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-format-letter-case"
:disabled="!normPreview.total_changes"
:disabled="!normPreview.total_changes || normResult === 'queued'"
:loading="normCommitting"
@click="onNormCommit"
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
@@ -291,7 +291,11 @@ async function onNormCommit() {
// confirm it's queued; the operator can re-run Preview later to verify.
await store.normalizeTags({ dryRun: false })
normResult.value = 'queued'
normPreview.value = { total_changes: 0, tags_to_rename: 0, collisions: 0, tags_to_merge: 0, sample: [] }
// Keep the preview showing what was QUEUED — do NOT zero it out. Overwriting
// it with a zeroed object made the card read "0 tag groups to change" the
// instant Standardize was clicked, which looked like nothing happened
// (operator-flagged 2026-06-07). The button disables on `queued`; re-running
// Preview later reflects the real remaining count as the task applies.
} finally {
normCommitting.value = false
}
+19 -1
View File
@@ -4,6 +4,7 @@ import GalleryView from './views/GalleryView.vue'
import ShowcaseView from './views/ShowcaseView.vue'
import TagsView from './views/TagsView.vue'
import ArtistView from './views/ArtistView.vue'
import SeriesView from './views/SeriesView.vue'
import SeriesManageView from './views/SeriesManageView.vue'
import SeriesReaderView from './views/SeriesReaderView.vue'
import SubscriptionsView from './views/SubscriptionsView.vue'
@@ -25,7 +26,9 @@ const routes = [
{ path: '/tags', name: 'tags', component: TagsView, meta: { title: 'Tags' } },
// Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series management — no meta.title (reached from a series tag card).
// Series browse — a nav entry (meta.title). Peer of Posts.
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series' } },
// Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title).
{ path: '/series/:tagId/read', name: 'series-read', component: SeriesReaderView, meta: { immersive: true } },
@@ -54,4 +57,19 @@ const router = createRouter({
routes
})
const DEFAULT_TITLE = 'FabledCurator'
// Keep the tab title in sync with the route on EVERY navigation. List routes set
// it from meta.title; detail routes (artist, series) have no meta.title, so they
// reset to the default here and then overwrite it with their own dynamic title
// (e.g. ArtistView sets "<artist> — FabledCurator"). Without this reset the
// previous view's dynamic title stuck around — an artist name showing on the
// Showcase tab, operator-flagged 2026-06-07.
router.afterEach((to) => {
if (typeof document === 'undefined') return
document.title = to.meta?.title
? `${to.meta.title}${DEFAULT_TITLE}`
: DEFAULT_TITLE
})
export default router
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
// Backs the Series browse view (FC-6.2). list_series returns every series as a
// card; homelab scale is small enough to load the whole list and sort/filter
// server-side.
export const useSeriesBrowseStore = defineStore('seriesBrowse', () => {
const api = useApi()
const series = ref([])
const sort = ref('recent')
const artistId = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
async function load() {
await run(async () => {
const params = { sort: sort.value }
if (artistId.value != null) params.artist_id = artistId.value
const body = await api.get('/api/series', { params })
series.value = body.series || []
})
}
function setSort(s) {
sort.value = s
return load()
}
return { series, sort, artistId, loading, error, load, setSort }
})
+83 -14
View File
@@ -16,7 +16,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
const tagId = ref(null)
const series = ref(null)
const pages = ref([]) // [{image_id, page_number, thumbnail_url}]
const chapters = ref([]) // [{id, chapter_number, title, is_placeholder, stated_page_start/end, pages:[...]}]
const gaps = ref([]) // [{after_chapter_id, start, end}]
const pageCount = ref(0)
const targetChapterId = ref(null) // which chapter the picker adds into
const picker = ref([]) // gallery scroll results
const pickerCursor = ref(null)
const pickerSelection = ref([]) // image ids
@@ -28,7 +31,14 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
try {
const body = await api.get(`/api/series/${id}/pages`)
series.value = body.series
pages.value = body.pages
chapters.value = body.chapters || []
gaps.value = body.gaps || []
pageCount.value = (body.pages || []).length
// Keep a valid add-target: the selected chapter, else the first one.
const ids = chapters.value.map(c => c.id)
if (!ids.includes(targetChapterId.value)) {
targetChapterId.value = ids.length ? ids[0] : null
}
} finally {
loading.value = false
}
@@ -38,6 +48,68 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
if (tagId.value != null) await load(tagId.value)
}
function gapAfter(chapterId) {
return gaps.value.find(g => g.after_chapter_id === chapterId) || null
}
// ---- chapters ----
async function createChapter({ title = null, isPlaceholder = false } = {}) {
await api.post(`/api/series/${tagId.value}/chapters`, {
body: { title, is_placeholder: isPlaceholder }
})
await refresh()
}
async function renameChapter(chapterId, title) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { title }
})
await refresh()
}
async function setChapterStated(chapterId, start, end) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_page_start: start, stated_page_end: end }
})
await refresh()
}
async function reorderChapters(orderedChapterIds) {
await api.post(`/api/series/${tagId.value}/chapters/reorder`, {
body: { chapter_ids: orderedChapterIds }
})
await refresh()
}
async function moveChapter(chapterId, dir) {
const ids = chapters.value.map(c => c.id)
const from = ids.indexOf(chapterId)
const to = from + dir
if (from === -1 || to < 0 || to >= ids.length) return
await reorderChapters(moveItem(ids, from, to))
}
async function deleteChapter(chapterId) {
await api.delete(`/api/series/${tagId.value}/chapters/${chapterId}`)
await refresh()
}
async function mergeChapter(sourceId, targetId) {
await api.post(`/api/series/${tagId.value}/chapters/${sourceId}/merge`, {
body: { target_chapter_id: targetId }
})
await refresh()
toast({ text: 'Chapters merged', type: 'success' })
}
async function reorderPages(chapterId, orderedImageIds) {
await api.post(`/api/series/${tagId.value}/chapters/${chapterId}/reorder`, {
body: { image_ids: orderedImageIds }
})
await refresh()
}
// ---- picker / pages ----
async function loadPicker(reset = false) {
if (reset) { picker.value = []; pickerCursor.value = null }
const params = { limit: 50 }
@@ -55,12 +127,13 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
async function addSelected() {
if (pickerSelection.value.length === 0) return
if (targetChapterId.value == null) await createChapter()
await api.post(`/api/series/${tagId.value}/pages`, {
body: { image_ids: pickerSelection.value }
body: { image_ids: pickerSelection.value, chapter_id: targetChapterId.value }
})
pickerSelection.value = []
await refresh()
toast({ text: 'Added to series', type: 'success' })
toast({ text: 'Added to chapter', type: 'success' })
}
async function remove(imageId) {
@@ -70,13 +143,6 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
await refresh()
}
async function reorder(orderedImageIds) {
await api.post(`/api/series/${tagId.value}/reorder`, {
body: { image_ids: orderedImageIds }
})
await refresh()
}
async function setCover(imageId) {
await api.post(`/api/series/${tagId.value}/cover`, {
body: { image_id: imageId }
@@ -85,8 +151,11 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
}
return {
tagId, series, pages, picker, pickerCursor, pickerSelection, loading,
load, refresh, loadPicker, togglePick, addSelected, remove,
reorder, setCover
tagId, series, chapters, gaps, pageCount, targetChapterId,
picker, pickerCursor, pickerSelection, loading,
load, refresh, gapAfter,
createChapter, renameChapter, setChapterStated, reorderChapters,
moveChapter, deleteChapter, mergeChapter, reorderPages,
loadPicker, togglePick, addSelected, remove, setCover
}
})
+21 -1
View File
@@ -42,7 +42,27 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => {
await run(async () => {
const body = await api.get(`/api/series/${tagId}/pages`)
series.value = body.series
pages.value = body.pages
// page_number is now WITHIN a chapter, so it can't anchor scroll/jump
// (two chapters both have a page 1). Decorate each page with a global
// `seq` (reading-order position) for anchors, plus chapter-divider info
// so the reader can mark where each chapter begins.
const chapters = body.chapters || []
const labelById = {}
for (const c of chapters) {
labelById[c.id] = c.title || `Chapter ${c.chapter_number}`
}
let prevChapter = null
pages.value = (body.pages || []).map((p, i) => {
const isChapterStart =
chapters.length > 1 && p.chapter_id !== prevChapter
prevChapter = p.chapter_id
return {
...p,
seq: i + 1,
isChapterStart,
chapterLabel: labelById[p.chapter_id] || null
}
})
})
}
+75
View File
@@ -0,0 +1,75 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { toast } from '../utils/toast.js'
// Backs the Series → Suggestions tab (FC-6.3). The matcher is confirm-only:
// accept turns a suggestion into a chapter; skip dismisses it. enabled +
// threshold are the operator-tunable knobs (DB-backed via /settings/import).
export const useSeriesSuggestionsStore = defineStore('seriesSuggestions', () => {
const api = useApi()
const suggestions = ref([])
const enabled = ref(true)
const threshold = ref(0.5)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
async function load() {
await run(async () => {
const body = await api.get('/api/series/suggestions')
suggestions.value = body.suggestions || []
})
}
async function loadSettings() {
const s = await api.get('/api/settings/import')
enabled.value = s.series_suggest_enabled
threshold.value = s.series_suggest_threshold
}
async function saveSettings(patch) {
await api.patch('/api/settings/import', { body: patch })
}
async function setEnabled(v) {
enabled.value = v
await saveSettings({ series_suggest_enabled: v })
}
async function setThreshold(v) {
threshold.value = v
await saveSettings({ series_suggest_threshold: v })
}
async function accept(id) {
try {
await api.post(`/api/series/suggestions/${id}/accept`, {})
suggestions.value = suggestions.value.filter(s => s.id !== id)
toast({ text: 'Added to series', type: 'success' })
} catch (e) {
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
}
}
async function dismiss(id) {
try {
await api.post(`/api/series/suggestions/${id}/dismiss`, {})
suggestions.value = suggestions.value.filter(s => s.id !== id)
} catch (e) {
toast({ text: `Skip failed: ${e.message}`, type: 'error' })
}
}
async function rescan() {
await api.post('/api/series/suggestions/rescan', {})
toast({
text: 'Rescan queued — runs in the background; reload to see new matches.',
type: 'info'
})
}
return {
suggestions, enabled, threshold, loading, error,
load, loadSettings, setEnabled, setThreshold, accept, dismiss, rescan
}
})
+211 -45
View File
@@ -2,9 +2,11 @@
<v-container fluid class="pt-2 pb-6">
<div class="fc-series__head">
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
<span class="fc-series__count">
{{ store.chapters.length }} chapter(s) · {{ store.pageCount }} page(s)
</span>
<v-btn
v-if="store.pages.length > 0"
v-if="store.pageCount > 0"
size="small" variant="tonal" color="accent"
prepend-icon="mdi-book-open"
class="fc-series__read"
@@ -13,28 +15,123 @@
</div>
<div class="fc-series__body">
<div class="fc-series__pages">
<div
v-for="(p, idx) in store.pages" :key="p.image_id"
class="fc-series__page" draggable="true"
@dragstart="dragFrom = idx"
@dragover.prevent
@drop="onDrop(idx)"
>
<span class="fc-series__pn">{{ p.page_number }}</span>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-series__pageactions">
<v-btn size="x-small" variant="text" icon="mdi-image-frame"
title="Make cover" @click="store.setCover(p.image_id)" />
<v-btn size="x-small" variant="text" icon="mdi-close"
title="Remove" @click="store.remove(p.image_id)" />
<!-- Chapters column -->
<div class="fc-series__chapters">
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
<section
class="fc-chapter"
:class="{ 'fc-chapter--target': ch.id === store.targetChapterId }"
>
<header class="fc-chapter__head">
<span class="fc-chapter__num">{{ ch.chapter_number }}</span>
<v-text-field
v-model="titleDraft[ch.id]"
:placeholder="`Chapter ${ch.chapter_number}`"
density="compact" variant="plain" hide-details
class="fc-chapter__title"
@keydown.enter="commitTitle(ch)"
@blur="commitTitle(ch)"
/>
<span v-if="ch.is_placeholder" class="fc-chapter__ph">placeholder</span>
<span v-else class="fc-chapter__pc">{{ ch.pages.length }} pg</span>
<div class="fc-chapter__stated">
<input
class="fc-chapter__num-in" type="number" min="0"
:value="ch.stated_page_start ?? ''" placeholder=""
title="Stated first page"
@change="onStated(ch, 'start', $event)"
>
<span></span>
<input
class="fc-chapter__num-in" type="number" min="0"
:value="ch.stated_page_end ?? ''" placeholder=""
title="Stated last page"
@change="onStated(ch, 'end', $event)"
>
</div>
<div class="fc-chapter__actions">
<v-btn
size="x-small" variant="text" icon="mdi-chevron-up"
title="Move chapter up" :disabled="ci === 0"
@click="store.moveChapter(ch.id, -1)"
/>
<v-btn
size="x-small" variant="text" icon="mdi-chevron-down"
title="Move chapter down"
:disabled="ci === store.chapters.length - 1"
@click="store.moveChapter(ch.id, 1)"
/>
<v-btn
size="x-small" variant="text" icon="mdi-arrow-collapse-up"
title="Merge into previous chapter" :disabled="ci === 0"
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
/>
<v-btn
size="x-small" variant="text" icon="mdi-target"
:color="ch.id === store.targetChapterId ? 'accent' : undefined"
title="Add picked images here"
@click="store.targetChapterId = ch.id"
/>
<v-btn
size="x-small" variant="text" icon="mdi-delete-outline"
title="Delete chapter (removes its pages from the series)"
@click="store.deleteChapter(ch.id)"
/>
</div>
</header>
<div v-if="ch.is_placeholder" class="fc-chapter__reserved">
Reserved slot a section you don't have yet.
</div>
<div v-else-if="ch.pages.length === 0" class="fc-chapter__empty">
No pages pick this chapter () then add from the right.
</div>
<div v-else class="fc-chapter__pages">
<div
v-for="(p, pi) in ch.pages" :key="p.image_id"
class="fc-page" draggable="true"
@dragstart="drag = { chapterId: ch.id, idx: pi }"
@dragover.prevent
@drop="onPageDrop(ch, pi)"
>
<span class="fc-page__pn">{{ p.page_number }}</span>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-page__actions">
<v-btn size="x-small" variant="text" icon="mdi-image-frame"
title="Make cover" @click="store.setCover(p.image_id)" />
<v-btn size="x-small" variant="text" icon="mdi-close"
title="Remove" @click="store.remove(p.image_id)" />
</div>
</div>
</div>
</section>
<div
v-if="store.gapAfter(ch.id)"
class="fc-gap"
>
<v-icon size="x-small">mdi-alert-outline</v-icon>
Gap: pages {{ store.gapAfter(ch.id).start }}{{ store.gapAfter(ch.id).end }} missing
</div>
</template>
<div v-if="store.chapters.length === 0" class="fc-series__empty">
No chapters yet add one, then add images from the right.
</div>
<div v-if="store.pages.length === 0" class="fc-series__empty">
No pages yet add images from the right.
<div class="fc-series__chapter-add">
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
@click="store.createChapter()">Add chapter</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-bookmark-outline"
@click="store.createChapter({ isPlaceholder: true })">
Add placeholder
</v-btn>
</div>
</div>
<!-- Picker column -->
<div class="fc-series__picker">
<div class="fc-series__pickerhead">
<span>{{ store.pickerSelection.length }} selected</span>
@@ -42,7 +139,7 @@
size="small" color="accent" variant="flat"
:disabled="store.pickerSelection.length === 0"
@click="store.addSelected()"
>Add to series</v-btn>
>Add to {{ targetLabel }}</v-btn>
</div>
<div class="fc-series__pickergrid">
<div
@@ -61,23 +158,52 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
const route = useRoute()
const store = useSeriesManageStore()
const dragFrom = ref(null)
const sentinel = ref(null)
const drag = ref(null) // { chapterId, idx }
const titleDraft = reactive({}) // chapterId -> draft string
function onDrop(toIdx) {
if (dragFrom.value === null || dragFrom.value === toIdx) return
// Keep local title drafts in sync with the loaded chapters. Edits commit on
// blur/Enter, which refreshes and resets the draft to the saved value.
watch(() => store.chapters, (chs) => {
Object.keys(titleDraft).forEach(k => delete titleDraft[k])
for (const c of chs) titleDraft[c.id] = c.title || ''
}, { immediate: true })
const targetLabel = computed(() => {
const ch = store.chapters.find(c => c.id === store.targetChapterId)
if (!ch) return 'chapter'
return ch.title || `Chapter ${ch.chapter_number}`
})
function commitTitle(ch) {
const v = (titleDraft[ch.id] || '').trim()
if (v === (ch.title || '')) return
store.renameChapter(ch.id, v || null)
}
function onStated(ch, which, ev) {
const raw = ev.target.value
const n = raw === '' ? null : parseInt(raw, 10)
const start = which === 'start' ? n : ch.stated_page_start
const end = which === 'end' ? n : ch.stated_page_end
store.setChapterStated(ch.id, start, end)
}
function onPageDrop(chapter, toIdx) {
if (!drag.value || drag.value.chapterId !== chapter.id) { drag.value = null; return }
if (drag.value.idx === toIdx) { drag.value = null; return }
const ordered = moveItem(
store.pages.map(p => p.image_id), dragFrom.value, toIdx
chapter.pages.map(p => p.image_id), drag.value.idx, toIdx
)
dragFrom.value = null
store.reorder(ordered)
drag.value = null
store.reorderPages(chapter.id, ordered)
}
useInfiniteScroll(sentinel, () => store.loadPicker())
@@ -92,33 +218,71 @@ onMounted(async () => {
.fc-series__head {
display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px;
}
.fc-series__name {
font-family: 'Fraunces', Georgia, serif; font-size: 22px;
}
.fc-series__name { font-family: 'Fraunces', Georgia, serif; font-size: 22px; }
.fc-series__count {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series__body { display: flex; gap: 16px; align-items: flex-start; }
.fc-series__pages { flex: 1; min-width: 0; display: flex;
flex-direction: column; gap: 6px; }
.fc-series__page {
display: flex; align-items: center; gap: 10px; padding: 6px;
background: rgb(var(--v-theme-surface)); border-radius: 6px;
cursor: grab;
.fc-series__chapters {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px;
}
.fc-series__page img {
width: 64px; height: 64px; object-fit: cover; border-radius: 4px;
.fc-chapter {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 8px; padding: 8px 10px;
background: rgb(var(--v-theme-surface));
}
.fc-series__pn {
width: 28px; text-align: center;
.fc-chapter--target { border-color: rgb(var(--v-theme-accent), 0.7); }
.fc-chapter__head { display: flex; align-items: center; gap: 8px; }
.fc-chapter__num {
flex: 0 0 auto; min-width: 22px; height: 22px; border-radius: 4px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-variant-numeric: tabular-nums;
background: rgb(var(--v-theme-accent), 0.16);
color: rgb(var(--v-theme-accent));
}
.fc-chapter__title { flex: 1; min-width: 0; }
.fc-chapter__ph {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-chapter__pc {
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-series__pageactions { margin-left: auto; }
.fc-series__empty {
padding: 32px; text-align: center;
.fc-chapter__stated { display: inline-flex; align-items: center; gap: 4px; }
.fc-chapter__num-in {
width: 42px; text-align: center; font-size: 12px;
background: rgb(var(--v-theme-surface-light));
border: 1px solid transparent; border-radius: 4px; padding: 2px 4px;
color: rgb(var(--v-theme-on-surface));
}
.fc-chapter__num-in:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
.fc-chapter__actions { flex: 0 0 auto; display: inline-flex; }
.fc-chapter__reserved, .fc-chapter__empty {
padding: 14px; text-align: center; font-size: 13px;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-chapter__pages { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.fc-page {
display: flex; align-items: center; gap: 10px; padding: 6px;
background: rgb(var(--v-theme-surface-light)); border-radius: 6px; cursor: grab;
}
.fc-page img { width: 56px; height: 56px; object-fit: cover; border-radius: 4px; }
.fc-page__pn {
width: 26px; text-align: center; font-variant-numeric: tabular-nums;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-page__actions { margin-left: auto; }
.fc-gap {
display: flex; align-items: center; gap: 6px; padding: 4px 10px;
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
}
.fc-series__chapter-add { display: flex; gap: 8px; margin-top: 4px; }
.fc-series__empty {
padding: 32px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series__picker { flex: 1; min-width: 0; }
.fc-series__pickerhead {
display: flex; align-items: center; justify-content: space-between;
@@ -128,8 +292,10 @@ onMounted(async () => {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
.fc-series__pick { cursor: pointer; aspect-ratio: 1; overflow: hidden;
border-radius: 4px; outline: 2px solid transparent; }
.fc-series__pick {
cursor: pointer; aspect-ratio: 1; overflow: hidden;
border-radius: 4px; outline: 2px solid transparent;
}
.fc-series__pick img { width: 100%; height: 100%; object-fit: cover; }
.fc-series__pick.on { outline-color: rgb(var(--v-theme-accent)); }
.fc-series__sentinel { height: 40px; }
+26 -15
View File
@@ -20,9 +20,9 @@
:value="currentPage" @change="jumpTo($event.target.value)"
>
<option
v-for="p in store.pages" :key="p.page_number"
:value="p.page_number"
>Page {{ p.page_number }}</option>
v-for="p in store.pages" :key="p.seq"
:value="p.seq"
>Page {{ p.seq }}</option>
</select>
<v-btn
icon="mdi-menu" variant="text" size="small"
@@ -43,23 +43,27 @@
<div
v-for="p in store.pages" :key="p.image_id"
class="fc-reader__thumb"
:class="{ active: p.page_number === currentPage }"
@click="jumpTo(p.page_number, true)"
:class="{ active: p.seq === currentPage }"
@click="jumpTo(p.seq, true)"
>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<span class="fc-reader__thumbnum">{{ p.page_number }}</span>
<span class="fc-reader__thumbnum">{{ p.seq }}</span>
</div>
</div>
</aside>
<div ref="scrollEl" class="fc-reader__content" @scroll="onScroll">
<div
v-for="p in store.pages" :key="p.image_id"
class="fc-reader__page" :id="'fc-page-' + p.page_number"
:data-page="p.page_number"
>
<img :src="p.image_url" :alt="'Page ' + p.page_number" loading="lazy" />
</div>
<template v-for="p in store.pages" :key="p.image_id">
<div v-if="p.isChapterStart" class="fc-reader__chdiv">
{{ p.chapterLabel }}
</div>
<div
class="fc-reader__page" :id="'fc-page-' + p.seq"
:data-page="p.seq"
>
<img :src="p.image_url" :alt="'Page ' + p.seq" loading="lazy" />
</div>
</template>
<div v-if="store.error" class="fc-reader__empty">
{{ store.error }}
<v-btn variant="text" color="accent" @click="goBack">Back</v-btn>
@@ -115,9 +119,9 @@ function pageMetrics() {
const root = scrollEl.value
if (!root) return []
return store.pages.map(p => {
const el = root.querySelector('#fc-page-' + p.page_number)
const el = root.querySelector('#fc-page-' + p.seq)
return {
page_number: p.page_number,
page_number: p.seq,
top: el ? el.offsetTop : 0,
height: el ? el.offsetHeight : 0
}
@@ -280,6 +284,13 @@ onUnmounted(() => {
display: flex; flex-direction: column; align-items: center;
gap: 2px; padding: 0.25rem;
}
.fc-reader__chdiv {
width: 100%; max-width: 1200px; margin: 0.5rem auto 0;
padding: 0.4rem 0.75rem;
font-family: 'Fraunces', Georgia, serif; font-size: 0.95rem;
color: rgb(var(--v-theme-on-surface-variant));
border-top: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-reader__page { width: 100%; display: flex; justify-content: center; }
.fc-reader__page img {
max-width: min(100%, 1200px); height: auto; display: block;
+264
View File
@@ -0,0 +1,264 @@
<template>
<v-container fluid class="pt-2 pb-6">
<v-tabs v-model="tab" density="compact" class="mb-3">
<v-tab value="browse">Browse</v-tab>
<v-tab value="suggestions">
Suggestions
<v-badge
v-if="sug.suggestions.length" :content="sug.suggestions.length"
inline color="accent"
/>
</v-tab>
</v-tabs>
<v-window v-model="tab">
<!-- ---- Browse ---- -->
<v-window-item value="browse">
<div class="fc-series-browse__controls">
<v-select
:model-value="store.sort"
:items="sortItems"
density="compact" variant="outlined" hide-details
label="Sort" style="max-width: 220px;"
@update:model-value="store.setSort($event)"
/>
</div>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
Failed to load: {{ store.error }}
</v-alert>
<div
v-else-if="!store.loading && store.series.length === 0"
class="fc-series-browse__empty"
>
No series yet. Make one from a post's “Series ▾ → New series from this
post”, or create a <code>series:</code> tag and add images.
</div>
<div class="fc-series-browse__grid">
<article
v-for="s in store.series" :key="s.id" class="fc-sbcard"
@click="manage(s.id)"
>
<div class="fc-sbcard__cover">
<img v-if="s.cover_thumbnail_url" :src="s.cover_thumbnail_url" alt="" loading="lazy" />
<div v-else class="fc-sbcard__cover--empty">
<v-icon size="large">mdi-book-open-page-variant</v-icon>
</div>
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
</span>
</div>
<div class="fc-sbcard__body">
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
<div class="fc-sbcard__meta">
<span v-if="s.artist_name" class="fc-sbcard__artist">{{ s.artist_name }}</span>
<span class="fc-sbcard__counts">
{{ s.chapter_count }} ch · {{ s.page_count }} pg
</span>
</div>
<div class="fc-sbcard__actions">
<v-btn
size="x-small" variant="tonal" color="accent"
prepend-icon="mdi-book-open"
:disabled="s.page_count === 0"
@click.stop="read(s.id)"
>Read</v-btn>
<v-btn
size="x-small" variant="text"
prepend-icon="mdi-pencil" @click.stop="manage(s.id)"
>Manage</v-btn>
</div>
</div>
</article>
</div>
<div v-if="store.loading" class="fc-series-browse__sentinel">
<v-progress-circular indeterminate color="accent" size="28" />
</div>
</v-window-item>
<!-- ---- Suggestions ---- -->
<v-window-item value="suggestions">
<div class="fc-sug__controls">
<v-switch
:model-value="sug.enabled" color="accent" density="compact"
hide-details label="Matching on"
@update:model-value="sug.setEnabled($event)"
/>
<v-text-field
:model-value="sug.threshold"
type="number" min="0" max="1" step="0.05"
density="compact" variant="outlined" hide-details
label="Threshold" style="max-width: 130px;"
@change="sug.setThreshold(Number($event.target.value))"
/>
<v-spacer />
<v-btn
variant="tonal" prepend-icon="mdi-radar" @click="sug.rescan()"
>Rescan</v-btn>
</div>
<v-alert v-if="sug.error" type="error" variant="tonal" closable class="my-4">
{{ sug.error }}
</v-alert>
<div
v-else-if="!sug.loading && sug.suggestions.length === 0"
class="fc-series-browse__empty"
>
No pending suggestions. Click <strong>Rescan</strong> to look for posts
that continue an existing series.
</div>
<div class="fc-sug__list">
<div v-for="s in sug.suggestions" :key="s.id" class="fc-sug__row">
<div class="fc-sug__main">
<div class="fc-sug__title">
{{ s.post_title }}
<v-icon size="x-small">mdi-arrow-right</v-icon>
<span class="fc-sug__series">{{ s.series_name }}</span>
</div>
<div class="fc-sug__signals">
<span class="fc-sug__score">{{ pct(s.score) }}</span>
<span
v-for="k in signalKeys(s)" :key="k" class="fc-sug__chip"
>{{ k }} {{ pct(s.signals[k]) }}</span>
</div>
</div>
<div class="fc-sug__actions">
<v-btn
size="small" color="accent" variant="flat"
@click="sug.accept(s.id)"
>Add</v-btn>
<v-btn size="small" variant="text" @click="sug.dismiss(s.id)">Skip</v-btn>
</div>
</div>
</div>
</v-window-item>
</v-window>
</v-container>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
const router = useRouter()
const store = useSeriesBrowseStore()
const sug = useSeriesSuggestionsStore()
const tab = ref('browse')
const sortItems = [
{ title: 'Recently updated', value: 'recent' },
{ title: 'Name', value: 'name' },
{ title: 'Size', value: 'size' }
]
function manage(id) {
router.push({ name: 'series-manage', params: { tagId: id } })
}
function read(id) {
router.push({ name: 'series-read', params: { tagId: id } })
}
function pct(v) { return `${Math.round((v || 0) * 100)}%` }
// Only the signals that actually fired (strength > 0), in a stable order.
function signalKeys(s) {
return ['title', 'artist', 'pages', 'tags'].filter(k => (s.signals?.[k] || 0) > 0)
}
onMounted(() => {
store.load()
sug.loadSettings()
sug.load()
})
</script>
<style scoped>
.fc-series-browse__controls {
display: flex; gap: 12px; margin-bottom: 16px;
}
.fc-series-browse__empty {
padding: 48px; text-align: center;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series-browse__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.fc-sbcard {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 8px; overflow: hidden; cursor: pointer;
background: rgb(var(--v-theme-surface));
transition: border-color 120ms ease, transform 120ms ease;
}
.fc-sbcard:hover {
border-color: rgb(var(--v-theme-accent), 0.6);
transform: translateY(-2px);
}
.fc-sbcard__cover {
position: relative; aspect-ratio: 3 / 2; background: rgb(var(--v-theme-surface-light));
}
.fc-sbcard__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-sbcard__cover--empty {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-sbcard__gap {
position: absolute; top: 6px; right: 6px;
display: inline-flex; align-items: center; gap: 2px;
padding: 1px 6px; border-radius: 999px; font-size: 11px;
background: rgba(20, 23, 26, 0.75);
color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
}
.fc-sbcard__body { padding: 8px 10px; }
.fc-sbcard__name {
font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600;
margin: 0 0 4px; color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-sbcard__meta {
display: flex; justify-content: space-between; gap: 8px;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 8px;
}
.fc-sbcard__artist { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fc-sbcard__counts { flex: 0 0 auto; font-variant-numeric: tabular-nums; }
.fc-sbcard__actions { display: flex; gap: 6px; }
/* Suggestions tab */
.fc-sug__controls {
display: flex; align-items: center; gap: 16px; margin-bottom: 12px;
}
.fc-sug__list { display: flex; flex-direction: column; gap: 8px; }
.fc-sug__row {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 8px;
border: 1px solid rgb(var(--v-theme-surface-light));
background: rgb(var(--v-theme-surface));
}
.fc-sug__main { flex: 1; min-width: 0; }
.fc-sug__title {
display: flex; align-items: center; gap: 6px;
font-size: 14px; color: rgb(var(--v-theme-on-surface));
overflow: hidden;
}
.fc-sug__series { color: rgb(var(--v-theme-accent)); font-weight: 600; }
.fc-sug__signals {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px;
}
.fc-sug__score {
font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700;
color: rgb(var(--v-theme-accent));
}
.fc-sug__chip {
font-size: 11px; padding: 1px 7px; border-radius: 999px;
background: rgb(var(--v-theme-surface-light));
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-sug__actions { flex: 0 0 auto; display: flex; gap: 6px; }
</style>
+44 -18
View File
@@ -13,6 +13,19 @@ function stubFetch(handler) {
})
}
const SERIES_BODY = {
series: { id: 7, name: 'V' },
chapters: [
{
id: 1, chapter_number: 1, title: null, is_placeholder: false,
stated_page_start: null, stated_page_end: null,
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
}
],
gaps: [],
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
}
describe('seriesManage', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
@@ -22,48 +35,61 @@ describe('seriesManage', () => {
expect(moveItem([1, 2, 3], 0, 2)).toEqual([2, 3, 1])
})
it('load fetches pages + series', async () => {
it('load fetches chapters + series and picks a default target', async () => {
const s = useSeriesManageStore()
stubFetch(() => ({
status: 200,
body: { series: { id: 7, name: 'V' },
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }] }
}))
stubFetch(() => ({ status: 200, body: SERIES_BODY }))
await s.load(7)
expect(s.series).toEqual({ id: 7, name: 'V' })
expect(s.pages.map(p => p.image_id)).toEqual([1])
expect(s.chapters.map(c => c.id)).toEqual([1])
expect(s.pageCount).toBe(1)
expect(s.targetChapterId).toBe(1)
})
it('reorder posts the full ordered id list', async () => {
it('reorderPages posts ordered ids to the chapter reorder route', async () => {
const s = useSeriesManageStore()
s.tagId = 7
s.pages = [{ image_id: 1 }, { image_id: 2 }, { image_id: 3 }]
const calls = []
stubFetch((url, init) => {
calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
if (url.includes('/reorder')) return { status: 200, body: { ok: true } }
return { status: 200, body: { series: { id: 7, name: 'V' }, pages: [] } }
return { status: 200, body: SERIES_BODY }
})
await s.reorder([3, 1, 2])
const r = calls.find(c => c.url.includes('/reorder'))
expect(r.url).toContain('/api/series/7/reorder')
expect(r.body).toEqual({ image_ids: [3, 1, 2] })
await s.reorderPages(3, [30, 10, 20])
const r = calls.find(c => c.url.includes('/chapters/3/reorder'))
expect(r.url).toContain('/api/series/7/chapters/3/reorder')
expect(r.body).toEqual({ image_ids: [30, 10, 20] })
})
it('addSelected posts picker selection then reloads', async () => {
it('moveChapter reorders via the chapter id list', async () => {
const s = useSeriesManageStore()
s.tagId = 7
s.chapters = [{ id: 1 }, { id: 2 }, { id: 3 }]
const calls = []
stubFetch((url, init) => {
calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
if (url.includes('/chapters/reorder')) return { status: 200, body: { ok: true } }
return { status: 200, body: SERIES_BODY }
})
await s.moveChapter(2, -1)
const r = calls.find(c => c.url.includes('/chapters/reorder'))
expect(r.body).toEqual({ chapter_ids: [2, 1, 3] })
})
it('addSelected posts selection + target chapter then clears', async () => {
const s = useSeriesManageStore()
s.tagId = 7
s.targetChapterId = 5
s.pickerSelection = [9, 10]
const calls = []
stubFetch((url, init) => {
calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
if (url.includes('/pages') && init.method === 'POST')
if (url.endsWith('/pages') && init.method === 'POST')
return { status: 200, body: { added_count: 2 } }
return { status: 200, body: { series: { id: 7, name: 'V' }, pages: [] } }
return { status: 200, body: SERIES_BODY }
})
await s.addSelected()
const add = calls.find(c => c.url.endsWith('/api/series/7/pages'))
expect(add.body).toEqual({ image_ids: [9, 10] })
expect(add.body).toEqual({ image_ids: [9, 10], chapter_id: 5 })
expect(s.pickerSelection).toEqual([])
})
})
+60 -5
View File
@@ -5,6 +5,7 @@ without external binaries. The real subprocess behavior is exercised
implicitly via the Celery task tests in test_tasks_backup.py.
"""
import json
import subprocess
from pathlib import Path
import pytest
@@ -16,9 +17,9 @@ pytestmark = pytest.mark.integration
@pytest.fixture
def fake_subprocess(monkeypatch):
"""Replace subprocess.run with a fake that writes a sentinel to
the target path (for pg_dump's -f, for tar's -cf). Captures all
calls in a list."""
"""Fake both subprocess.run (restore path) AND subprocess.Popen (the
bounded-kill backup path) so tests run without external binaries. Each
writes the target sentinel and records the cmd in a shared list."""
calls = []
class _FakeProc:
@@ -26,17 +27,33 @@ def fake_subprocess(monkeypatch):
stdout = b""
stderr = b""
def _fake_run(cmd, **kwargs):
calls.append(list(cmd))
def _write_sentinel(cmd):
if cmd[0] == "pg_dump":
i = cmd.index("-f")
Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n")
elif cmd[0] == "tar" and "-cf" in cmd:
i = cmd.index("-cf")
Path(cmd[i + 1]).write_bytes(b"fake tar payload")
def _fake_run(cmd, **kwargs):
calls.append(list(cmd))
_write_sentinel(cmd)
return _FakeProc()
class _FakePopen:
def __init__(self, cmd, **kwargs):
calls.append(list(cmd))
self.returncode = 0
_write_sentinel(cmd)
def communicate(self, timeout=None):
return (b"", b"")
def kill(self):
self.returncode = -9
monkeypatch.setattr("subprocess.run", _fake_run)
monkeypatch.setattr("subprocess.Popen", _FakePopen)
return calls
@@ -198,3 +215,41 @@ def test_backups_dir_created_on_first_use(tmp_path):
d = backup_service._backups_dir(tmp_path)
assert d.is_dir()
assert d.name == "_backups"
# --- bounded-kill + local-temp (FC #739) -----------------------------
def test_backup_db_dumps_to_local_temp_not_nfs_backups_dir(tmp_path, fake_subprocess):
"""pg_dump must target a LOCAL temp path, not the (NFS) _backups dir —
so its long phase can't hang uninterruptibly on an NFS write."""
backup_service.backup_db(db_url="postgresql://u@h/d", images_root=tmp_path)
cmd = fake_subprocess[0]
dump_target = cmd[cmd.index("-f") + 1]
assert "_backups" not in dump_target
# The finished file still ends up in _backups (moved there).
result = backup_service.backup_db(
db_url="postgresql://u@h/d", images_root=tmp_path,
)
assert "_backups" in result["sql_path"]
def test_run_bounded_fails_fast_when_unkillable(monkeypatch):
"""A child stuck in D-state (communicate keeps timing out even after kill)
must NOT block the reaper — _run_bounded kills then re-raises promptly."""
killed = {"n": 0}
class _Hang:
def __init__(self, cmd, **kwargs):
pass
def communicate(self, timeout=None):
raise subprocess.TimeoutExpired(cmd="x", timeout=timeout or 0)
def kill(self):
killed["n"] += 1
monkeypatch.setattr("subprocess.Popen", _Hang)
with pytest.raises(subprocess.TimeoutExpired):
backup_service._run_bounded(["pg_dump"], 1)
assert killed["n"] == 1
+7 -1
View File
@@ -9,6 +9,7 @@ import pytest
from sqlalchemy import func, select
from backend.app.models import Artist, ImageRecord, Tag, TagKind
from backend.app.models.series_chapter import SeriesChapter
from backend.app.models.series_page import SeriesPage
from backend.app.models.tag import image_tag
from backend.app.services import cleanup_service
@@ -376,7 +377,12 @@ def test_reset_content_tagging_deletes_content_keeps_fandom_series(db_sync, tmp_
{"image_record_id": img.id, "tag_id": c.id},
{"image_record_id": img.id, "tag_id": s.id}, # series membership
]))
db_sync.add(SeriesPage(series_tag_id=s.id, image_id=img.id, page_number=1))
ch = SeriesChapter(series_tag_id=s.id, chapter_number=1)
db_sync.add(ch)
db_sync.flush()
db_sync.add(SeriesPage(
series_tag_id=s.id, chapter_id=ch.id, image_id=img.id, page_number=1
))
db_sync.commit()
result = cleanup_service.reset_content_tagging(db_sync, dry_run=False)
+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
+193
View File
@@ -0,0 +1,193 @@
"""FC-6.1 — chapter layer over series_page."""
import pytest
from sqlalchemy import func, select
from backend.app.models import ImageRecord, TagKind
from backend.app.models.series_chapter import SeriesChapter
from backend.app.models.series_page import SeriesPage
from backend.app.services.series_service import SeriesError, SeriesService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
_SEQ = 0
async def _img(db):
global _SEQ
_SEQ += 1
rec = ImageRecord(
path=f"/tmp/fc_sc_{_SEQ}.png", sha256=f"{_SEQ:064d}",
size_bytes=1, mime="image/png", origin="uploaded",
)
db.add(rec)
await db.flush()
return rec.id
async def _series(db, name):
return (await TagService(db).find_or_create(name, TagKind.series)).id
async def _chapter_numbers(db, sid):
rows = (
await db.execute(
select(SeriesChapter.id, SeriesChapter.chapter_number)
.where(SeriesChapter.series_tag_id == sid)
.order_by(SeriesChapter.chapter_number)
)
).all()
return [(r.id, r.chapter_number) for r in rows]
async def _page_order(db, chapter_id):
rows = (
await db.execute(
select(SeriesPage.image_id)
.where(SeriesPage.chapter_id == chapter_id)
.order_by(SeriesPage.page_number)
)
).scalars().all()
return list(rows)
@pytest.mark.asyncio
async def test_add_images_creates_default_chapter(db):
svc = SeriesService(db)
sid = await _series(db, "C1")
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2])
chapters = await _chapter_numbers(db, sid)
assert len(chapters) == 1
assert chapters[0][1] == 1
assert await _page_order(db, chapters[0][0]) == [i1, i2]
@pytest.mark.asyncio
async def test_create_and_target_specific_chapter(db):
svc = SeriesService(db)
sid = await _series(db, "C2")
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1]) # default chapter 1
ch2 = await svc.create_chapter(sid, title="Two")
assert ch2["chapter_number"] == 2
await svc.add_images(sid, [i2], chapter_id=ch2["id"])
assert await _page_order(db, ch2["id"]) == [i2]
# i1 stayed in chapter 1.
chapters = await _chapter_numbers(db, sid)
assert len(chapters) == 2
@pytest.mark.asyncio
async def test_reorder_chapters(db):
svc = SeriesService(db)
sid = await _series(db, "C3")
c1 = (await svc.create_chapter(sid))["id"]
c2 = (await svc.create_chapter(sid))["id"]
c3 = (await svc.create_chapter(sid))["id"]
await svc.reorder_chapters(sid, [c3, c1, c2])
assert await _chapter_numbers(db, sid) == [(c3, 1), (c1, 2), (c2, 3)]
with pytest.raises(SeriesError):
await svc.reorder_chapters(sid, [c1, c2])
@pytest.mark.asyncio
async def test_reorder_pages_within_chapter(db):
svc = SeriesService(db)
sid = await _series(db, "C4")
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2, i3])
cid = (await _chapter_numbers(db, sid))[0][0]
await svc.reorder_pages(sid, cid, [i3, i1, i2])
assert await _page_order(db, cid) == [i3, i1, i2]
@pytest.mark.asyncio
async def test_legacy_reorder_rejects_multi_chapter(db):
svc = SeriesService(db)
sid = await _series(db, "C5")
i1 = await _img(db)
await svc.add_images(sid, [i1])
await svc.create_chapter(sid) # now 2 chapters
with pytest.raises(SeriesError):
await svc.reorder(sid, [i1])
@pytest.mark.asyncio
async def test_merge_chapter_moves_pages_and_renumbers(db):
svc = SeriesService(db)
sid = await _series(db, "C6")
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1]) # chapter 1
c2 = (await svc.create_chapter(sid))["id"]
await svc.add_images(sid, [i2, i3], chapter_id=c2)
c1 = (await _chapter_numbers(db, sid))[0][0]
moved = await svc.merge_chapter(sid, c2, c1)
assert moved == 2
assert await _page_order(db, c1) == [i1, i2, i3]
# source chapter gone; remaining renumbered 1..N.
assert await _chapter_numbers(db, sid) == [(c1, 1)]
@pytest.mark.asyncio
async def test_delete_chapter_cascades_pages_and_renumbers(db):
svc = SeriesService(db)
sid = await _series(db, "C7")
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1])
c2 = (await svc.create_chapter(sid))["id"]
await svc.add_images(sid, [i2], chapter_id=c2)
await svc.delete_chapter(sid, c2)
assert [c for _, c in await _chapter_numbers(db, sid)] == [1]
# i2's page cascaded away with its chapter.
cnt = await db.scalar(
select(func.count()).select_from(SeriesPage)
.where(SeriesPage.image_id == i2)
)
assert cnt == 0
@pytest.mark.asyncio
async def test_set_cover_brings_chapter_and_page_to_front(db):
svc = SeriesService(db)
sid = await _series(db, "C8")
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await svc.add_images(sid, [i1]) # chapter 1
c2 = (await svc.create_chapter(sid))["id"]
await svc.add_images(sid, [i2, i3], chapter_id=c2)
await svc.set_cover(sid, i3)
chapters = await _chapter_numbers(db, sid)
assert chapters[0][0] == c2 # i3's chapter is now first
assert (await _page_order(db, c2))[0] == i3 # i3 first in its chapter
@pytest.mark.asyncio
async def test_list_pages_flags_gaps_from_stated_ranges(db):
svc = SeriesService(db)
sid = await _series(db, "C9")
await svc.create_chapter(sid, stated_page_start=1, stated_page_end=4)
await svc.create_chapter(sid, stated_page_start=9, stated_page_end=12)
out = await svc.list_pages(sid)
assert len(out["chapters"]) == 2
assert len(out["gaps"]) == 1
assert out["gaps"][0]["start"] == 5
assert out["gaps"][0]["end"] == 8
@pytest.mark.asyncio
async def test_add_with_stated_pages_records_them(db):
svc = SeriesService(db)
sid = await _series(db, "C10")
i1, i2 = await _img(db), await _img(db)
await svc.add_images(sid, [i1, i2], stated_pages={i1: 9, i2: 10})
rows = dict(
(
await db.execute(
select(SeriesPage.image_id, SeriesPage.stated_page)
.where(SeriesPage.series_tag_id == sid)
)
).all()
)
assert rows[i1] == 9
assert rows[i2] == 10
+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))
+48
View File
@@ -0,0 +1,48 @@
"""Pure scoring helpers for the series continuation matcher (FC-6.3)."""
from backend.app.services.series_match_service import (
normalize_title,
pages_signal,
tags_signal,
title_signal,
weighted_score,
)
def test_normalize_title_strips_installment_markers():
assert normalize_title("Story pages 9-12") == "story"
assert normalize_title("Bunker Diaries 04") == "bunker diaries"
assert normalize_title("My Comic [3/8]") == "my comic"
assert normalize_title("") == ""
assert normalize_title(None) == ""
def test_title_signal():
# Same stem after stripping page markers → full match.
assert title_signal(["Story pages 1-4"], "Story pages 9-12") == 1.0
# Unrelated → no overlap.
assert title_signal(["My Comic"], "Other Thing") == 0.0
# No data → 0.
assert title_signal([], "x") == 0.0
def test_pages_signal():
assert pages_signal(8, 9) == 1.0 # clean continuation
assert pages_signal(8, 10) == 0.5 # near (diff 2)
assert pages_signal(8, 11) == 0.5 # near (diff 3)
assert pages_signal(8, 20) == 0.0 # far
assert pages_signal(8, 5) == 0.0 # before / overlap
assert pages_signal(None, 9) == 0.0
def test_tags_signal():
assert tags_signal(0) == 0.0
assert tags_signal(3) == 1.0
assert tags_signal(6) == 1.0 # capped
assert round(tags_signal(1), 3) == 0.333
def test_weighted_score():
assert weighted_score({"title": 1, "artist": 1, "pages": 1, "tags": 1}) == 1.0
assert weighted_score({}) == 0.0
assert weighted_score({"title": 1.0, "artist": 1.0}) == 0.6
+127
View File
@@ -0,0 +1,127 @@
"""FC-6.3 — matcher writes pending suggestions; accept/dismiss lifecycle."""
import pytest
from sqlalchemy import func, select
from backend.app.models import Artist, ImageRecord, Post, TagKind
from backend.app.models.series_suggestion import SeriesSuggestion
from backend.app.services.series_match_service import SeriesMatchService
from backend.app.services.series_service import SeriesService
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_with_images(db, artist, title, ext, n):
global _SEQ
p = Post(artist_id=artist.id, external_post_id=ext, post_title=title)
db.add(p)
await db.flush()
for _ in range(n):
_SEQ += 1
db.add(ImageRecord(
path=f"/tmp/fc_sm_{_SEQ}.png", sha256=f"{_SEQ:064d}",
size_bytes=1, mime="image/png", origin="downloaded",
primary_post_id=p.id, artist_id=artist.id,
))
await db.flush()
return p
async def _series_from(db, post):
out = await SeriesService(db).promote_post_to_series(post.id)
return out["series_tag_id"]
@pytest.mark.asyncio
async def test_match_post_suggests_continuation(db):
artist = await _artist(db, "Story Artist")
a = await _post_with_images(db, artist, "Story pages 1-4", "a", 4)
sid = await _series_from(db, a) # series stated 1-4
b = await _post_with_images(db, artist, "Story pages 5-8", "b", 4)
svc = SeriesMatchService(db)
made = await svc.match_post(b.id, threshold=0.5)
assert made == 1
pending = await svc.list_pending()
assert len(pending) == 1
sug = pending[0]
assert sug["series_tag_id"] == sid
assert sug["post_id"] == b.id
# title 1.0 + artist 1.0 + pages 1.0 (5 == 4+1) → 0.4+0.2+0.25 = 0.85
assert sug["score"] >= 0.85
assert sug["signals"]["pages"] == 1.0
@pytest.mark.asyncio
async def test_match_is_idempotent_under_unique(db):
artist = await _artist(db, "Idem Artist")
a = await _post_with_images(db, artist, "Tale pages 1-4", "a", 2)
await _series_from(db, a)
b = await _post_with_images(db, artist, "Tale pages 5-8", "b", 2)
svc = SeriesMatchService(db)
await svc.match_post(b.id, threshold=0.5)
await svc.match_post(b.id, threshold=0.5) # again
cnt = await db.scalar(
select(func.count()).select_from(SeriesSuggestion)
.where(SeriesSuggestion.post_id == b.id)
)
assert cnt == 1
@pytest.mark.asyncio
async def test_accept_adds_chapter_and_marks_added(db):
artist = await _artist(db, "Accept Artist")
a = await _post_with_images(db, artist, "Saga pages 1-4", "a", 3)
sid = await _series_from(db, a)
b = await _post_with_images(db, artist, "Saga pages 5-8", "b", 3)
svc = SeriesMatchService(db)
await svc.match_post(b.id, threshold=0.5)
sug = (await svc.list_pending())[0]
await svc.accept(sug["id"])
data = await SeriesService(db).list_pages(sid)
assert len(data["chapters"]) == 2 # b became a 2nd chapter
status = await db.scalar(
select(SeriesSuggestion.status).where(SeriesSuggestion.id == sug["id"])
)
assert status == "added"
@pytest.mark.asyncio
async def test_dismiss_then_rematch_skips(db):
artist = await _artist(db, "Dismiss Artist")
a = await _post_with_images(db, artist, "Epic pages 1-4", "a", 2)
await _series_from(db, a)
b = await _post_with_images(db, artist, "Epic pages 5-8", "b", 2)
svc = SeriesMatchService(db)
await svc.match_post(b.id, threshold=0.5)
sug = (await svc.list_pending())[0]
await svc.dismiss(sug["id"])
assert await svc.list_pending() == []
# A re-match must respect the dismissal (decided → skipped).
made = await svc.match_post(b.id, threshold=0.5)
assert made == 0
@pytest.mark.asyncio
async def test_rescan_scans_posts(db):
artist = await _artist(db, "Rescan Artist")
a = await _post_with_images(db, artist, "Quest pages 1-4", "a", 2)
await _series_from(db, a)
await _post_with_images(db, artist, "Quest pages 5-8", "b", 2)
svc = SeriesMatchService(db)
summary = await svc.rescan(threshold=0.5)
assert summary["scanned"] >= 2
assert summary["suggested"] >= 1
assert summary["partial"] is False
+16 -2
View File
@@ -3,6 +3,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from backend.app.models import ImageRecord, TagKind
from backend.app.models.series_chapter import SeriesChapter
from backend.app.models.series_page import SeriesPage
from backend.app.services.tag_service import TagService
@@ -24,10 +25,20 @@ async def _img(db):
@pytest.mark.asyncio
async def _chapter(db, series_tag_id):
ch = SeriesChapter(series_tag_id=series_tag_id, chapter_number=1)
db.add(ch)
await db.flush()
return ch.id
async def test_series_page_roundtrip_and_unique_image(db):
s = await TagService(db).find_or_create("Vol1", TagKind.series)
i1 = await _img(db)
db.add(SeriesPage(series_tag_id=s.id, image_id=i1, page_number=1))
ch = await _chapter(db, s.id)
db.add(SeriesPage(
series_tag_id=s.id, chapter_id=ch, image_id=i1, page_number=1
))
await db.flush()
got = await db.scalar(
select(SeriesPage.page_number).where(SeriesPage.image_id == i1)
@@ -35,6 +46,9 @@ async def test_series_page_roundtrip_and_unique_image(db):
assert got == 1
# UNIQUE image_id: same image can't be a page twice (any series).
s2 = await TagService(db).find_or_create("Vol2", TagKind.series)
db.add(SeriesPage(series_tag_id=s2.id, image_id=i1, page_number=1))
ch2 = await _chapter(db, s2.id)
db.add(SeriesPage(
series_tag_id=s2.id, chapter_id=ch2, image_id=i1, page_number=1
))
with pytest.raises(IntegrityError):
await db.flush()
+12 -1
View File
@@ -414,16 +414,27 @@ async def test_alias_create_does_not_clobber_existing(db):
@pytest.mark.asyncio
async def test_merge_repoints_series_pages(db):
from backend.app.models.series_chapter import SeriesChapter
from backend.app.models.series_page import SeriesPage
svc = TagService(db)
a = await svc.find_or_create("SerA", TagKind.series)
b = await svc.find_or_create("SerB", TagKind.series)
img = await _img(db)
db.add(SeriesPage(series_tag_id=a.id, image_id=img, page_number=1))
cha = SeriesChapter(series_tag_id=a.id, chapter_number=1)
db.add(cha)
await db.flush()
db.add(SeriesPage(
series_tag_id=a.id, chapter_id=cha.id, image_id=img, page_number=1
))
await db.flush()
await svc.merge(a.id, b.id)
owner = await db.scalar(
select(SeriesPage.series_tag_id).where(SeriesPage.image_id == img)
)
assert owner == b.id
# The chapter moved to the survivor too, so the page's FK stayed valid.
ch_owner = await db.scalar(
select(SeriesChapter.series_tag_id).where(SeriesChapter.id == cha.id)
)
assert ch_owner == b.id
+50 -2
View File
@@ -34,16 +34,32 @@ def fake_subprocess_and_images_root(monkeypatch, tmp_path):
stdout = b""
stderr = b""
def _fake_run(cmd, **kwargs):
def _sentinel(cmd):
if cmd[0] == "pg_dump":
i = cmd.index("-f")
Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n")
elif cmd[0] == "tar" and "-cf" in cmd:
i = cmd.index("-cf")
Path(cmd[i + 1]).write_bytes(b"fake tar payload")
def _fake_run(cmd, **kwargs):
_sentinel(cmd)
return _FakeProc()
class _FakePopen:
def __init__(self, cmd, **kwargs):
self.returncode = 0
_sentinel(cmd)
def communicate(self, timeout=None):
return (b"", b"")
def kill(self):
self.returncode = -9
# backup_db/backup_images go through _run_bounded (Popen); restore via run.
monkeypatch.setattr("subprocess.run", _fake_run)
monkeypatch.setattr("subprocess.Popen", _FakePopen)
def _seed_backup(db_sync, *, kind, status, started_at, tag=None,
@@ -89,7 +105,8 @@ async def test_backup_db_task_records_failure_on_subprocess_error(db_sync, monke
def _boom(*a, **kw):
raise RuntimeError("synthetic pg_dump fail")
monkeypatch.setattr("subprocess.run", _boom)
# backup_db dumps via _run_bounded → subprocess.Popen.
monkeypatch.setattr("subprocess.Popen", _boom)
with pytest.raises(RuntimeError):
backup_db_task.delay().get()
@@ -237,6 +254,37 @@ def test_prune_backups_never_deletes_running_or_restoring(db_sync):
assert set(statuses) == {"running", "restoring"}
# --- recover_stalled_backup_runs (per-kind threshold, FC #739) -------
def test_stall_sweep_flips_db_fast_but_spares_running_images(db_sync):
from backend.app.tasks.maintenance import recover_stalled_backup_runs
now = datetime.now(UTC)
# A db backup stuck 50 min → past the 40-min db window → flipped to error.
db_id = _seed_backup(
db_sync, kind="db", status="running",
started_at=now - timedelta(minutes=50),
)
# An images backup running 50 min is still well under the 7h window → spared.
img_id = _seed_backup(
db_sync, kind="images", status="running",
started_at=now - timedelta(minutes=50),
)
db_sync.commit()
recover_stalled_backup_runs.apply().get()
db_status = db_sync.execute(
select(BackupRun.status).where(BackupRun.id == db_id)
).scalar_one()
img_status = db_sync.execute(
select(BackupRun.status).where(BackupRun.id == img_id)
).scalar_one()
assert db_status == "error"
assert img_status == "running"
# --- backup_db_nightly ----------------------------------------------