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
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:
@@ -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")
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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 9–12", "pp. 9 - 12" -> (9, 12)
|
||||
fraction "page 3 of 8", "pg 3/8", "[3/8]", "(3/8)" -> (3, 3)
|
||||
single "page 5", "pg 5", "pp 5" -> (5, 5)
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Page keyword: page/pages/pg/pgs/pp/pp. (NOT a bare "p" — too many false hits.)
|
||||
_KW = r"(?:pages?|pgs?|pp\.?)"
|
||||
_DASH = r"[-–—]"
|
||||
|
||||
_RANGE = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\s*{_DASH}\s*(\d{{1,4}})", re.I)
|
||||
_OF = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\s*(?:of|/)\s*\d{{1,4}}\b", re.I)
|
||||
_BRACKET = re.compile(r"[\[(]\s*(\d{1,4})\s*/\s*\d{1,4}\s*[\])]")
|
||||
_SINGLE = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\b", re.I)
|
||||
|
||||
|
||||
def parse_page_range(text: str | None) -> tuple[int, int] | None:
|
||||
"""Return (start, end) or None. start <= end; a single page yields (n, n)."""
|
||||
if not text:
|
||||
return None
|
||||
m = _RANGE.search(text)
|
||||
if m:
|
||||
a, b = int(m.group(1)), int(m.group(2))
|
||||
return (a, b) if a <= b else (b, a)
|
||||
for rx in (_OF, _BRACKET, _SINGLE):
|
||||
m = rx.search(text)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
return (n, n)
|
||||
return None
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Pure unit tests for the stated-page parser (FC-6.2)."""
|
||||
|
||||
from backend.app.services.page_number_parser import parse_page_range
|
||||
|
||||
|
||||
def test_explicit_ranges():
|
||||
assert parse_page_range("pages 9-12") == (9, 12)
|
||||
assert parse_page_range("pg 9–12") == (9, 12) # en-dash
|
||||
assert parse_page_range("pp. 9 - 12") == (9, 12)
|
||||
assert parse_page_range("Chapter 2 (pages 1-4)") == (1, 4)
|
||||
|
||||
|
||||
def test_reversed_range_is_normalized():
|
||||
assert parse_page_range("pages 12-9") == (9, 12)
|
||||
|
||||
|
||||
def test_single_page():
|
||||
assert parse_page_range("Page 5") == (5, 5)
|
||||
assert parse_page_range("pg 5") == (5, 5)
|
||||
|
||||
|
||||
def test_fraction_forms_take_the_numerator():
|
||||
assert parse_page_range("[3/8]") == (3, 3)
|
||||
assert parse_page_range("(3/8)") == (3, 3)
|
||||
assert parse_page_range("page 3 of 8") == (3, 3)
|
||||
assert parse_page_range("pg 3/8") == (3, 3)
|
||||
|
||||
|
||||
def test_no_page_keyword_is_none():
|
||||
# Bare numbers / non-page words must not match (false positives are worse).
|
||||
assert parse_page_range("My Comic Part Three") is None
|
||||
assert parse_page_range("Episode 5") is None
|
||||
assert parse_page_range("Released 2024") is None
|
||||
assert parse_page_range("Part 3") is None
|
||||
|
||||
|
||||
def test_empty_and_none():
|
||||
assert parse_page_range("") is None
|
||||
assert parse_page_range(None) is None
|
||||
@@ -0,0 +1,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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ----------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user