Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0666e15211 | |||
| a00a2786e3 | |||
| 9770dd3474 | |||
| 7daf90f41e | |||
| aaa375654b | |||
| 5bc8ef65ad | |||
| 978959bdc4 | |||
| 7309d1d6d4 | |||
| 747390631d | |||
| daaa7543a8 | |||
| 19a91a1641 | |||
| c0fd80e694 | |||
| 9e262cc5f0 | |||
| db490e92df | |||
| 8ad40da145 | |||
| 1804a2c622 | |||
| 43b02d79a4 | |||
| 677317244e | |||
| a92817677d | |||
| e0d2a20588 | |||
| 394c7dcd67 | |||
| a73d9327d8 | |||
| 5201fab088 | |||
| b79708524e | |||
| 1d84f67418 | |||
| 1226d3b23a | |||
| 6c5dbfe4a0 | |||
| 91265df3d6 | |||
| 68cda6114d | |||
| c217009425 | |||
| f4f49d407e | |||
| a183be7e6e | |||
| f2e9ae07dc | |||
| d9d502a60d | |||
| 1819caaf5b | |||
| 22dc516dc7 | |||
| 11acdb0322 | |||
| 4c42a15fa1 | |||
| 2eb9fd5dd0 | |||
| 14c4dd1ea0 | |||
| 619e7712c2 | |||
| 01e5ce1410 | |||
| 416d8d71cd | |||
| 3bb94674cf | |||
| a3c9499e93 | |||
| 87c7318125 | |||
| a75c602175 | |||
| e4e35163ab | |||
| b7d07324ee | |||
| c65da42593 | |||
| ef8f4f7193 | |||
| 19eb4e9388 | |||
| a559fabdd5 | |||
| 711fd2bb75 | |||
| 3556a54260 | |||
| df2310bc70 | |||
| 9374f63953 | |||
| ec3d27b219 | |||
| 6332ae13fd | |||
| 3c89223dcb | |||
| 23f452021f | |||
| 62cca64dce | |||
| 89dfa42e18 | |||
| 2b69540ecc | |||
| 4fe53cdf6b | |||
| cb9b286c53 | |||
| a497104661 | |||
| 5bb25245a5 | |||
| 911d535f56 | |||
| 03bd3b2eda | |||
| e82c2ee57b | |||
| cd43439401 | |||
| bde19944db | |||
| 402086c34c | |||
| fb7383eea7 | |||
| e47fa0cf4b | |||
| b211900390 | |||
| 697a86d31c | |||
| 9a2cd569c3 | |||
| d592e0ca02 | |||
| 7a872a3619 | |||
| 4bb11ce7dc | |||
| e42a86d995 | |||
| b2e59e7e17 | |||
| e53f8959af | |||
| 5b615b7ded | |||
| d6c15f4ea0 | |||
| 3b2f7a41c3 | |||
| 218bfebb92 | |||
| ec43e823e1 | |||
| 682beafbc5 | |||
| 96c30eba13 | |||
| 2ec7d86a3b | |||
| 6222928746 | |||
| 1bdaa04aa2 | |||
| 1c2dc7659a | |||
| 7395e77d75 | |||
| 618dafde85 | |||
| 96fffaff64 | |||
| 575d817919 | |||
| add1c1ad14 | |||
| 593f65c9cc |
@@ -69,3 +69,4 @@ Thumbs.db
|
||||
alembic/versions/__pycache__/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
.superpowers/
|
||||
|
||||
+22
-1
@@ -2,12 +2,21 @@
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy import engine_from_config, pool, text
|
||||
|
||||
from alembic import context
|
||||
from backend.app.config import get_config
|
||||
from backend.app.models import Base
|
||||
|
||||
# Arbitrary fixed 64-bit key for the session/transaction advisory lock that
|
||||
# serializes concurrent `alembic upgrade head` runs. Every `web` replica runs
|
||||
# migrations in its entrypoint, so under `docker stack deploy` two replicas can
|
||||
# boot at once and race the same DDL — duplicate CREATE TABLE, then a crashed
|
||||
# replica (operator-flagged 2026-06-07: 0040 raced; one backend died with
|
||||
# AdminShutdown). The first replica to reach the lock migrates; the rest block,
|
||||
# then find the version table already at head and apply nothing.
|
||||
_MIGRATION_LOCK_KEY = 0xFCA1E35C
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
@@ -44,6 +53,18 @@ def run_migrations_online() -> None:
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
# Serialize concurrent migrators (see _MIGRATION_LOCK_KEY). A
|
||||
# transaction-scoped advisory lock: the first replica to get here
|
||||
# holds it for the whole upgrade and is auto-released when this
|
||||
# transaction ends. A sibling replica blocks on this line, and only
|
||||
# once the leader commits does it proceed to read the version table
|
||||
# — now at head — so it runs zero migrations instead of re-applying
|
||||
# the same DDL. The lock is acquired BEFORE run_migrations() reads
|
||||
# the current revision, which is what makes the no-op correct.
|
||||
connection.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:k)"),
|
||||
{"k": _MIGRATION_LOCK_KEY},
|
||||
)
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""patreon_seen_media: per-source ledger of already-ingested Patreon media
|
||||
|
||||
Revision ID: 0037
|
||||
Revises: 0036
|
||||
Create Date: 2026-06-05
|
||||
|
||||
Native Patreon ingester (build step 2a). Replaces gallery-dl's
|
||||
archive.sqlite3 with our own queryable table. The downloader upserts one
|
||||
row per (source, media) so routine walks skip media we've already
|
||||
processed; a future "recovery" mode bypasses the ledger to re-walk.
|
||||
|
||||
`filehash` is a 32-hex Patreon CDN MD5, OR a video sentinel of the form
|
||||
``video:<post_id>:<media_id>`` — hence String(128). The unique
|
||||
constraint on (source_id, filehash) is the dedup upsert key.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0037"
|
||||
down_revision: Union[str, None] = "0036"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"patreon_seen_media",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"source_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("source.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("filehash", sa.String(128), nullable=False),
|
||||
sa.Column("post_id", sa.String(64), nullable=True),
|
||||
sa.Column(
|
||||
"seen_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"source_id", "filehash", name="uq_patreon_seen_media_source_id"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("patreon_seen_media")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""patreon_failed_media: per-source dead-letter ledger for failing Patreon media
|
||||
|
||||
Revision ID: 0038
|
||||
Revises: 0037
|
||||
Create Date: 2026-06-06
|
||||
|
||||
Plan #705 (#7). Media that keeps failing to download/validate (404'd CDN,
|
||||
deleted post, geo-blocked Mux, persistently-corrupt bytes) gets recorded here
|
||||
with an attempt counter; once it crosses the dead-letter threshold the ingester
|
||||
skips it on routine walks (recovery still re-attempts). A clean download clears
|
||||
the row. UNIQUE (source_id, filehash) is the upsert key (same media key the
|
||||
seen-ledger uses).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0038"
|
||||
down_revision: Union[str, None] = "0037"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"patreon_failed_media",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"source_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("source.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("filehash", sa.String(128), nullable=False),
|
||||
sa.Column("attempts", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("last_error", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"first_failed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.Column(
|
||||
"last_failed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"source_id", "filehash", name="uq_patreon_failed_media_source_id"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("patreon_failed_media")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""library_audit_run: resume cursor + progress timestamp for chunked scans
|
||||
|
||||
Revision ID: 0039
|
||||
Revises: 0038
|
||||
Create Date: 2026-06-07
|
||||
|
||||
scan_library_for_rule used to run one 2h pass that timed out on large libraries
|
||||
and monopolized the concurrency-1 maintenance queue (operator-flagged). It now
|
||||
runs short time-boxed chunks that re-enqueue: `resume_after_id` persists the
|
||||
keyset cursor so the next chunk continues where it left off, and
|
||||
`last_progress_at` lets the recovery sweep tell a progressing multi-chunk audit
|
||||
from a genuinely stuck one.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0039"
|
||||
down_revision: Union[str, None] = "0038"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"library_audit_run",
|
||||
sa.Column(
|
||||
"resume_after_id", sa.Integer, nullable=False, server_default="0"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"library_audit_run",
|
||||
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("library_audit_run", "last_progress_at")
|
||||
op.drop_column("library_audit_run", "resume_after_id")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""series chapter stated_part: operator-facing Part N label (FC-6.4)
|
||||
|
||||
Revision ID: 0042
|
||||
Revises: 0041
|
||||
Create Date: 2026-06-07
|
||||
|
||||
A chapter's positional chapter_number is auto-managed (rewritten 1..N on
|
||||
reorder/delete), so it can't double as the installment number the operator wants
|
||||
to type (e.g. a series authored from a post that is Part 2). Add a nullable
|
||||
stated_part alongside it — the same split as series_page.page_number (order) vs
|
||||
series_page.stated_page (printed number). Nullable; the UI falls back to
|
||||
chapter_number when unset.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0042"
|
||||
down_revision: Union[str, None] = "0041"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"series_chapter", sa.Column("stated_part", sa.Integer, nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("series_chapter", "stated_part")
|
||||
@@ -245,6 +245,32 @@ async def tags_reset_content():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/normalize", methods=["POST"])
|
||||
async def tags_normalize():
|
||||
"""#714: retro-normalize existing tags to the #701 canonical form (Title
|
||||
Case + collapsed whitespace) and merge case/whitespace-variant duplicates.
|
||||
|
||||
dry_run=true (default) returns a projection inline — group/collision/rename
|
||||
counts + a sample of the changes — so the UI shows exactly what'll happen.
|
||||
dry_run=false dispatches the long-running maintenance task (the merge FK
|
||||
repoints can touch many tags); the UI tails the activity dashboard for the
|
||||
summary. Idempotent; back up first (the merges are irreversible)."""
|
||||
from ..services.tag_service import normalize_existing_tags
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", True))
|
||||
|
||||
if dry_run:
|
||||
async with get_session() as session:
|
||||
result = await normalize_existing_tags(session, dry_run=True)
|
||||
return jsonify(result)
|
||||
|
||||
from ..tasks.admin import normalize_tags_task
|
||||
|
||||
async_result = normalize_tags_task.delay()
|
||||
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
|
||||
async def db_stats():
|
||||
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
||||
@@ -289,3 +315,14 @@ async def trigger_vacuum():
|
||||
|
||||
vacuum_analyze.delay()
|
||||
return jsonify({"status": "queued"}), 202
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/reextract-archives", methods=["POST"])
|
||||
async def trigger_reextract_archives():
|
||||
"""Operator-triggered re-extract (#713): PostAttachments that are actually
|
||||
archives but were filed opaquely (pre magic-byte gate) get extracted and
|
||||
their members linked to the post. Idempotent; runs on the maintenance queue."""
|
||||
from ..tasks.admin import reextract_archive_attachments_task
|
||||
|
||||
async_result = reextract_archive_attachments_task.delay()
|
||||
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
|
||||
|
||||
@@ -121,13 +121,15 @@ async def delete_credential(platform: str):
|
||||
|
||||
@credentials_bp.route("/<platform>/verify", methods=["POST"])
|
||||
async def verify_credential(platform: str):
|
||||
"""Test the stored credential by running gallery-dl --simulate
|
||||
against one of the platform's enabled sources. On success stamps
|
||||
last_verified. Returns {valid: bool|null, reason, last_verified?}.
|
||||
valid=null means "couldn't test" (no credential, or no enabled
|
||||
source to point at)."""
|
||||
"""Test the stored credential against one of the platform's enabled sources,
|
||||
WITHOUT downloading. Routes through the platform's backend
|
||||
(download_backends.verify_credential) — native ingester for Patreon, an
|
||||
authenticated API page; gallery-dl --simulate for the rest. On success
|
||||
stamps last_verified. Returns {valid: bool|null, reason, last_verified?};
|
||||
valid=null means "couldn't test" (no credential, no enabled source, or an
|
||||
inconclusive network/drift result)."""
|
||||
from ..models import Artist, Source
|
||||
from ..services.gallery_dl import GalleryDLService, SourceConfig
|
||||
from ..services.download_backends import verify_source_credential
|
||||
|
||||
async with get_session() as session:
|
||||
if not await _ext_key_ok(session):
|
||||
@@ -154,14 +156,14 @@ async def verify_credential(platform: str):
|
||||
cookies_path = await svc.get_cookies_path(platform)
|
||||
auth_token = await svc.get_token(platform)
|
||||
|
||||
gdl = GalleryDLService(images_root=Path("/images"))
|
||||
ok, message = await gdl.verify(
|
||||
ok, message = await verify_source_credential(
|
||||
platform=platform,
|
||||
url=source.url,
|
||||
artist_slug=artist.slug,
|
||||
platform=platform,
|
||||
source_config=SourceConfig.from_dict(source.config_overrides or {}),
|
||||
config_overrides=source.config_overrides or {},
|
||||
cookies_path=str(cookies_path) if cookies_path else None,
|
||||
auth_token=auth_token,
|
||||
images_root=Path("/images"),
|
||||
)
|
||||
|
||||
last_verified = None
|
||||
|
||||
@@ -44,6 +44,9 @@ def _list_record(event: DownloadEvent, source: Source | None, artist: Artist | N
|
||||
"bytes_downloaded": event.bytes_downloaded,
|
||||
"error": event.error,
|
||||
"summary": _summary_from_metadata(event.metadata_),
|
||||
# plan #709: mid-walk live counts for a RUNNING native-ingester event
|
||||
# (None otherwise; phase 3 overwrites metadata with run_stats on finish).
|
||||
"live": (event.metadata_ or {}).get("live"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+121
-15
@@ -85,6 +85,22 @@ async def create_source():
|
||||
return _bad("empty_url", detail=str(exc))
|
||||
except DuplicateSourceError as exc:
|
||||
return _bad("duplicate", status=409, existing_id=exc.existing_id)
|
||||
|
||||
# Immediate kickoff: a new enabled source is armed for backfill (#693)
|
||||
# but would otherwise sit idle until the next scheduler tick (~60s).
|
||||
# Enqueue the first walk now, skipping only if the platform is in a
|
||||
# rate-limit cooldown (the scheduler picks it up when that clears).
|
||||
dispatch_id = None
|
||||
if record.enabled:
|
||||
cooldowns = await active_platform_cooldowns(session)
|
||||
if record.platform not in cooldowns:
|
||||
session.add(DownloadEvent(source_id=record.id, status="pending"))
|
||||
await session.commit()
|
||||
dispatch_id = record.id
|
||||
|
||||
if dispatch_id is not None:
|
||||
from ..tasks.download import download_source
|
||||
download_source.delay(dispatch_id)
|
||||
return jsonify(record.to_dict()), 201
|
||||
|
||||
|
||||
@@ -122,29 +138,119 @@ async def delete_source(source_id: int):
|
||||
|
||||
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
|
||||
async def set_backfill(source_id: int):
|
||||
"""Plan #544: arm a source for backfill mode for the next N download
|
||||
runs. Body: `{"runs": int}` (1..10, default 3). Returns the updated
|
||||
source dict. While backfill_runs_remaining > 0, downloads use
|
||||
gallery-dl's full-walk config (skip: True + 30-min timeout) instead
|
||||
of the catch-up default (skip: "exit:20" + 14.5-min timeout)."""
|
||||
"""Plan #693/#697: start/stop a run-until-done backfill, or start a recovery.
|
||||
Body: `{"action": "start" | "stop" | "recover"}` (default "start"). 'start'
|
||||
walks the full post history in time-boxed chunks until it reaches the bottom
|
||||
(then the source shows 'complete'); 'recover' is the same walk but bypasses
|
||||
the Patreon seen-ledger to re-fetch dropped-and-deleted near-dups under the
|
||||
current pHash threshold; 'stop' cancels either back to tick mode. Returns the
|
||||
updated source dict (incl. backfill_state / backfill_chunks /
|
||||
backfill_bypass_seen)."""
|
||||
from pathlib import Path
|
||||
|
||||
from ..services.credential_service import CredentialService
|
||||
from ..services.download_backends import (
|
||||
uses_native_ingester,
|
||||
verify_source_credential,
|
||||
)
|
||||
from .credentials import _get_crypto
|
||||
|
||||
payload = await request.get_json(silent=True) or {}
|
||||
runs = payload.get("runs", 3)
|
||||
try:
|
||||
runs = int(runs)
|
||||
except (TypeError, ValueError):
|
||||
return _bad("invalid_runs", detail="runs must be an integer")
|
||||
action = payload.get("action", "start")
|
||||
if action not in ("start", "stop", "recover"):
|
||||
return _bad(
|
||||
"invalid_action",
|
||||
detail="action must be 'start', 'stop', or 'recover'",
|
||||
)
|
||||
|
||||
# Pre-flight (plan #703 #2): before arming a deep walk on a native-ingester
|
||||
# platform (where verify is one cheap API page), refuse if the credential is
|
||||
# DEFINITIVELY rejected — don't burn chunks against expired cookies. Proceed
|
||||
# on valid OR inconclusive (a network blip shouldn't block). Gated to native
|
||||
# platforms: gallery-dl verify is a slow --simulate subprocess, too heavy for
|
||||
# an arm action. The credential read happens in a session that's CLOSED
|
||||
# before the verify network call (don't hold a DB conn across the request).
|
||||
if action in ("start", "recover"):
|
||||
async with get_session() as session:
|
||||
rec = await SourceService(session).get(source_id)
|
||||
if rec is None:
|
||||
return _bad("not_found", status=404)
|
||||
native = uses_native_ingester(rec.platform)
|
||||
if native:
|
||||
cred = CredentialService(session, _get_crypto())
|
||||
cookies_path = await cred.get_cookies_path(rec.platform)
|
||||
auth_token = await cred.get_token(rec.platform)
|
||||
if native:
|
||||
ok, message = await verify_source_credential(
|
||||
platform=rec.platform,
|
||||
url=rec.url,
|
||||
artist_slug=rec.artist_slug,
|
||||
config_overrides=rec.config_overrides or {},
|
||||
cookies_path=str(cookies_path) if cookies_path else None,
|
||||
auth_token=auth_token,
|
||||
images_root=Path("/images"),
|
||||
)
|
||||
if ok is False:
|
||||
return _bad("credential_rejected", detail=message, status=409)
|
||||
|
||||
async with get_session() as session:
|
||||
try:
|
||||
record = await SourceService(session).set_backfill_runs(
|
||||
source_id, runs,
|
||||
)
|
||||
svc = SourceService(session)
|
||||
if action == "start":
|
||||
record = await svc.start_backfill(source_id)
|
||||
elif action == "recover":
|
||||
record = await svc.start_recovery(source_id)
|
||||
else:
|
||||
record = await svc.stop_backfill(source_id)
|
||||
except LookupError:
|
||||
return _bad("not_found", status=404)
|
||||
except ValueError as exc:
|
||||
return _bad("invalid_runs", detail=str(exc))
|
||||
return jsonify(record.to_dict())
|
||||
|
||||
|
||||
@sources_bp.route("/<int:source_id>/preview", methods=["POST"])
|
||||
async def preview_source_endpoint(source_id: int):
|
||||
"""Plan #708 B4: dry-run — count what a backfill WOULD download for a native
|
||||
platform (Patreon today), without downloading. Walks the first few feed pages
|
||||
and counts media not already in the seen/dead ledgers. Returns
|
||||
{total_new, posts_scanned, pages_scanned, has_more, sample[]} or 409 + reason
|
||||
(unresolvable campaign id / auth / drift). 400 for gallery-dl platforms (no
|
||||
cheap dry-run — their verify is a slow --simulate)."""
|
||||
from pathlib import Path
|
||||
|
||||
from ..services.credential_service import CredentialService
|
||||
from ..services.download_backends import preview_source, uses_native_ingester
|
||||
from ..tasks._sync_engine import sync_session_factory
|
||||
from .credentials import _get_crypto
|
||||
|
||||
async with get_session() as session:
|
||||
rec = await SourceService(session).get(source_id)
|
||||
if rec is None:
|
||||
return _bad("not_found", status=404)
|
||||
if not uses_native_ingester(rec.platform):
|
||||
return _bad(
|
||||
"unsupported",
|
||||
detail="Preview is only available for native-ingester platforms.",
|
||||
status=400,
|
||||
)
|
||||
cred = CredentialService(session, _get_crypto())
|
||||
cookies_path = await cred.get_cookies_path(rec.platform)
|
||||
|
||||
# The walk + ledger reads are sync (run off the request loop); the process
|
||||
# sync engine is the same one the download task uses.
|
||||
result = await preview_source(
|
||||
platform=rec.platform,
|
||||
url=rec.url,
|
||||
source_id=source_id,
|
||||
config_overrides=rec.config_overrides or {},
|
||||
cookies_path=str(cookies_path) if cookies_path else None,
|
||||
images_root=Path("/images"),
|
||||
sync_session_factory=sync_session_factory(),
|
||||
)
|
||||
if "error" in result:
|
||||
return _bad("preview_failed", detail=result["error"], status=409)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
|
||||
async def check_source(source_id: int):
|
||||
"""FC-3c: enqueue a download for this source.
|
||||
|
||||
@@ -31,7 +31,7 @@ system_activity_bp = Blueprint(
|
||||
# absent.
|
||||
_QUEUE_NAMES = (
|
||||
"default", "import", "thumbnail", "ml",
|
||||
"download", "scan", "maintenance",
|
||||
"download", "scan", "maintenance", "maintenance_long",
|
||||
)
|
||||
|
||||
# Cache module-level so all requests share the cache between polls.
|
||||
|
||||
+267
-1
@@ -8,12 +8,14 @@ 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 (
|
||||
TagMergeConflict,
|
||||
TagService,
|
||||
TagValidationError,
|
||||
normalize_tag_name,
|
||||
)
|
||||
from ..utils.tag_prefix import parse_kind_prefix
|
||||
|
||||
@@ -141,6 +143,11 @@ async def create_tag():
|
||||
|
||||
fandom_id = body.get("fandom_id")
|
||||
|
||||
# #701: Title-Case operator-entered tags. Only here (the explicit create
|
||||
# endpoint), NOT in the shared find_or_create — the ML tagger uses that path
|
||||
# and must keep the booru vocabulary's casing for allowlist matching.
|
||||
name = normalize_tag_name(name)
|
||||
|
||||
async with get_session() as session:
|
||||
svc = TagService(session)
|
||||
try:
|
||||
@@ -362,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:
|
||||
@@ -378,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()
|
||||
@@ -433,3 +470,232 @@ 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_part" in body:
|
||||
part, perr = _opt_int(body, "stated_part")
|
||||
if perr:
|
||||
return perr
|
||||
kwargs.update(set_part=True, stated_part=part)
|
||||
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})
|
||||
|
||||
@@ -43,10 +43,16 @@ def make_celery() -> Celery:
|
||||
"backend.app.tasks.thumbnail.*": {"queue": "thumbnail"},
|
||||
"backend.app.tasks.download.*": {"queue": "download"},
|
||||
"backend.app.tasks.scan.*": {"queue": "scan"},
|
||||
# `maintenance` is the QUICK lane — recovery sweeps, vacuum, cleanup
|
||||
# (concurrency-1 on the scheduler). The long one-shots (DB backups,
|
||||
# library audits, admin maintenance: normalize/re-extract/cascade-
|
||||
# delete) run on a SEPARATE `maintenance_long` lane + worker so they
|
||||
# can never starve the quick self-healing sweeps (operator-flagged
|
||||
# 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours).
|
||||
"backend.app.tasks.maintenance.*": {"queue": "maintenance"},
|
||||
"backend.app.tasks.backup.*": {"queue": "maintenance"},
|
||||
"backend.app.tasks.admin.*": {"queue": "maintenance"},
|
||||
"backend.app.tasks.library_audit.*": {"queue": "maintenance"},
|
||||
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
|
||||
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
|
||||
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
|
||||
},
|
||||
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
||||
task_acks_late=True,
|
||||
|
||||
@@ -14,9 +14,13 @@ from .import_settings import ImportSettings
|
||||
from .import_task import ImportTask
|
||||
from .library_audit_run import LibraryAuditRun
|
||||
from .ml_settings import MLSettings
|
||||
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
|
||||
@@ -33,9 +37,13 @@ __all__ = [
|
||||
"BackupRun",
|
||||
"Source",
|
||||
"Credential",
|
||||
"PatreonFailedMedia",
|
||||
"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."""
|
||||
|
||||
@@ -35,3 +35,10 @@ class LibraryAuditRun(Base):
|
||||
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Chunked-scan state (alembic 0039): keyset cursor the next chunk resumes
|
||||
# from, and the last time a chunk made progress (so the recovery sweep can
|
||||
# tell a progressing multi-chunk audit from a stuck one).
|
||||
resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""PatreonFailedMedia — per-source dead-letter ledger of Patreon media that
|
||||
keeps failing to download/validate.
|
||||
|
||||
Plan #705 (#7). A media that fails every walk (404'd CDN URL, deleted post,
|
||||
geo-blocked Mux stream, persistently-corrupt bytes) would otherwise re-error
|
||||
forever and re-burn backfill chunks. After ``attempts`` reaches the dead-letter
|
||||
threshold the ingester skips it on routine tick/backfill walks (recovery still
|
||||
re-attempts it — the operator's "try everything again"). A later clean download
|
||||
clears the row (the media recovered).
|
||||
|
||||
`filehash` is the same per-media key the seen-ledger uses (32-hex CDN MD5, or a
|
||||
``video:`` / ``post:filename`` synthesized key) — hence String(128). UNIQUE
|
||||
(source_id, filehash) is the upsert key.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import DateTime
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PatreonFailedMedia(Base):
|
||||
__tablename__ = "patreon_failed_media"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"source_id", "filehash", name="uq_patreon_failed_media_source_id"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
first_failed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
last_failed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""PatreonSeenMedia — per-source ledger of Patreon media already
|
||||
downloaded+processed.
|
||||
|
||||
Replaces gallery-dl's archive.sqlite3 with our own queryable table so
|
||||
routine walks can skip media we've already ingested (and a future
|
||||
"recovery" mode can deliberately bypass the ledger to re-walk).
|
||||
|
||||
`filehash` is normally a Patreon CDN MD5 (32 hex chars), but videos —
|
||||
which have no stable content hash at discovery time — use a sentinel of
|
||||
the form ``video:<post_id>:<media_id>``, hence String(128) rather than 32.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import DateTime
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PatreonSeenMedia(Base):
|
||||
__tablename__ = "patreon_seen_media"
|
||||
__table_args__ = (
|
||||
# Dedup key the downloader upserts against: one ledger row per
|
||||
# (source, media). A second sighting of the same media is a no-op.
|
||||
UniqueConstraint("source_id", "filehash", name="uq_patreon_seen_media_source_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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.
|
||||
|
||||
stated_part is the operator-facing "Part N" label (FC-6.4), separate from the
|
||||
positional chapter_number: chapter_number is auto-managed ordering (rewritten
|
||||
1..N on reorder/delete), while stated_part is the real installment number the
|
||||
operator types — e.g. a series authored from a post that is Part 2 of a story.
|
||||
Nullable when unset (the UI then falls back to showing chapter_number).
|
||||
"""
|
||||
|
||||
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)
|
||||
stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
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(),
|
||||
)
|
||||
@@ -22,7 +22,11 @@ class TagAllowlist(Base):
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.95)
|
||||
# Default auto-apply threshold for a newly-accepted tag. 0.90 (lowered from
|
||||
# 0.95 on operator evidence 2026-06-07: 0.95 was too strict and skipped
|
||||
# confident-enough applications). Per-tag value is still tunable in the
|
||||
# allowlist table; existing rows keep whatever they were stored with.
|
||||
min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.90)
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -16,9 +16,45 @@ log = logging.getLogger(__name__)
|
||||
|
||||
ARCHIVE_EXTS = {".zip", ".cbz", ".rar", ".7z"}
|
||||
|
||||
# Magic-byte signatures, so an archive with a mangled / extension-less filename
|
||||
# is still recognised. Patreon attachment download URLs sanitize to names like
|
||||
# `01_https___www.patreon.com_media-u_v3_131083093`, whose `Path.suffix` is junk
|
||||
# (`.com_media-u_v3_131083093`), never `.zip` — an extension-only gate filed
|
||||
# those as opaque PostAttachments and NEVER extracted them (operator-flagged
|
||||
# 2026-06-06). Detection is by extension first (cheap), then header sniff.
|
||||
_RAR_MAGIC = b"Rar!\x1a\x07"
|
||||
_7Z_MAGIC = b"7z\xbc\xaf\x27\x1c"
|
||||
|
||||
|
||||
def detect_archive_format(path: Path) -> str | None:
|
||||
"""Return "zip" | "rar" | "7z" for an archive, else None.
|
||||
|
||||
Trusts a known extension first, then falls back to magic-byte sniffing so a
|
||||
mis-named or extension-less archive is still handled. (zip covers .cbz too.)
|
||||
"""
|
||||
ext = Path(path).suffix.lower()
|
||||
if ext in (".zip", ".cbz"):
|
||||
return "zip"
|
||||
if ext == ".rar":
|
||||
return "rar"
|
||||
if ext == ".7z":
|
||||
return "7z"
|
||||
try:
|
||||
if zipfile.is_zipfile(path):
|
||||
return "zip"
|
||||
with open(path, "rb") as fh:
|
||||
head = fh.read(8)
|
||||
except OSError:
|
||||
return None
|
||||
if head.startswith(_RAR_MAGIC):
|
||||
return "rar"
|
||||
if head.startswith(_7Z_MAGIC):
|
||||
return "7z"
|
||||
return None
|
||||
|
||||
|
||||
def is_archive(path: Path) -> bool:
|
||||
return Path(path).suffix.lower() in ARCHIVE_EXTS
|
||||
return detect_archive_format(path) is not None
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -32,16 +68,16 @@ def extract_archive(path: Path):
|
||||
members: list[tuple[str, Path]] = []
|
||||
try:
|
||||
try:
|
||||
ext = Path(path).suffix.lower()
|
||||
if ext in (".zip", ".cbz"):
|
||||
fmt = detect_archive_format(path)
|
||||
if fmt == "zip":
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
zf.extractall(base)
|
||||
elif ext == ".rar":
|
||||
elif fmt == "rar":
|
||||
import rarfile
|
||||
|
||||
with rarfile.RarFile(path) as rf:
|
||||
rf.extractall(base)
|
||||
elif ext == ".7z":
|
||||
elif fmt == "7z":
|
||||
import py7zr
|
||||
|
||||
with py7zr.SevenZipFile(path, "r") as zf:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,8 @@ the one-and-done GS/IR migration tooling.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -22,6 +24,8 @@ from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
|
||||
from ..models.series_page import SeriesPage
|
||||
from ..models.tag import image_tag
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def project_artist_cascade(session: Session, *, slug: str) -> dict:
|
||||
"""Read-only projection of what delete_artist_cascade would touch.
|
||||
@@ -665,3 +669,170 @@ def cancel_audit_run(session: Session, *, audit_id: int) -> None:
|
||||
.where(LibraryAuditRun.status == "running")
|
||||
.values(status="cancelled", finished_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
|
||||
# -- archive-attachment re-extraction (#713 part 2) ------------------------
|
||||
|
||||
_ARCHIVE_EXT_FOR_FORMAT = {"zip": ".zip", "rar": ".rar", "7z": ".7z"}
|
||||
|
||||
|
||||
def _reextract_archive_to_post(
|
||||
importer, archive_path: Path, post, source_row, artist, images_root: Path,
|
||||
) -> list[int]:
|
||||
"""Extract one stored archive and link its members to `post`.
|
||||
|
||||
The stored attachment has no adjacent sidecar (it lives in the sha-addressed
|
||||
attachment store). Stage a copy + a reconstructed sidecar UNDER the artist's
|
||||
library dir (`images_root/<slug>/<platform>/<post>/`) — the importer
|
||||
re-derives the artist from the path AND copies members relative to it, so the
|
||||
members land in the real library and resolve to the right artist — then re-run
|
||||
`attach_in_place`: the archive extracts and `find_or_create_post` re-attaches
|
||||
the members to the SAME Post (source_id + external_post_id). Removes only the
|
||||
staged archive + sidecar afterward; the imported member files stay. Returns
|
||||
the new member image ids.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
|
||||
from .archive_extractor import detect_archive_format
|
||||
|
||||
fmt = detect_archive_format(archive_path)
|
||||
ext = _ARCHIVE_EXT_FOR_FORMAT.get(fmt or "", ".zip")
|
||||
platform = source_row.platform if source_row is not None else "imported"
|
||||
sidecar = {
|
||||
"category": source_row.platform if source_row is not None
|
||||
else (post.raw_metadata or {}).get("category"),
|
||||
"id": post.external_post_id,
|
||||
"title": post.post_title or "",
|
||||
"content": post.description or "",
|
||||
"published_at": post.post_date.isoformat() if post.post_date else None,
|
||||
"url": post.post_url,
|
||||
}
|
||||
work = images_root / artist.slug / platform / str(post.external_post_id)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
staged = work / f"archive{ext}" # clean ext → is_archive + find_sidecar
|
||||
sidecar_path = staged.with_suffix(".json")
|
||||
try:
|
||||
shutil.copy2(archive_path, staged)
|
||||
sidecar_path.write_text(json.dumps(sidecar))
|
||||
res = importer.attach_in_place(staged, artist=artist, source=source_row)
|
||||
return list(res.member_image_ids or [])
|
||||
finally:
|
||||
# Drop only the staged archive + sidecar; the extracted member files
|
||||
# were copied into the library alongside them and must stay.
|
||||
staged.unlink(missing_ok=True)
|
||||
sidecar_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def reextract_archive_attachments(
|
||||
session: Session,
|
||||
*,
|
||||
images_root: Path,
|
||||
time_budget_seconds: float | None = None,
|
||||
after_id: int = 0,
|
||||
) -> dict:
|
||||
"""Re-process existing PostAttachments that are ACTUALLY archives but were
|
||||
filed opaquely before #713 part 1 (extension-only is_archive missed mangled /
|
||||
extension-less Patreon attachment names). For each: extract the members,
|
||||
import them, and link them to the attachment's post.
|
||||
|
||||
Idempotent — members dedupe by sha256, the archive dedupes by sha — so it's
|
||||
safe to run repeatedly. Returns a summary dict for task_run.metadata.
|
||||
|
||||
Time-boxed + resumable: scans PostAttachments in ascending id order starting
|
||||
after ``after_id``. When ``time_budget_seconds`` elapses, stops and reports
|
||||
``partial=True`` + ``resume_after_id`` (the last scanned id) so the task can
|
||||
re-enqueue itself and continue — a large archive back-catalog can't run the
|
||||
task into the Celery time limit or hog the maintenance lane. A bare re-run
|
||||
(after_id=0) would never advance because an already-extracted archive is
|
||||
still an archive on disk, so the cursor is what guarantees forward progress.
|
||||
"""
|
||||
from ..models import ImportSettings, Post, PostAttachment, Source
|
||||
from ..tasks.ml import tag_and_embed
|
||||
from ..tasks.thumbnail import generate_thumbnail
|
||||
from .archive_extractor import is_archive
|
||||
from .importer import Importer
|
||||
from .thumbnailer import Thumbnailer
|
||||
|
||||
summary = {
|
||||
"scanned": 0, "archives": 0, "members_imported": 0,
|
||||
"posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0,
|
||||
"errors": 0, "partial": False, "resume_after_id": after_id,
|
||||
}
|
||||
settings = ImportSettings.load_sync(session)
|
||||
importer = Importer(
|
||||
session=session, images_root=images_root, import_root=images_root,
|
||||
thumbnailer=Thumbnailer(images_root=images_root), settings=settings,
|
||||
)
|
||||
|
||||
attachments = session.execute(
|
||||
select(PostAttachment)
|
||||
.where(PostAttachment.id > after_id)
|
||||
.order_by(PostAttachment.id)
|
||||
).scalars().all()
|
||||
enqueue_ids: list[int] = []
|
||||
start = time.monotonic()
|
||||
for att in attachments:
|
||||
summary["scanned"] += 1
|
||||
summary["resume_after_id"] = att.id
|
||||
stored = Path(att.path)
|
||||
try:
|
||||
if not stored.is_file() or not is_archive(stored):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
summary["archives"] += 1
|
||||
if att.post_id is None:
|
||||
summary["skipped_no_post"] += 1
|
||||
continue
|
||||
post = session.get(Post, att.post_id)
|
||||
if post is None:
|
||||
summary["skipped_no_post"] += 1
|
||||
continue
|
||||
artist = session.get(Artist, att.artist_id) if att.artist_id else None
|
||||
if artist is None and post.artist_id:
|
||||
artist = session.get(Artist, post.artist_id)
|
||||
if artist is None or not artist.slug:
|
||||
# The importer re-derives the artist from the staged path, so we need
|
||||
# a real artist+slug to anchor under. (Shouldn't happen for
|
||||
# subscription posts; skip rather than orphan the members.)
|
||||
summary["skipped_no_artist"] += 1
|
||||
continue
|
||||
source_row = session.get(Source, post.source_id) if post.source_id else None
|
||||
try:
|
||||
ids = _reextract_archive_to_post(
|
||||
importer, stored, post, source_row, artist, images_root,
|
||||
)
|
||||
session.commit()
|
||||
except Exception as exc: # one bad archive must not strand the rest
|
||||
session.rollback()
|
||||
summary["errors"] += 1
|
||||
log.warning("re-extract failed for attachment %s: %s", att.id, exc)
|
||||
continue
|
||||
if ids:
|
||||
summary["members_imported"] += len(ids)
|
||||
summary["posts_touched"] += 1
|
||||
enqueue_ids.extend(ids)
|
||||
|
||||
# Time-box the chunk. resume_after_id already points at this attachment,
|
||||
# so the next run starts strictly after it. Checked after the commit so a
|
||||
# half-extracted archive never straddles the boundary.
|
||||
if (
|
||||
time_budget_seconds is not None
|
||||
and time.monotonic() - start >= time_budget_seconds
|
||||
):
|
||||
summary["partial"] = True
|
||||
break
|
||||
else:
|
||||
# Loop ran to exhaustion — nothing left to resume.
|
||||
summary["partial"] = False
|
||||
|
||||
# Thumbnails + ML for the newly-imported members (best-effort; off the
|
||||
# critical path — a Redis hiccup must not fail the whole re-extract).
|
||||
for img_id in enqueue_ids:
|
||||
try:
|
||||
generate_thumbnail.delay(img_id)
|
||||
tag_and_embed.delay(img_id)
|
||||
except Exception as exc:
|
||||
log.warning("re-extract enqueue failed for image %s: %s", img_id, exc)
|
||||
return summary
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Platform → download-backend dispatch (one place that knows which platforms
|
||||
are served by the native FC ingester vs. the gallery-dl subprocess).
|
||||
|
||||
gallery-dl wasn't built to be driven by an automated scheduler — no native
|
||||
checkpoint/resume, no structured logs, per-file HEADs that dominate wall-clock.
|
||||
The native ingester (services/patreon_ingester.py, plan #697) replaces it for
|
||||
Patreon and is the path we grow as more platforms migrate. To keep that
|
||||
migration DRY, every caller that has to behave differently per backend —
|
||||
download routing, the credential-verify probe, cursor handling — asks THIS
|
||||
module instead of testing ``platform == "patreon"`` inline. When a platform gets
|
||||
a native ingester, it moves into ``NATIVE_INGESTER_PLATFORMS`` here and both the
|
||||
download path and verify switch over together.
|
||||
|
||||
The backend surfaces share a UNIFORM signature so a caller invokes the same
|
||||
function regardless of platform:
|
||||
- verify_credential(...) → (ok: bool|None, message: str)
|
||||
- (download stays in download_service for now; uses_native_ingester() is the
|
||||
shared predicate it routes on, so the decision lives here too.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from .gallery_dl import DownloadResult, ErrorType
|
||||
from .patreon_ingester import PatreonIngester
|
||||
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
|
||||
|
||||
# Platforms whose download + verify go through the native ingester rather than
|
||||
# gallery-dl. gallery-dl still serves every other platform (subscribestar,
|
||||
# hentaifoundry, discord, pixiv, deviantart) unchanged.
|
||||
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon"})
|
||||
|
||||
# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
|
||||
# messages so the operator sees the exact lookup endpoint that was hit.
|
||||
_CAMPAIGNS_API = "https://www.patreon.com/api/campaigns"
|
||||
|
||||
|
||||
def uses_native_ingester(platform: str) -> bool:
|
||||
"""True when `platform` is served by the native ingester (not gallery-dl).
|
||||
The single predicate the download path and verify both route on."""
|
||||
return platform in NATIVE_INGESTER_PLATFORMS
|
||||
|
||||
|
||||
async def run_download(
|
||||
*,
|
||||
ctx: dict,
|
||||
source_config,
|
||||
skip_value: bool | str,
|
||||
mode: str | None,
|
||||
gdl,
|
||||
sync_session_factory,
|
||||
) -> tuple[DownloadResult, str | None]:
|
||||
"""Uniform download across backends — the download counterpart to
|
||||
`verify_source_credential`, so this module is the ONE place that knows how
|
||||
each platform both downloads AND verifies (the seam that makes adding a
|
||||
platform a bounded job).
|
||||
|
||||
Returns `(DownloadResult, resolved_campaign_id)`; `resolved_campaign_id` is
|
||||
non-None only when a native vanity lookup ran this call (so phase 3 caches
|
||||
it). Native platforms route through their ingester in `mode`
|
||||
(tick/backfill/recovery); gallery-dl platforms run the subprocess. The caller
|
||||
(download_service) prepares `source_config`/`skip_value`/`mode` from the
|
||||
backfill state machine and owns phase 3.
|
||||
"""
|
||||
platform = ctx["platform"]
|
||||
if uses_native_ingester(platform):
|
||||
return await _run_native_ingester(
|
||||
ctx, source_config, mode, gdl, sync_session_factory
|
||||
)
|
||||
result = await gdl.download(
|
||||
url=ctx["url"],
|
||||
artist_slug=ctx["artist_slug"],
|
||||
platform=platform,
|
||||
source_config=source_config,
|
||||
cookies_path=ctx["cookies_path"],
|
||||
auth_token=ctx["auth_token"],
|
||||
skip_value=skip_value,
|
||||
)
|
||||
return result, None
|
||||
|
||||
|
||||
async def _run_native_ingester(
|
||||
ctx: dict, source_config, mode: str | None, gdl, sync_session_factory,
|
||||
) -> tuple[DownloadResult, str | None]:
|
||||
"""Patreon (today the only native platform): resolve the campaign id, then run
|
||||
the native ingester in a worker thread (it is sync requests/subprocess).
|
||||
|
||||
`resolved_campaign_id` is non-None only when we had to look it up from the
|
||||
vanity URL this run, so phase 3 caches it the way the old gallery-dl retry
|
||||
did. A campaign id we cannot resolve is a loud NOT_FOUND — never a silent
|
||||
empty success.
|
||||
"""
|
||||
overrides = ctx["config_overrides"] or {}
|
||||
campaign_id, resolved_campaign_id = await resolve_campaign_id_for_source(
|
||||
ctx["url"], ctx["cookies_path"], overrides
|
||||
)
|
||||
if not campaign_id:
|
||||
url = ctx["url"]
|
||||
vanity = extract_vanity(url)
|
||||
return (
|
||||
DownloadResult(
|
||||
success=False,
|
||||
url=url,
|
||||
artist_slug=ctx["artist_slug"],
|
||||
platform="patreon",
|
||||
error_type=ErrorType.NOT_FOUND,
|
||||
error_message=(
|
||||
f"Could not resolve Patreon campaign id. source_url={url!r}; "
|
||||
f"vanity={vanity!r}; "
|
||||
f"lookup=GET {_CAMPAIGNS_API}?filter[vanity]={vanity or ''} "
|
||||
"(vanity lookup failed — cookies expired or creator moved?)"
|
||||
),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Honor the operator's existing rate-limit knobs on the native path (plan
|
||||
# #703): the global download_rate_limit_seconds (gallery-dl's `rate_limit`,
|
||||
# here on the gdl service) paces media downloads; page fetches use the
|
||||
# per-source sleep_request override, else `max(0.5, rate_limit/4)` — the same
|
||||
# API-pacing default gallery-dl applied as its `sleep-request`.
|
||||
rate_limit = gdl._rate_limit
|
||||
request_sleep = (
|
||||
source_config.sleep_request
|
||||
if source_config.sleep_request is not None
|
||||
else max(0.5, rate_limit / 4)
|
||||
)
|
||||
ingester = PatreonIngester(
|
||||
images_root=gdl.images_root,
|
||||
cookies_path=ctx["cookies_path"],
|
||||
session_factory=sync_session_factory,
|
||||
validate=gdl._validate_files,
|
||||
rate_limit=rate_limit,
|
||||
request_sleep=request_sleep,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
dl_result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: ingester.run(
|
||||
source_id=ctx["source_id"],
|
||||
campaign_id=campaign_id,
|
||||
artist_slug=ctx["artist_slug"],
|
||||
url=ctx["url"],
|
||||
mode=mode,
|
||||
resume_cursor=source_config.resume_cursor,
|
||||
time_budget_seconds=source_config.timeout,
|
||||
posts_base=int(overrides.get("_backfill_posts", 0)),
|
||||
# plan #709: live progress writes to this running event mid-walk.
|
||||
event_id=ctx.get("event_id"),
|
||||
),
|
||||
)
|
||||
return dl_result, resolved_campaign_id
|
||||
|
||||
|
||||
async def preview_source(
|
||||
*,
|
||||
platform: str,
|
||||
url: str,
|
||||
source_id: int,
|
||||
config_overrides: dict | None,
|
||||
cookies_path: str | None,
|
||||
images_root: Path,
|
||||
sync_session_factory,
|
||||
page_limit: int = 3,
|
||||
) -> dict:
|
||||
"""Dry-run preview for a native platform (plan #708 B4): resolve the campaign
|
||||
id, then walk a few pages counting media not already seen/dead — no download.
|
||||
|
||||
Returns the preview dict (total_new / posts_scanned / pages_scanned /
|
||||
has_more / sample), or `{"error": msg}` on a resolve / auth / drift failure.
|
||||
Native-only — the caller gates on `uses_native_ingester`.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from .patreon_client import PatreonAPIError
|
||||
|
||||
campaign_id, _ = await resolve_campaign_id_for_source(
|
||||
url, cookies_path, config_overrides or {}
|
||||
)
|
||||
if not campaign_id:
|
||||
vanity = extract_vanity(url)
|
||||
return {
|
||||
"error": (
|
||||
f"Couldn't resolve the campaign id. source_url={url!r}; "
|
||||
f"vanity={vanity!r}; lookup=GET {_CAMPAIGNS_API}?filter[vanity]={vanity or ''} "
|
||||
"(cookies expired, or the creator moved/renamed?)."
|
||||
)
|
||||
}
|
||||
ingester = PatreonIngester(
|
||||
images_root=images_root,
|
||||
cookies_path=cookies_path,
|
||||
session_factory=sync_session_factory,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: ingester.preview(source_id, campaign_id, page_limit=page_limit),
|
||||
)
|
||||
except PatreonAPIError as exc:
|
||||
return {"error": f"Couldn't preview: {exc}"}
|
||||
return result
|
||||
|
||||
|
||||
async def verify_source_credential(
|
||||
*,
|
||||
platform: str,
|
||||
url: str,
|
||||
artist_slug: str,
|
||||
config_overrides: dict | None,
|
||||
cookies_path: str | None,
|
||||
auth_token: str | None,
|
||||
images_root: Path,
|
||||
) -> tuple[bool | None, str]:
|
||||
"""Uniform credential probe across backends. Returns `(ok, message)`:
|
||||
True = authenticated, False = rejected, None = inconclusive (drift /
|
||||
network / nothing to test). Callers don't branch on platform — they call
|
||||
this and render the result.
|
||||
"""
|
||||
if uses_native_ingester(platform):
|
||||
# Native ingester platforms verify via their own lightweight auth probe
|
||||
# (resolve campaign id + one authenticated API page). Patreon today.
|
||||
from .patreon_ingester import verify_patreon_credential
|
||||
|
||||
return await verify_patreon_credential(url, cookies_path, config_overrides)
|
||||
|
||||
# gallery-dl platforms: --simulate one item; the extractor errors before it
|
||||
# can list if auth is bad.
|
||||
from .gallery_dl import GalleryDLService, SourceConfig
|
||||
|
||||
gdl = GalleryDLService(images_root=images_root)
|
||||
return await gdl.verify(
|
||||
url=url,
|
||||
artist_slug=artist_slug,
|
||||
platform=platform,
|
||||
source_config=SourceConfig.from_dict(config_overrides or {}),
|
||||
cookies_path=cookies_path,
|
||||
auth_token=auth_token,
|
||||
)
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -25,44 +24,25 @@ from sqlalchemy.orm import joinedload
|
||||
|
||||
from ..models import Artist, DownloadEvent, Source
|
||||
from .credential_service import CredentialService
|
||||
from .download_backends import run_download, uses_native_ingester
|
||||
from .gallery_dl import (
|
||||
BACKFILL_CHUNK_SECONDS,
|
||||
BACKFILL_SKIP_VALUE,
|
||||
BACKFILL_TIMEOUT_SECONDS,
|
||||
TICK_SKIP_VALUE,
|
||||
DownloadResult,
|
||||
ErrorType,
|
||||
GalleryDLService,
|
||||
SourceConfig,
|
||||
extract_errors_warnings,
|
||||
truncate_log,
|
||||
)
|
||||
from .importer import Importer
|
||||
from .patreon_resolver import resolve_campaign_id
|
||||
from .platforms import auth_type_for
|
||||
from .scheduler_service import set_platform_cooldown
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_PATREON_VANITY_RE = re.compile(
|
||||
r"^https?://(?:www\.)?patreon\.com/(?:c/)?(?!id:)([^/?#]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CAMPAIGN_ID_FAILURE_PATTERN = "failed to extract campaign id"
|
||||
|
||||
|
||||
def _extract_patreon_vanity(url: str) -> str | None:
|
||||
m = _PATREON_VANITY_RE.match(url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _looks_like_campaign_id_failure(stdout: str, stderr: str) -> bool:
|
||||
return _CAMPAIGN_ID_FAILURE_PATTERN in f"{stdout}\n{stderr}".lower()
|
||||
|
||||
|
||||
def _effective_url(platform: str, source_url: str, overrides: dict) -> str:
|
||||
if platform == "patreon" and overrides.get("patreon_campaign_id"):
|
||||
return f"https://www.patreon.com/id:{overrides['patreon_campaign_id']}"
|
||||
return source_url
|
||||
|
||||
|
||||
class DownloadService:
|
||||
"""Async orchestrator. The Celery task runs `asyncio.run(svc.download_source(N))`.
|
||||
|
||||
@@ -77,12 +57,18 @@ class DownloadService:
|
||||
gdl: GalleryDLService,
|
||||
importer: Importer,
|
||||
cred_service: CredentialService,
|
||||
sync_session_factory=None,
|
||||
):
|
||||
self.async_session = async_session
|
||||
self.sync_session = sync_session
|
||||
self.gdl = gdl
|
||||
self.importer = importer
|
||||
self.cred_service = cred_service
|
||||
# Sync sessionmaker the native Patreon ingester opens SHORT-LIVED
|
||||
# sessions from for its seen-ledger reads/writes (never one held across
|
||||
# the multi-minute walk — see PatreonIngester). Only the patreon branch
|
||||
# of phase 2 uses it; gallery-dl sources leave it None.
|
||||
self.sync_session_factory = sync_session_factory
|
||||
|
||||
async def download_source(self, source_id: int) -> int:
|
||||
"""Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is."""
|
||||
@@ -107,63 +93,85 @@ class DownloadService:
|
||||
self.sync_session.close()
|
||||
|
||||
source_config = SourceConfig.from_dict(ctx["config_overrides"] or {})
|
||||
# alembic 0031 / plan #544: derive skip_value + timeout from the
|
||||
# source's backfill_runs_remaining counter. When > 0, walk the full
|
||||
# post history (skip: True + 1170s); when 0, exit gallery-dl after
|
||||
# 20 contiguous archived items (skip: "exit:20" + the default
|
||||
# 870s). Operator sets backfill via POST /api/sources/{id}/backfill.
|
||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||
if backfill_remaining > 0:
|
||||
# Backfill mode (plan #693): the source's `_backfill_state == "running"`
|
||||
# selects a time-boxed deep-walk chunk — skip: True (walk full history)
|
||||
# + the BACKFILL_CHUNK_SECONDS budget, resuming from the cursor
|
||||
# checkpoint (plan #689). State is the single source of truth; it stays
|
||||
# "running" across ticks until the walk reaches the bottom (phase 3
|
||||
# flips it to "complete"). Otherwise tick mode: exit gallery-dl after
|
||||
# 20 contiguous archived items (skip: "exit:20" + the default 870s).
|
||||
# Operator drives this via POST /api/sources/{id}/backfill {action}.
|
||||
overrides = ctx["config_overrides"] or {}
|
||||
in_backfill = overrides.get("_backfill_state") == "running"
|
||||
# Recovery (plan #697) reuses the entire #693 backfill state machine —
|
||||
# cursor checkpoint, time-boxed chunks, complete/stall lifecycle — and
|
||||
# differs only in bypassing the tier-1 seen-ledger so dropped-and-deleted
|
||||
# near-dups get re-fetched and re-evaluated under the CURRENT pHash
|
||||
# threshold (tier-2 disk still spares files we kept). The
|
||||
# `_backfill_bypass_seen` flag rides alongside the running backfill state;
|
||||
# download mode is "recovery" when both are set.
|
||||
bypass_seen = bool(overrides.get("_backfill_bypass_seen"))
|
||||
if in_backfill:
|
||||
skip_value: bool | str = BACKFILL_SKIP_VALUE
|
||||
source_config.timeout = BACKFILL_TIMEOUT_SECONDS
|
||||
source_config.timeout = BACKFILL_CHUNK_SECONDS
|
||||
pending_cursor = overrides.get("_backfill_cursor")
|
||||
if uses_native_ingester(ctx["platform"]) and pending_cursor:
|
||||
source_config.resume_cursor = pending_cursor
|
||||
else:
|
||||
skip_value = TICK_SKIP_VALUE
|
||||
|
||||
effective_url = _effective_url(
|
||||
ctx["platform"], ctx["url"], ctx["config_overrides"] or {}
|
||||
# Phase 2 dispatch is uniform across backends (download_backends.
|
||||
# run_download — the download counterpart to verify_source_credential):
|
||||
# native platforms run their ingester in `mode` (zero per-file HEADs,
|
||||
# native cursor/resume, loud drift detection); gallery-dl platforms run
|
||||
# the subprocess. Either returns a DownloadResult-shaped object so phase 3
|
||||
# is untouched. `mode` is None for gallery-dl (run_download ignores it).
|
||||
mode: str | None = None
|
||||
if uses_native_ingester(ctx["platform"]):
|
||||
if in_backfill and bypass_seen:
|
||||
mode = "recovery"
|
||||
elif in_backfill:
|
||||
mode = "backfill"
|
||||
else:
|
||||
mode = "tick"
|
||||
dl_result, resolved_campaign_id = await self._run_download(
|
||||
ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode,
|
||||
)
|
||||
|
||||
dl_result = await self.gdl.download(
|
||||
url=effective_url,
|
||||
artist_slug=ctx["artist_slug"],
|
||||
platform=ctx["platform"],
|
||||
source_config=source_config,
|
||||
cookies_path=ctx["cookies_path"],
|
||||
auth_token=ctx["auth_token"],
|
||||
skip_value=skip_value,
|
||||
)
|
||||
|
||||
resolved_campaign_id: str | None = None
|
||||
if (
|
||||
ctx["platform"] == "patreon"
|
||||
and not dl_result.success
|
||||
and "patreon_campaign_id" not in (ctx["config_overrides"] or {})
|
||||
and _looks_like_campaign_id_failure(dl_result.stdout, dl_result.stderr)
|
||||
):
|
||||
vanity = _extract_patreon_vanity(ctx["url"])
|
||||
if vanity:
|
||||
log.info(
|
||||
"Attempting campaign-ID resolution for %s (%s)",
|
||||
ctx["artist_slug"], vanity,
|
||||
# A backfill chunk that hit its time-box but made forward progress is
|
||||
# NORMAL, not a failure — reclassify TIMEOUT → PARTIAL so it reads as
|
||||
# "ok/progress" (PARTIAL maps to status "ok"), not a red error, and the
|
||||
# next chunk just resumes from the new cursor (plan #693). A chunk that
|
||||
# timed out with NO progress stays TIMEOUT and feeds phase 3's
|
||||
# stall-guard. Leave RATE_LIMITED alone so the platform-cooldown fires.
|
||||
if in_backfill and dl_result.error_type == ErrorType.TIMEOUT:
|
||||
new_cursor = dl_result.cursor # plan #704: structured, not scraped
|
||||
advanced = bool(
|
||||
(new_cursor and new_cursor != overrides.get("_backfill_cursor"))
|
||||
or dl_result.files_downloaded > 0
|
||||
)
|
||||
if advanced:
|
||||
dl_result.error_type = ErrorType.PARTIAL
|
||||
dl_result.error_message = (
|
||||
f"Backfill chunk: {dl_result.files_downloaded} file(s) — continuing"
|
||||
)
|
||||
resolved_campaign_id = await resolve_campaign_id(
|
||||
vanity, ctx["cookies_path"]
|
||||
)
|
||||
if resolved_campaign_id:
|
||||
dl_result = await self.gdl.download(
|
||||
url=f"https://www.patreon.com/id:{resolved_campaign_id}",
|
||||
artist_slug=ctx["artist_slug"],
|
||||
platform=ctx["platform"],
|
||||
source_config=source_config,
|
||||
cookies_path=ctx["cookies_path"],
|
||||
auth_token=ctx["auth_token"],
|
||||
skip_value=skip_value,
|
||||
)
|
||||
|
||||
return await self._phase3_persist(
|
||||
ctx["event_id"], ctx, dl_result, resolved_campaign_id,
|
||||
)
|
||||
|
||||
async def _run_download(
|
||||
self, *, ctx: dict, source_config, skip_value, mode: str | None,
|
||||
) -> tuple[DownloadResult, str | None]:
|
||||
"""Phase-2 dispatch → `download_backends.run_download`, passing this
|
||||
service's gdl + sync sessionmaker. Kept as a thin instance method so tests
|
||||
can stub the whole phase-2 dispatch on the service, and so the per-backend
|
||||
construction lives in download_backends (the backend registry)."""
|
||||
return await run_download(
|
||||
ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode,
|
||||
gdl=self.gdl, sync_session_factory=self.sync_session_factory,
|
||||
)
|
||||
|
||||
async def _phase1_setup(self, source_id: int) -> dict[str, Any]:
|
||||
source = (await self.async_session.execute(
|
||||
select(Source).options(joinedload(Source.artist)).where(Source.id == source_id)
|
||||
@@ -261,6 +269,11 @@ class DownloadService:
|
||||
source_row = self.sync_session.get(Source, ctx["source_id"])
|
||||
|
||||
import_summary = {"attached": 0, "skipped": 0, "errors": 0}
|
||||
# Archives detected but captured WITHOUT extracting any image (probe
|
||||
# rejected / corrupt / missing extractor backend). Surfaced on the event
|
||||
# so a post showing "no images" beside a zip is diagnosable (plan
|
||||
# follow-up 2026-06-06 — the recurring archive-association report).
|
||||
unextracted_archives: list[dict] = []
|
||||
bytes_downloaded = 0
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -314,6 +327,17 @@ class DownloadService:
|
||||
# mirror duplicate_hash cleanup so we don't keep two copies.
|
||||
# Operator-flagged 2026-06-02 (Lustria OST zip).
|
||||
import_summary["attached"] += 1
|
||||
# An archive captured WITHOUT extracting any image carries a
|
||||
# reason on result.error — record it so the event explains the
|
||||
# "no images beside a zip" symptom instead of staying silent.
|
||||
if result.error:
|
||||
unextracted_archives.append(
|
||||
{"file": path.name, "reason": result.error}
|
||||
)
|
||||
log.warning(
|
||||
"archive captured unextracted (%s): %s",
|
||||
path.name, result.error,
|
||||
)
|
||||
try:
|
||||
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
|
||||
except OSError:
|
||||
@@ -360,11 +384,16 @@ class DownloadService:
|
||||
select(DownloadEvent).where(DownloadEvent.id == event_id)
|
||||
)).scalar_one()
|
||||
|
||||
run_stats = self.gdl._compute_run_stats(
|
||||
dl_result.return_code, dl_result.stdout, dl_result.stderr
|
||||
)
|
||||
# plan #704: the native ingester returns structured run_stats; only the
|
||||
# gallery-dl path needs the regex-over-stdout reconstruction.
|
||||
if dl_result.run_stats is not None:
|
||||
run_stats = dict(dl_result.run_stats)
|
||||
else:
|
||||
run_stats = self.gdl._compute_run_stats(
|
||||
dl_result.return_code, dl_result.stdout, dl_result.stderr
|
||||
)
|
||||
run_stats["quarantined_count"] = dl_result.files_quarantined
|
||||
stderr_summary = self.gdl._extract_errors_warnings(dl_result.stderr)
|
||||
stderr_summary = extract_errors_warnings(dl_result.stderr)
|
||||
|
||||
# Plan #544: PARTIAL means the run downloaded ≥1 file but the
|
||||
# subprocess didn't finish in budget (typically wall-clock timeout
|
||||
@@ -384,50 +413,116 @@ class DownloadService:
|
||||
ev.metadata_ = {
|
||||
"run_stats": run_stats,
|
||||
"error_type": dl_result.error_type.value if dl_result.error_type else None,
|
||||
"stdout": self.gdl._truncate_log(dl_result.stdout) or None,
|
||||
"stderr": self.gdl._truncate_log(dl_result.stderr) or None,
|
||||
"stdout": truncate_log(dl_result.stdout) or None,
|
||||
"stderr": truncate_log(dl_result.stderr) or None,
|
||||
"stderr_errors_warnings": stderr_summary or None,
|
||||
"duration_seconds": dl_result.duration_seconds,
|
||||
"quarantined_paths": dl_result.quarantined_paths or None,
|
||||
"import_summary": import_summary,
|
||||
# Archives detected but captured without extracting an image — the
|
||||
# recurring "post shows a zip but no images" report. Each entry is
|
||||
# {file, reason}; None when every archive extracted cleanly.
|
||||
"unextracted_archives": unextracted_archives or None,
|
||||
}
|
||||
await self._update_source_health(
|
||||
source_id=ctx["source_id"], status=status, error_message=ev.error,
|
||||
error_type=dl_result.error_type.value if dl_result.error_type else None,
|
||||
retry_after_seconds=getattr(dl_result, "retry_after_seconds", None),
|
||||
)
|
||||
# Plan #544: backfill lifecycle — auto-complete when a clean
|
||||
# backfill run drained the queue (gallery-dl exited 0 + zero files
|
||||
# downloaded means there was nothing to fetch); otherwise decrement
|
||||
# the counter. Next tick falls back to tick mode once it hits 0.
|
||||
#
|
||||
# Audit 2026-06-02 gating: VALIDATION_FAILED also exits the
|
||||
# subprocess with return_code=0 and files_downloaded=0 (every
|
||||
# file was quarantined), which used to match the auto-complete
|
||||
# predicate exactly — zeroing the operator's armed budget on
|
||||
# the FIRST quarantine run instead of decrementing. Require
|
||||
# dl_result.success + no error_type so only genuinely-empty
|
||||
# successful runs drain the counter.
|
||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||
if backfill_remaining > 0:
|
||||
src = (await self.async_session.execute(
|
||||
select(Source).where(Source.id == ctx["source_id"])
|
||||
)).scalar_one()
|
||||
queue_drained = (
|
||||
dl_result.success
|
||||
and dl_result.error_type is None
|
||||
and dl_result.return_code == 0
|
||||
and dl_result.files_downloaded == 0
|
||||
)
|
||||
if queue_drained:
|
||||
src.backfill_runs_remaining = 0
|
||||
else:
|
||||
src.backfill_runs_remaining = max(0, backfill_remaining - 1)
|
||||
await self._apply_backfill_lifecycle(ctx, dl_result)
|
||||
await self.async_session.commit()
|
||||
return event_id
|
||||
|
||||
async def _apply_backfill_lifecycle(self, ctx: dict, dl_result) -> None:
|
||||
"""Backfill state machine (plan #693, building on the cursor of #689).
|
||||
|
||||
A backfill runs in time-boxed chunks while
|
||||
`config_overrides["_backfill_state"] == "running"`. Each chunk:
|
||||
- COMPLETES the walk → clean rc=0 (gallery-dl with skip:True exits 0
|
||||
only after exhausting the newest→oldest walk; a chunk cut short by
|
||||
its time-box returns success=False / rc<0 via TimeoutExpired). On
|
||||
completion: state="complete", clear the cursor, return to tick mode.
|
||||
- made PROGRESS (cursor advanced and/or files written) → stay
|
||||
"running", checkpoint the new cursor, bump the chunk counter, and
|
||||
spend one of the safety-cap chunks (backfill_runs_remaining). If the
|
||||
cap is exhausted without finishing → state="stalled".
|
||||
- made NO progress → increment the stall counter; two strikes →
|
||||
state="stalled", clear the cursor (a wedged walk can't loop).
|
||||
|
||||
State is the single source of truth; the cursor lives only inside a
|
||||
running backfill. config_overrides is reassigned (not mutated in place)
|
||||
for SQLAlchemy JSON change detection.
|
||||
"""
|
||||
overrides = ctx["config_overrides"] or {}
|
||||
if overrides.get("_backfill_state") != "running":
|
||||
return # not backfilling — tick mode, nothing to do
|
||||
|
||||
old_cursor = overrides.get("_backfill_cursor")
|
||||
cap_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||
|
||||
src = (await self.async_session.execute(
|
||||
select(Source).where(Source.id == ctx["source_id"])
|
||||
)).scalar_one()
|
||||
new_overrides = dict(src.config_overrides or {})
|
||||
chunks = int(new_overrides.get("_backfill_chunks", 0)) + 1
|
||||
new_overrides["_backfill_chunks"] = chunks
|
||||
# plan #704 (#5): _backfill_posts (the live progress badge) is OWNED by the
|
||||
# ingester now — it writes a monotonic absolute mid-walk at each page
|
||||
# boundary (ingest_core._checkpoint_posts), so the badge climbs DURING a
|
||||
# chunk instead of jumping once per chunk here, and the re-walked resume
|
||||
# page is no longer double-counted. new_overrides (read fresh above)
|
||||
# carries the ingester's committed value forward untouched.
|
||||
|
||||
completed = (
|
||||
dl_result.success
|
||||
and dl_result.error_type is None
|
||||
and dl_result.return_code == 0
|
||||
)
|
||||
if completed:
|
||||
new_overrides["_backfill_state"] = "complete"
|
||||
new_overrides.pop("_backfill_cursor", None)
|
||||
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||
# plan #697: a recovery walk shares this lifecycle; clear its bypass
|
||||
# flag on completion so the next routine tick honors the seen-ledger.
|
||||
new_overrides.pop("_backfill_bypass_seen", None)
|
||||
src.config_overrides = new_overrides
|
||||
src.backfill_runs_remaining = 0
|
||||
return
|
||||
|
||||
# Did not finish. The native ingester checkpoints + resumes via cursor
|
||||
# (carried structurally on the result, plan #704); gallery-dl platforms
|
||||
# have no resumable cursor (every chunk re-walks from the top), so they
|
||||
# advance only by the download archive growing.
|
||||
new_cursor = (
|
||||
dl_result.cursor if uses_native_ingester(ctx["platform"]) else None
|
||||
)
|
||||
advanced = bool(
|
||||
(new_cursor and new_cursor != old_cursor)
|
||||
or dl_result.files_downloaded > 0
|
||||
)
|
||||
if advanced:
|
||||
if new_cursor:
|
||||
new_overrides["_backfill_cursor"] = new_cursor
|
||||
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||
cap_remaining = max(0, cap_remaining - 1)
|
||||
src.backfill_runs_remaining = cap_remaining
|
||||
if cap_remaining == 0:
|
||||
# Safety cap hit before reaching the bottom — pause, don't loop.
|
||||
new_overrides["_backfill_state"] = "stalled"
|
||||
else:
|
||||
stalls = int(new_overrides.get("_backfill_cursor_stalls", 0)) + 1
|
||||
if stalls >= 2:
|
||||
new_overrides["_backfill_state"] = "stalled"
|
||||
new_overrides.pop("_backfill_cursor", None)
|
||||
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||
else:
|
||||
new_overrides["_backfill_cursor_stalls"] = stalls
|
||||
|
||||
src.config_overrides = new_overrides
|
||||
|
||||
async def _update_source_health(
|
||||
self, *, source_id: int, status: str, error_message: str | None,
|
||||
error_type: str | None = None,
|
||||
error_type: str | None = None, retry_after_seconds: float | None = None,
|
||||
) -> None:
|
||||
"""FC-3d: update Source.{consecutive_failures, last_error, last_checked_at}.
|
||||
|
||||
@@ -459,7 +554,17 @@ class DownloadService:
|
||||
# by error class without opening Logs per row.
|
||||
source.error_type = error_type
|
||||
if error_type == "rate_limited":
|
||||
await set_platform_cooldown(self.async_session, source.platform)
|
||||
# plan #708 B1: honor the server's Retry-After when the native
|
||||
# client surfaced one (clamped to a sane [60, 3600] window so a
|
||||
# tiny hint can't leave the platform effectively un-cooled and a
|
||||
# huge one can't strand it for hours); else the flat default.
|
||||
if retry_after_seconds is not None:
|
||||
seconds = int(min(max(retry_after_seconds, 60), 3600))
|
||||
await set_platform_cooldown(
|
||||
self.async_session, source.platform, seconds=seconds,
|
||||
)
|
||||
else:
|
||||
await set_platform_cooldown(self.async_session, source.platform)
|
||||
elif status == "skipped":
|
||||
source.last_error = None
|
||||
source.last_checked_at = now
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Artist, Source
|
||||
from ..utils.slug import slugify
|
||||
from .source_service import NEW_SOURCE_BACKFILL_RUNS
|
||||
from .source_service import BACKFILL_MAX_CHUNKS
|
||||
|
||||
|
||||
class UnknownPlatformError(Exception):
|
||||
@@ -205,16 +205,17 @@ class ExtensionService:
|
||||
return existing, False
|
||||
sp = await self.session.begin_nested()
|
||||
try:
|
||||
# New subscription sources arm a few backfill runs so the
|
||||
# first ticks walk the full history (otherwise gallery-dl's
|
||||
# exit:20 short-circuits before the archive is built).
|
||||
# Mirrors SourceService.create — without it, Firefox quick-
|
||||
# add on a creator with >20 unsynced posts would surface
|
||||
# as "check failed" with no diagnosis. Audit 2026-06-02.
|
||||
# New subscription sources arm run-until-done backfill (plan #693)
|
||||
# so the first ticks walk the full history (otherwise gallery-dl's
|
||||
# exit:20 short-circuits before the archive is built). Mirrors
|
||||
# SourceService.create — without it, Firefox quick-add on a creator
|
||||
# with >20 unsynced posts would surface as "check failed" with no
|
||||
# diagnosis. Audit 2026-06-02.
|
||||
src = Source(
|
||||
artist_id=artist_id, platform=platform,
|
||||
url=url, enabled=True,
|
||||
backfill_runs_remaining=NEW_SOURCE_BACKFILL_RUNS,
|
||||
config_overrides={"_backfill_state": "running"},
|
||||
backfill_runs_remaining=BACKFILL_MAX_CHUNKS,
|
||||
)
|
||||
self.session.add(src)
|
||||
await self.session.flush()
|
||||
|
||||
@@ -11,9 +11,15 @@ expected to write.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
JPEG_HEAD = b"\xff\xd8\xff"
|
||||
JPEG_TAIL = b"\xff\xd9"
|
||||
|
||||
@@ -108,3 +114,52 @@ def validate_file(path: Path) -> ValidationResult:
|
||||
return ValidationResult(ok=True, format="webp", size=size)
|
||||
|
||||
return ValidationResult(ok=True, format=None, size=size)
|
||||
|
||||
|
||||
def quarantine_file(
|
||||
images_root: Path,
|
||||
path: Path,
|
||||
artist_slug: str,
|
||||
platform: str,
|
||||
*,
|
||||
url: str | None,
|
||||
result: ValidationResult,
|
||||
) -> Path | None:
|
||||
"""Move a validation-failed file to `_quarantine/<slug>/<platform>` and write
|
||||
a `.quarantine.json` provenance sidecar next to it.
|
||||
|
||||
Returns the destination path, or None if the move itself failed (file left
|
||||
in place — the caller decides what to report). The caller has already run
|
||||
`validate_file` and seen `result.ok is False`. ONE implementation for both
|
||||
download backends — gallery-dl's batch post-process and the native ingester's
|
||||
per-media path — so the quarantine layout + provenance sidecar can't drift.
|
||||
"""
|
||||
quarantine_root = images_root / "_quarantine" / artist_slug / platform
|
||||
try:
|
||||
quarantine_root.mkdir(parents=True, exist_ok=True)
|
||||
dest = quarantine_root / path.name
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
dest = quarantine_root / f"{path.stem}.{counter}{path.suffix}"
|
||||
counter += 1
|
||||
shutil.move(str(path), str(dest))
|
||||
sidecar = dest.with_suffix(dest.suffix + ".quarantine.json")
|
||||
sidecar.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"original_path": str(path),
|
||||
"source_url": url,
|
||||
"artist_slug": artist_slug,
|
||||
"platform": platform,
|
||||
"format": result.format,
|
||||
"reason": result.reason,
|
||||
"size": result.size,
|
||||
"quarantined_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
except OSError as exc:
|
||||
log.error("Failed to quarantine %s: %s. File left in place.", path, exc)
|
||||
return None
|
||||
return dest
|
||||
|
||||
+155
-118
@@ -22,7 +22,7 @@ from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from .file_validator import is_validatable, validate_file
|
||||
from .file_validator import is_validatable, quarantine_file, validate_file
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,6 +39,12 @@ class ErrorType(StrEnum):
|
||||
HTTP_ERROR = "http_error"
|
||||
UNSUPPORTED_URL = "unsupported_url"
|
||||
VALIDATION_FAILED = "validation_failed"
|
||||
# Native Patreon ingester only (plan #697): a response parsed as JSON but
|
||||
# didn't match the JSON:API shape the ingester depends on (missing `data`,
|
||||
# media lacking `file_name`/`url`, etc.). Distinct from AUTH_ERROR — the fix
|
||||
# is updating the ingester's field-set/parser, not rotating credentials. The
|
||||
# contract test guards against silently shipping this.
|
||||
API_DRIFT = "api_drift"
|
||||
# Run made real progress (downloaded ≥1 file) but did not finish in the
|
||||
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
|
||||
# mapping classifies this as "ok" because the next tick continues.
|
||||
@@ -55,25 +61,24 @@ class ErrorType(StrEnum):
|
||||
# 20 contiguous HEADs is still negligible.
|
||||
TICK_SKIP_VALUE = "exit:20"
|
||||
|
||||
# Backfill mode (operator-triggered deep scan): walk the full history.
|
||||
# Source.backfill_runs_remaining > 0 selects this mode; the longer
|
||||
# timeout below absorbs creators with thousands of posts.
|
||||
# Backfill mode (operator-triggered deep scan): walk the full history,
|
||||
# one TIME-BOXED CHUNK per run (plan #693). config_overrides["_backfill_state"]
|
||||
# == "running" selects this mode; the cursor checkpoint (plan #689) lets each
|
||||
# chunk resume where the last stopped, so the walk advances across chunks
|
||||
# until gallery-dl exits cleanly (= reached the bottom).
|
||||
#
|
||||
# Sits below download_source's Celery soft_time_limit
|
||||
# (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py) with ~180s of
|
||||
# headroom for phase-3 persist. subprocess.run MUST raise TimeoutExpired
|
||||
# before Celery raises SoftTimeLimitExceeded — that exception path
|
||||
# captures partial stdout/stderr and finalizes the event; the soft-limit
|
||||
# path (until the 2026-06-03 fix) did not. Audit history: 1800 guaranteed
|
||||
# SIGKILL against the old hard limit (Knuxy #38275); 1170 was then sized
|
||||
# "30s shy of the hard limit (1200)" but still EXCEEDED the soft limit
|
||||
# (900), so SoftTimeLimitExceeded preempted TimeoutExpired and every
|
||||
# backfill stranded empty (Anduo #39912). Raising the Celery soft/hard
|
||||
# limits to 1350/1500 (tasks/download.py) is what made 1170 safe.
|
||||
# backfill_runs_remaining=3 still gives ~58 minutes of cumulative walk
|
||||
# across three runs for prolific creators.
|
||||
# The chunk budget is deliberately FAR below download_source's Celery
|
||||
# soft_time_limit (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py), not
|
||||
# "just under" it. Hitting this budget is the NORMAL chunk boundary, not a
|
||||
# failure: subprocess.run raises TimeoutExpired (which captures partial
|
||||
# stdout/stderr + the last emitted cursor), and download_service reclassifies
|
||||
# a chunk that made progress as PARTIAL (status "ok"), not an error. The huge
|
||||
# headroom means a stuck file or a slow chunk can never let Celery's
|
||||
# SoftTimeLimitExceeded preempt TimeoutExpired (the failure mode behind Knuxy
|
||||
# #38275 / Anduo #39912/#40411). Earlier we ran one ~1170s run-to-the-wall
|
||||
# per arming; that died as a timeout error every time on large catalogs.
|
||||
BACKFILL_SKIP_VALUE = True
|
||||
BACKFILL_TIMEOUT_SECONDS = 1170
|
||||
BACKFILL_CHUNK_SECONDS = 600
|
||||
|
||||
|
||||
# Sits well below download_source's Celery soft_time_limit
|
||||
@@ -98,6 +103,17 @@ class SourceConfig:
|
||||
Source.backfill_runs_remaining column at the download_service layer
|
||||
and is passed to _build_config_for_source as `skip_value`. Same for
|
||||
the per-run subprocess timeout.
|
||||
|
||||
`resume_cursor` is RUNTIME state, not persisted operator config: the
|
||||
cursor-paged backfill checkpoint (see parse_last_cursor + the
|
||||
download_service backfill lifecycle). It is consumed only by the native
|
||||
Patreon ingester (the sole cursor-paged platform; gallery-dl's Patreon path
|
||||
was removed at the #697 cutover): on a backfill/recovery run the ingester
|
||||
resumes its newest→oldest walk from that pagination cursor instead of
|
||||
restarting from the top. download_service threads it from
|
||||
config_overrides["_backfill_cursor"]; SourceConfig deliberately does NOT read
|
||||
it in from_dict (config_overrides also holds operator config, and
|
||||
resume_cursor must only apply in backfill/recovery mode).
|
||||
"""
|
||||
content_types: list[str] = field(default_factory=lambda: ["all"])
|
||||
sleep: float | None = None
|
||||
@@ -106,6 +122,7 @@ class SourceConfig:
|
||||
filename_pattern: str | None = None
|
||||
save_metadata: bool = True
|
||||
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
|
||||
resume_cursor: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> SourceConfig:
|
||||
@@ -138,6 +155,51 @@ class DownloadResult:
|
||||
duration_seconds: float = 0.0
|
||||
started_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
# Plan #704 — structured fields the NATIVE ingester populates directly (None
|
||||
# on the gallery-dl path, which keeps the regex-over-stdout route). When set,
|
||||
# phase 3 reads these instead of scraping the text it would otherwise have to
|
||||
# reconstruct: `run_stats` mirrors _compute_run_stats' shape; `cursor` is the
|
||||
# backfill checkpoint the ingester knows exactly (no parse_last_cursor).
|
||||
run_stats: dict | None = None
|
||||
cursor: str | None = None
|
||||
posts_processed: int = 0
|
||||
# Plan #708 B1 — the server's Retry-After seconds on a RATE_LIMITED result, so
|
||||
# the platform cooldown matches the hint instead of a flat default. None when
|
||||
# unknown (no header, or not a rate-limit failure).
|
||||
retry_after_seconds: float | None = None
|
||||
|
||||
|
||||
def extract_errors_warnings(stderr: str) -> str:
|
||||
"""Keep only the `[error]`/`[warning]` lines from a captured stderr blob.
|
||||
|
||||
A generic download-log helper (module-level so the native-ingester result
|
||||
path can shape its logs without reaching through a GalleryDLService instance).
|
||||
"""
|
||||
if not stderr:
|
||||
return ""
|
||||
kept = [
|
||||
line for line in stderr.splitlines()
|
||||
if "][error]" in line.lower() or "][warning]" in line.lower()
|
||||
]
|
||||
return "\n".join(kept)
|
||||
|
||||
|
||||
def truncate_log(text: str, max_bytes: int = 500_000) -> str:
|
||||
"""Cap a captured log to `max_bytes`, eliding the middle (head + tail kept)."""
|
||||
if not text:
|
||||
return text
|
||||
encoded = text.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text
|
||||
half = max_bytes // 2
|
||||
head = encoded[:half].decode("utf-8", errors="ignore")
|
||||
tail = encoded[-half:].decode("utf-8", errors="ignore")
|
||||
head_lines = head.count("\n")
|
||||
tail_lines = tail.count("\n")
|
||||
total_lines = text.count("\n")
|
||||
elided = max(0, total_lines - head_lines - tail_lines)
|
||||
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
|
||||
return head + marker + tail
|
||||
|
||||
|
||||
def _summarize_validation_failures(failures: list[dict]) -> str:
|
||||
@@ -154,6 +216,40 @@ def _summarize_validation_failures(failures: list[dict]) -> str:
|
||||
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
|
||||
|
||||
|
||||
# (parse_last_cursor was removed in plan #704: the native ingester now carries
|
||||
# its checkpoint cursor as a structured DownloadResult.cursor field, so there is
|
||||
# no log text to scrape — and gallery-dl platforms never had a cursor.)
|
||||
|
||||
|
||||
def make_run_stats(
|
||||
*,
|
||||
exit_code: int = 0,
|
||||
downloaded_count: int = 0,
|
||||
skipped_count: int = 0,
|
||||
per_item_failures: int = 0,
|
||||
warning_count: int = 0,
|
||||
tier_gated_count: int = 0,
|
||||
quarantined_count: int = 0,
|
||||
dead_lettered_count: int = 0,
|
||||
) -> dict:
|
||||
"""The canonical `run_stats` dict shape, in ONE place.
|
||||
|
||||
Both result producers — gallery-dl's `_compute_run_stats` (log scrape) and the
|
||||
native ingester's per-outcome tally (ingest_core) — build through this so the
|
||||
key set can't drift between backends. Phase 3 + the Logs UI read these keys.
|
||||
"""
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"downloaded_count": downloaded_count,
|
||||
"skipped_count": skipped_count,
|
||||
"per_item_failures": per_item_failures,
|
||||
"warning_count": warning_count,
|
||||
"tier_gated_count": tier_gated_count,
|
||||
"quarantined_count": quarantined_count,
|
||||
"dead_lettered_count": dead_lettered_count,
|
||||
}
|
||||
|
||||
|
||||
class GalleryDLService:
|
||||
"""Service for executing gallery-dl downloads."""
|
||||
|
||||
@@ -187,16 +283,10 @@ class GalleryDLService:
|
||||
"permission denied", "tier required", "pledge required",
|
||||
]
|
||||
|
||||
# Per-platform defaults. Lifted from GS — same six platforms FC supports.
|
||||
# Per-platform defaults for the gallery-dl-backed platforms. Patreon was
|
||||
# removed at the plan-#697 cutover — it now uses the native ingester
|
||||
# (services/patreon_ingester.py), not gallery-dl.
|
||||
PLATFORM_DEFAULTS = {
|
||||
"patreon": {
|
||||
"content_types": ["images", "image_large", "attachments", "postfile", "content"],
|
||||
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
|
||||
"filename": "{num:>02}_{filename}.{extension}",
|
||||
"videos": True,
|
||||
"embeds": True,
|
||||
"cursor": True,
|
||||
},
|
||||
"subscribestar": {
|
||||
"content_types": ["all"],
|
||||
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
|
||||
@@ -255,7 +345,12 @@ class GalleryDLService:
|
||||
"skip": True,
|
||||
"sleep": self._rate_limit,
|
||||
"sleep-request": max(0.5, self._rate_limit / 4),
|
||||
"retries": 3,
|
||||
# 2 (not 3) retries — a stuck CDN host shouldn't burn a whole
|
||||
# backfill chunk on one file. Anduo #40838 spent ~600s on a
|
||||
# single image (4 extractor HEAD retries × 30s + 4 downloader
|
||||
# GET retries × 120s); halving retries + the downloader
|
||||
# timeout below caps a wedged file at ~1-2 min instead.
|
||||
"retries": 2,
|
||||
"timeout": 30.0,
|
||||
"verify": True,
|
||||
"postprocessors": [
|
||||
@@ -270,27 +365,17 @@ class GalleryDLService:
|
||||
"downloader": {
|
||||
"part": True,
|
||||
"part-directory": str(self._config_dir / "temp"),
|
||||
"retries": 3,
|
||||
"timeout": 120.0,
|
||||
# Forward Patreon as Referer/Origin to yt-dlp when it
|
||||
# fetches video manifests. Operator-flagged 2026-06-01
|
||||
# (DaferQ patreon): video posts hosted on Mux carry a JWT
|
||||
# playback restriction that checks Referer/Origin on every
|
||||
# request — not just the token signature. gallery-dl's
|
||||
# HEAD probe to stream.mux.com returns 200 (the token is
|
||||
# valid), but yt-dlp's actual GET-with-Range to fetch the
|
||||
# m3u8 manifest 403s because yt-dlp sends its own default
|
||||
# Referer, which Mux's policy rejects. Forcing the right
|
||||
# headers fixes the headers-only case; Mux IP-range
|
||||
# restrictions are unfixable from here.
|
||||
"ytdl": {
|
||||
"raw-options": {
|
||||
"http_headers": {
|
||||
"Referer": "https://www.patreon.com/",
|
||||
"Origin": "https://www.patreon.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
# See the extractor retries note above (Anduo #40838): 2
|
||||
# retries + a 60s read-timeout (was 120) so a stalled
|
||||
# connection fails fast. 60s is a per-read timeout, not a
|
||||
# transfer cap — large GIFs that keep streaming are unaffected.
|
||||
"retries": 2,
|
||||
"timeout": 60.0,
|
||||
# NOTE: the Patreon/Mux yt-dlp Referer/Origin forwarding lived
|
||||
# here until the plan-#697 cutover. It was Patreon-specific (and
|
||||
# would have wrongly tagged the other platforms' yt-dlp fetches);
|
||||
# Patreon video is now handled by the native ingester's
|
||||
# downloader (patreon_downloader._VIDEO_HEADERS), so it's gone.
|
||||
},
|
||||
"output": {"progress": True},
|
||||
}
|
||||
@@ -342,14 +427,7 @@ class GalleryDLService:
|
||||
|
||||
platform_section = config["extractor"].setdefault(platform, {})
|
||||
|
||||
if platform == "patreon":
|
||||
if "all" in source_config.content_types:
|
||||
platform_section["files"] = [
|
||||
"images", "image_large", "attachments", "postfile", "content",
|
||||
]
|
||||
else:
|
||||
platform_section["files"] = source_config.content_types
|
||||
elif platform == "hentaifoundry":
|
||||
if platform == "hentaifoundry":
|
||||
if "pictures" in source_config.content_types or "all" in source_config.content_types:
|
||||
platform_section["include"] = "all"
|
||||
|
||||
@@ -509,10 +587,6 @@ class GalleryDLService:
|
||||
if not written_paths:
|
||||
return quarantined_relpaths, failures
|
||||
|
||||
quarantine_root = (
|
||||
self.images_root / "_quarantine" / artist_slug / platform
|
||||
)
|
||||
|
||||
for path in written_paths:
|
||||
if not is_validatable(path):
|
||||
continue
|
||||
@@ -523,34 +597,14 @@ class GalleryDLService:
|
||||
continue
|
||||
if result.ok:
|
||||
continue
|
||||
try:
|
||||
quarantine_root.mkdir(parents=True, exist_ok=True)
|
||||
dest = quarantine_root / path.name
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
dest = quarantine_root / f"{path.stem}.{counter}{path.suffix}"
|
||||
counter += 1
|
||||
path.rename(dest)
|
||||
sidecar = dest.with_suffix(dest.suffix + ".quarantine.json")
|
||||
sidecar.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"original_path": str(path),
|
||||
"source_url": url,
|
||||
"artist_slug": artist_slug,
|
||||
"platform": platform,
|
||||
"format": result.format,
|
||||
"reason": result.reason,
|
||||
"size": result.size,
|
||||
"quarantined_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
except OSError as exc:
|
||||
log.error("Failed to quarantine %s: %s. File left in place.", path, exc)
|
||||
continue
|
||||
|
||||
# Shared move+sidecar (file_validator.quarantine_file) — same impl the
|
||||
# native ingester uses, so the layout + provenance sidecar can't drift.
|
||||
dest = quarantine_file(
|
||||
self.images_root, path, artist_slug, platform,
|
||||
url=url, result=result,
|
||||
)
|
||||
if dest is None:
|
||||
continue # move failed → left in place, not counted
|
||||
log.warning(
|
||||
"Quarantined corrupt file: %s → %s (%s: %s)",
|
||||
path, dest, result.format, result.reason,
|
||||
@@ -588,41 +642,24 @@ class GalleryDLService:
|
||||
if "][warning]" in line.lower() and "not allowed to view post" in line.lower()
|
||||
)
|
||||
|
||||
return {
|
||||
"exit_code": return_code,
|
||||
"downloaded_count": self._count_downloaded_files(stdout),
|
||||
"skipped_count": skipped_stdout + skipped_stderr,
|
||||
"per_item_failures": per_item_failures,
|
||||
"warning_count": warning_count,
|
||||
"tier_gated_count": tier_gated_count,
|
||||
}
|
||||
return make_run_stats(
|
||||
exit_code=return_code,
|
||||
downloaded_count=self._count_downloaded_files(stdout),
|
||||
skipped_count=skipped_stdout + skipped_stderr,
|
||||
per_item_failures=per_item_failures,
|
||||
warning_count=warning_count,
|
||||
tier_gated_count=tier_gated_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_errors_warnings(stderr: str) -> str:
|
||||
if not stderr:
|
||||
return ""
|
||||
kept = [
|
||||
line for line in stderr.splitlines()
|
||||
if "][error]" in line.lower() or "][warning]" in line.lower()
|
||||
]
|
||||
return "\n".join(kept)
|
||||
# Thin delegator to the module-level helper (kept for existing callers).
|
||||
return extract_errors_warnings(stderr)
|
||||
|
||||
@staticmethod
|
||||
def _truncate_log(text: str, max_bytes: int = 500_000) -> str:
|
||||
if not text:
|
||||
return text
|
||||
encoded = text.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text
|
||||
half = max_bytes // 2
|
||||
head = encoded[:half].decode("utf-8", errors="ignore")
|
||||
tail = encoded[-half:].decode("utf-8", errors="ignore")
|
||||
head_lines = head.count("\n")
|
||||
tail_lines = tail.count("\n")
|
||||
total_lines = text.count("\n")
|
||||
elided = max(0, total_lines - head_lines - tail_lines)
|
||||
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
|
||||
return head + marker + tail
|
||||
# Thin delegator to the module-level helper (kept for existing callers).
|
||||
return truncate_log(text, max_bytes)
|
||||
|
||||
async def download(
|
||||
self,
|
||||
|
||||
@@ -31,7 +31,12 @@ from ..models import (
|
||||
Source,
|
||||
)
|
||||
from ..utils import safe_probe
|
||||
from ..utils.paths import derive_subdir, derive_top_level_artist, hash_suffixed_name
|
||||
from ..utils.paths import (
|
||||
derive_subdir,
|
||||
derive_top_level_artist,
|
||||
hash_suffixed_name,
|
||||
safe_ext,
|
||||
)
|
||||
from ..utils.phash import compute_phash, find_similar
|
||||
from ..utils.sidecar import find_sidecar, parse_sidecar
|
||||
from ..utils.slug import slugify
|
||||
@@ -82,27 +87,9 @@ def is_video(path: Path) -> bool:
|
||||
|
||||
def _safe_ext(path: Path) -> str:
|
||||
"""Conservatively extract a file extension for PostAttachment.ext
|
||||
(varchar(32)).
|
||||
|
||||
gallery-dl produces some filenames with URL-encoded query-string
|
||||
artifacts embedded into the basename (e.g.
|
||||
`79507046_media_..._https___www.patreon.com_media-u_Z0FBQUFBQm5q...`).
|
||||
`Path.suffix` finds the LAST dot and returns everything after, which
|
||||
in those cases yields a 50+ char "extension" of mostly base64-ish
|
||||
junk. That blows the column. Operator-flagged 2026-05-25.
|
||||
|
||||
Real extensions are short and alphanumeric. We accept anything ≤ 16
|
||||
chars where every post-dot character is alphanumeric; anything else
|
||||
means the input wasn't a real extension and we return the empty
|
||||
string. ext is nullable-ish (empty string still satisfies NOT NULL)
|
||||
and consumers should treat "" as "no known extension".
|
||||
"""
|
||||
suffix = path.suffix.lower()
|
||||
if not suffix or len(suffix) > 16:
|
||||
return ""
|
||||
if not all(c.isalnum() for c in suffix[1:]):
|
||||
return ""
|
||||
return suffix
|
||||
(varchar(32)). Thin wrapper over the shared `utils.paths.safe_ext` (kept for
|
||||
the Path-typed call sites + the [[path_suffix_sanitize]] memory pointer)."""
|
||||
return safe_ext(path)
|
||||
|
||||
|
||||
def _mime_for(path: Path) -> str:
|
||||
@@ -443,13 +430,17 @@ class Importer:
|
||||
self._capture_attachment(
|
||||
source, post=post, artist=artist_use, resolved=True,
|
||||
)
|
||||
return ImportResult(status="attached")
|
||||
reason = f"archive probe rejected, captured unextracted: {probe.reason}"
|
||||
log.warning("%s: %s", source.name, reason)
|
||||
return ImportResult(status="attached", error=reason)
|
||||
|
||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist_use)
|
||||
member_ids: list[int] = []
|
||||
member_total = 0
|
||||
with extract_archive(source) as members:
|
||||
for _name, member_path in members:
|
||||
member_total += 1
|
||||
if not is_supported(member_path):
|
||||
continue # non-media preserved via the stored archive
|
||||
res = self._import_media(
|
||||
@@ -466,7 +457,19 @@ class Importer:
|
||||
status="imported", image_id=member_ids[0],
|
||||
member_image_ids=member_ids,
|
||||
)
|
||||
return ImportResult(status="attached")
|
||||
# No images landed — surface WHY so a post showing "no images" beside an
|
||||
# archive is diagnosable instead of silent. Zero members usually means
|
||||
# the extractor backend is missing/failed (unar for rar, py7zr for 7z)
|
||||
# or the file is corrupt; non-zero-but-no-images means it held only
|
||||
# non-media files.
|
||||
reason = (
|
||||
"archive extracted but held no supported image/video members"
|
||||
if member_total
|
||||
else "archive yielded no members (unsupported/corrupt, or the "
|
||||
"extractor backend failed)"
|
||||
)
|
||||
log.warning("%s: %s", source.name, reason)
|
||||
return ImportResult(status="attached", error=reason)
|
||||
|
||||
def _import_media(
|
||||
self, source: Path, attribution_path: Path,
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
"""Platform-agnostic native-ingest core (plan #706, build on #697/#703/#704/#705).
|
||||
|
||||
The orchestration that drives a native subscription walk — page a feed →
|
||||
extract media → tiered skip (seen-ledger / on-disk / dead-letter) → download →
|
||||
mark-seen / record-failures / checkpoint-cursor → return a gallery-dl-shaped
|
||||
`DownloadResult`, across tick/backfill/recovery modes — is identical for every
|
||||
platform. Only four things are platform-specific, and they're INJECTED at
|
||||
construction by a thin adapter (e.g. `PatreonIngester`):
|
||||
|
||||
- `client` — `.iter_posts(feed_id, cursor)` yielding `(post, included,
|
||||
page_cursor)` + `.extract_media(post, included) -> [media]`.
|
||||
- `downloader`— `.download_post(post, media, artist_slug, is_seen,
|
||||
should_stop) -> [MediaOutcome]` (status in downloaded/
|
||||
skipped_seen/skipped_disk/quarantined/error;
|
||||
`.path`/`.error`/`.post_id`). `should_stop()` is polled
|
||||
between media so the time-box is honoured mid-post.
|
||||
- ledger — `seen_model` + `failed_model` SQLAlchemy models (+ their
|
||||
on-conflict UNIQUE constraint names) and a `ledger_key(media)`.
|
||||
- failure map — the adapter overrides `_failure_result` (platform exception
|
||||
→ DownloadResult.error_type) and supplies `error_base` (the
|
||||
exception type the walk catches) + `platform` (result label).
|
||||
|
||||
Everything DB touches a SHORT-LIVED sync session from the injected sessionmaker —
|
||||
never held across a network fetch ([[db-connection-held-across-subprocess]]).
|
||||
Plain-HTTP homelab: no secure-context Web API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from .gallery_dl import DownloadResult, ErrorType, make_run_stats
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Stop a tick after this many CONTIGUOUS already-have-it media (seen-ledger or
|
||||
# on-disk) — the cheap native equivalent of gallery-dl's `exit:20`, now free of
|
||||
# per-file HEADs. Headroom against paywalled/undownloadable items interleaving.
|
||||
_TICK_SEEN_THRESHOLD = 20
|
||||
|
||||
# plan #705 #7: after this many failed download/validate attempts a media is
|
||||
# "dead-lettered" and skipped on routine tick/backfill walks (recovery still
|
||||
# re-attempts it). Stops a permanently-broken media re-erroring forever.
|
||||
DEAD_LETTER_THRESHOLD = 3
|
||||
# last_error is Text but bound it so a giant traceback doesn't bloat the row.
|
||||
_ERROR_MAX = 1000
|
||||
|
||||
# plan #709: throttle the live-progress write to the running DownloadEvent to one
|
||||
# every ~5s — a steady cadence for the Downloads view regardless of how big/slow a
|
||||
# page is (page boundaries can be minutes apart on image-dense backfills, so a
|
||||
# page-tied update would lurch). Trivial churn (~one single-row UPDATE / 5s).
|
||||
_LIVE_PROGRESS_INTERVAL = 5.0
|
||||
|
||||
|
||||
class Ingester:
|
||||
"""Generic native-ingest orchestration. Subclass with a platform adapter
|
||||
(see the module docstring) — or construct directly with the keyword seams."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client,
|
||||
downloader,
|
||||
session_factory: Callable[[], object],
|
||||
seen_model,
|
||||
failed_model,
|
||||
seen_constraint: str,
|
||||
failed_constraint: str,
|
||||
ledger_key: Callable[[object], str],
|
||||
platform: str,
|
||||
error_base: type[Exception],
|
||||
):
|
||||
self.client = client
|
||||
self.downloader = downloader
|
||||
self.session_factory = session_factory
|
||||
self._seen_model = seen_model
|
||||
self._failed_model = failed_model
|
||||
self._seen_constraint = seen_constraint
|
||||
self._failed_constraint = failed_constraint
|
||||
self._ledger_key = ledger_key
|
||||
self._platform = platform
|
||||
self._error_base = error_base
|
||||
|
||||
# -- public ------------------------------------------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
*,
|
||||
source_id: int,
|
||||
campaign_id: str,
|
||||
artist_slug: str,
|
||||
url: str,
|
||||
mode: str,
|
||||
resume_cursor: str | None = None,
|
||||
time_budget_seconds: float = 870.0,
|
||||
seen_threshold: int = _TICK_SEEN_THRESHOLD,
|
||||
posts_base: int = 0,
|
||||
event_id: int | None = None,
|
||||
) -> DownloadResult:
|
||||
"""Walk + download for one source, returning a gallery-dl-shaped result.
|
||||
|
||||
`mode` is "tick" | "backfill" | "recovery". Recovery bypasses the tier-1
|
||||
seen-ledger AND the dead-letter ledger (tier-2 disk still skips kept
|
||||
files). The walk stops on:
|
||||
- budget exhaustion (time_budget_seconds) → TIMEOUT / PARTIAL
|
||||
- tick early-out (seen_threshold contiguous seen) → success
|
||||
- reaching the bottom of the feed → success (rc 0)
|
||||
A client-level failure (drift / auth / network) fails the whole run loud.
|
||||
"""
|
||||
bypass_seen = mode == "recovery"
|
||||
# Only deep walks checkpoint their cursor mid-flight (plan #705 #6); a
|
||||
# tick has no resumable backfill state.
|
||||
checkpoint = mode in ("backfill", "recovery")
|
||||
ledger_key = self._ledger_key
|
||||
start = time.monotonic()
|
||||
last_live = start # plan #709: last live-progress write timestamp
|
||||
log_lines: list[str] = []
|
||||
written: list[str] = []
|
||||
quarantined_paths: list[str] = []
|
||||
downloaded = 0
|
||||
errors = 0
|
||||
quarantined = 0
|
||||
dead_lettered = 0
|
||||
skipped_count = 0
|
||||
posts_processed = 0
|
||||
# Net-new posts THIS chunk for the live progress badge (plan #704 #5);
|
||||
# excludes the re-walked resume page so _backfill_posts stays a monotonic
|
||||
# absolute across chunks instead of an inflating sum. posts_processed
|
||||
# stays the gross per-chunk count used for the run summary.
|
||||
chunk_new_posts = 0
|
||||
consecutive_seen = 0
|
||||
emitted_cursor: str | None = None
|
||||
reached_bottom = False
|
||||
budget_hit = False
|
||||
early_out = False
|
||||
stopped = False # plan #708 B4: operator hit Stop mid-walk
|
||||
cancel_armed = False # latched once we observe a live "running" state
|
||||
|
||||
def _result(
|
||||
*, success: bool, return_code: int,
|
||||
error_type: ErrorType | None, error_message: str | None,
|
||||
) -> DownloadResult:
|
||||
# plan #704: return STRUCTURED data — phase 3 reads run_stats/cursor
|
||||
# directly instead of regex-scraping a reconstructed stdout. stdout
|
||||
# stays a human-readable summary (no fake `Cursor:` lines).
|
||||
return DownloadResult(
|
||||
success=success,
|
||||
url=url,
|
||||
artist_slug=artist_slug,
|
||||
platform=self._platform,
|
||||
files_downloaded=downloaded,
|
||||
files_quarantined=quarantined,
|
||||
quarantined_paths=list(quarantined_paths),
|
||||
written_paths=written,
|
||||
stdout="\n".join(log_lines),
|
||||
stderr="",
|
||||
return_code=return_code,
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
duration_seconds=time.monotonic() - start,
|
||||
cursor=emitted_cursor,
|
||||
posts_processed=posts_processed,
|
||||
run_stats=make_run_stats(
|
||||
exit_code=return_code,
|
||||
downloaded_count=downloaded,
|
||||
skipped_count=skipped_count,
|
||||
per_item_failures=errors,
|
||||
quarantined_count=quarantined,
|
||||
dead_lettered_count=dead_lettered,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
for post, included, page_cursor in self.client.iter_posts(
|
||||
campaign_id, cursor=resume_cursor
|
||||
):
|
||||
# Checkpoint the cursor that FETCHED this page the moment we
|
||||
# START it — so a chunk cut mid-page resumes the page, not the one
|
||||
# after it. Carried as DownloadResult.cursor (plan #704).
|
||||
if page_cursor and page_cursor != emitted_cursor:
|
||||
emitted_cursor = page_cursor
|
||||
# plan #705 #6: persist the cursor at each page boundary so a
|
||||
# worker SIGKILL mid-chunk resumes near the crash, not the
|
||||
# chunk start. (phase 3 still writes the final cursor — same
|
||||
# value; this is the crash-safety net.) plan #704 #5: persist
|
||||
# the live posts count alongside it so the badge climbs DURING
|
||||
# the chunk, not only when it ends.
|
||||
if checkpoint:
|
||||
# plan #708 B4: an operator Stop pops `_backfill_state` —
|
||||
# bail at the page boundary (progress already checkpointed)
|
||||
# before more network work, so the live chunk halts
|
||||
# promptly instead of running to its time-box. LATCH on the
|
||||
# first observed "running" state, so a run invoked WITHOUT a
|
||||
# running state (a unit test, or a stale call) never
|
||||
# spuriously self-cancels. A short SELECT, never held.
|
||||
if self._still_running(source_id):
|
||||
cancel_armed = True
|
||||
elif cancel_armed:
|
||||
stopped = True
|
||||
break
|
||||
self._checkpoint_cursor(source_id, emitted_cursor)
|
||||
self._checkpoint_posts(source_id, posts_base + chunk_new_posts)
|
||||
|
||||
# Time-box check at the post boundary (coarse, like a gallery-dl
|
||||
# chunk). Backfill/recovery resume from emitted_cursor next chunk.
|
||||
if time.monotonic() - start >= time_budget_seconds:
|
||||
budget_hit = True
|
||||
break
|
||||
|
||||
posts_processed += 1
|
||||
# The resume page (its cursor == resume_cursor) was already
|
||||
# counted by the chunk that checkpointed it — don't re-count it
|
||||
# into the persisted badge (plan #704 #5). First chunk has
|
||||
# resume_cursor None, so everything counts.
|
||||
if not (resume_cursor and page_cursor == resume_cursor):
|
||||
chunk_new_posts += 1
|
||||
media = self.client.extract_media(post, included)
|
||||
if not media:
|
||||
continue
|
||||
|
||||
keys = [ledger_key(m) for m in media]
|
||||
# Recovery bypasses BOTH the seen-ledger AND the dead-letter
|
||||
# ledger (the operator's "try everything again"); routine walks
|
||||
# skip seen + dead media (tier-1 + tier-1.5, plan #705 #7).
|
||||
dead = set() if bypass_seen else self._dead_keys(source_id, keys)
|
||||
seen = (
|
||||
set()
|
||||
if bypass_seen
|
||||
else self._seen_keys(source_id, keys)
|
||||
)
|
||||
skip = seen | dead
|
||||
|
||||
def _is_skip(m, _skip=skip) -> bool:
|
||||
return ledger_key(m) in _skip
|
||||
|
||||
# Honour the time-box DURING a media-dense post too, not only at
|
||||
# the per-post boundary below — else one heavy post can blow the
|
||||
# chunk budget out to the Celery soft limit (Pocketacer, 2026-06-07).
|
||||
outcomes = self.downloader.download_post(
|
||||
post, media, artist_slug, is_seen=_is_skip,
|
||||
should_stop=lambda: time.monotonic() - start >= time_budget_seconds,
|
||||
)
|
||||
|
||||
to_mark: list[tuple[str, str]] = []
|
||||
to_clear: list[str] = [] # recovered → drop any dead-letter row
|
||||
to_fail: list[tuple[str, str, str]] = [] # (key, post_id, error)
|
||||
for media_item, outcome in zip(media, outcomes, strict=False):
|
||||
key = ledger_key(media_item)
|
||||
if key in dead:
|
||||
dead_lettered += 1 # skipped because previously dead
|
||||
if outcome.status == "downloaded":
|
||||
downloaded += 1
|
||||
if outcome.path is not None:
|
||||
written.append(str(outcome.path))
|
||||
to_mark.append((key, media_item.post_id))
|
||||
to_clear.append(key)
|
||||
consecutive_seen = 0
|
||||
elif outcome.status == "skipped_disk":
|
||||
# Already on disk (a prior run). Reconcile the ledger so a
|
||||
# later tick skips it at tier-1 without a disk stat, but
|
||||
# do NOT re-feed it to phase 3 — attach_in_place would see
|
||||
# the duplicate sha256 and unlink the on-disk copy.
|
||||
to_mark.append((key, media_item.post_id))
|
||||
to_clear.append(key)
|
||||
skipped_count += 1
|
||||
consecutive_seen += 1
|
||||
elif outcome.status == "skipped_seen":
|
||||
skipped_count += 1
|
||||
consecutive_seen += 1
|
||||
elif outcome.status == "quarantined":
|
||||
# New content that failed validation (corrupt) — counted
|
||||
# distinctly so the run surfaces a real quarantined total.
|
||||
# Not marked seen (a later walk may re-fetch a fixed file);
|
||||
# it IS new content, so it breaks the run-of-seen. Counts
|
||||
# toward the dead-letter ledger (plan #705 #7).
|
||||
quarantined += 1
|
||||
if outcome.path is not None:
|
||||
quarantined_paths.append(str(outcome.path))
|
||||
to_fail.append((key, media_item.post_id, outcome.error or "quarantined"))
|
||||
consecutive_seen = 0
|
||||
elif outcome.status == "error":
|
||||
errors += 1
|
||||
to_fail.append((key, media_item.post_id, outcome.error or "error"))
|
||||
# An error neither advances nor resets the run-of-seen.
|
||||
|
||||
if mode == "tick" and consecutive_seen >= seen_threshold:
|
||||
early_out = True
|
||||
break
|
||||
|
||||
# Persist ledger changes AFTER the network fetch, on short
|
||||
# sessions: mark downloaded/on-disk seen, clear any dead-letter
|
||||
# for recovered media, and record failures (plan #705 #7).
|
||||
if to_mark:
|
||||
self._mark_seen(source_id, to_mark)
|
||||
if to_clear:
|
||||
self._clear_failures(source_id, to_clear)
|
||||
if to_fail:
|
||||
self._record_failures(source_id, to_fail)
|
||||
|
||||
# plan #709: time-throttled live progress to the running event so
|
||||
# the Downloads view ticks ~every 5s, independent of page size.
|
||||
now = time.monotonic()
|
||||
if event_id is not None and (now - last_live) >= _LIVE_PROGRESS_INTERVAL:
|
||||
last_live = now
|
||||
self._write_live_progress(event_id, {
|
||||
"downloaded": downloaded,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors,
|
||||
"quarantined": quarantined,
|
||||
"posts": posts_processed,
|
||||
})
|
||||
|
||||
if early_out:
|
||||
break
|
||||
else:
|
||||
reached_bottom = True
|
||||
except self._error_base as exc:
|
||||
# The platform's client-error base — _failure_result (adapter)
|
||||
# maps it to a typed error.
|
||||
return self._failure_result(exc, _result)
|
||||
|
||||
# plan #708 B4: a Stop already popped the backfill state (incl. cursor +
|
||||
# posts), so don't re-write them — return PARTIAL (reads as "ok/progress",
|
||||
# the lifecycle no-ops since state is gone) instead of a false "complete".
|
||||
if stopped:
|
||||
return _result(
|
||||
success=False, return_code=-1,
|
||||
error_type=ErrorType.PARTIAL,
|
||||
error_message=f"Stopped by operator: {downloaded} file(s) this chunk",
|
||||
)
|
||||
|
||||
# Final authoritative posts count for the badge — captures the last page
|
||||
# after the last boundary write and the time-box break (plan #704 #5).
|
||||
if checkpoint:
|
||||
self._checkpoint_posts(source_id, posts_base + chunk_new_posts)
|
||||
|
||||
if errors:
|
||||
log_lines.append(f"{errors} media item(s) failed")
|
||||
if quarantined:
|
||||
log_lines.append(f"{quarantined} media item(s) quarantined (invalid)")
|
||||
if dead_lettered:
|
||||
log_lines.append(f"{dead_lettered} media item(s) skipped (dead-lettered)")
|
||||
log_lines.append(
|
||||
f"{self._platform} ingest ({mode}): {downloaded} downloaded, "
|
||||
f"{skipped_count} skipped, {quarantined} quarantined, "
|
||||
f"{dead_lettered} dead-lettered, {errors} error(s), "
|
||||
f"{posts_processed} post(s)"
|
||||
+ (", reached end" if reached_bottom else "")
|
||||
+ (", time-boxed" if budget_hit else "")
|
||||
)
|
||||
|
||||
if budget_hit:
|
||||
# A chunk that hit its time-box but made forward progress is a
|
||||
# NORMAL chunk boundary, not a failure (PARTIAL → status "ok"); the
|
||||
# next chunk resumes from the emitted cursor. No progress → TIMEOUT,
|
||||
# which feeds download_service's backfill stall-guard. rc<0 mirrors
|
||||
# subprocess TimeoutExpired so completion detection stays false.
|
||||
made_progress = downloaded > 0 or emitted_cursor != resume_cursor
|
||||
if made_progress:
|
||||
return _result(
|
||||
success=False, return_code=-1,
|
||||
error_type=ErrorType.PARTIAL,
|
||||
error_message=(
|
||||
f"Backfill chunk: {downloaded} file(s) — continuing"
|
||||
),
|
||||
)
|
||||
return _result(
|
||||
success=False, return_code=-1,
|
||||
error_type=ErrorType.TIMEOUT,
|
||||
error_message="Chunk timed out with no progress",
|
||||
)
|
||||
|
||||
# Normal success: reached the bottom, or a tick that early-outed. rc 0 +
|
||||
# error_type None is REQUIRED for a backfill/recovery walk that reached
|
||||
# the bottom to be marked COMPLETE by
|
||||
# download_service._apply_backfill_lifecycle — so we return None even
|
||||
# when downloaded == 0 (a re-confirming walk that found nothing new still
|
||||
# completed). success=True maps to status "ok" regardless. A tick that
|
||||
# early-outed also returns here; ticks never set backfill state so the
|
||||
# lifecycle is a no-op for them.
|
||||
return _result(
|
||||
success=True, return_code=0,
|
||||
error_type=None, error_message=None,
|
||||
)
|
||||
|
||||
# -- preview (dry-run) -------------------------------------------------
|
||||
|
||||
def preview(
|
||||
self,
|
||||
source_id: int,
|
||||
campaign_id: str,
|
||||
*,
|
||||
page_limit: int = 3,
|
||||
sample_size: int = 10,
|
||||
) -> dict:
|
||||
"""Dry-run (plan #708 B4): walk up to `page_limit` pages and count media
|
||||
NOT already in the seen/dead ledgers, WITHOUT downloading anything.
|
||||
|
||||
Read-only — only the seen/dead SELECTs touch the DB (short sessions). Lets
|
||||
an operator gauge "is this source worth a backfill?" cheaply. Returns:
|
||||
{total_new, posts_scanned, pages_scanned, has_more,
|
||||
sample: [{title, date, new}, ...]} # sample = posts with new media
|
||||
A client-level failure (auth/drift) propagates to the caller.
|
||||
"""
|
||||
total_new = 0
|
||||
posts_scanned = 0
|
||||
pages_scanned = 0
|
||||
has_more = False
|
||||
sample: list[dict] = []
|
||||
unset = object()
|
||||
last_page: object = unset
|
||||
for post, included, page_cursor in self.client.iter_posts(
|
||||
campaign_id, cursor=None
|
||||
):
|
||||
if page_cursor != last_page:
|
||||
last_page = page_cursor
|
||||
pages_scanned += 1
|
||||
if pages_scanned > page_limit:
|
||||
has_more = True
|
||||
pages_scanned = page_limit
|
||||
break
|
||||
posts_scanned += 1
|
||||
media = self.client.extract_media(post, included)
|
||||
if not media:
|
||||
continue
|
||||
keys = [self._ledger_key(m) for m in media]
|
||||
skip = self._seen_keys(source_id, keys) | self._dead_keys(source_id, keys)
|
||||
new_count = sum(1 for m in media if self._ledger_key(m) not in skip)
|
||||
total_new += new_count
|
||||
if new_count > 0 and len(sample) < sample_size:
|
||||
meta = self.client.post_meta(post)
|
||||
sample.append(
|
||||
{
|
||||
"title": meta.get("title") or "(untitled)",
|
||||
"date": meta.get("date"),
|
||||
"new": new_count,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"total_new": total_new,
|
||||
"posts_scanned": posts_scanned,
|
||||
"pages_scanned": pages_scanned,
|
||||
"has_more": has_more,
|
||||
"sample": sample,
|
||||
}
|
||||
|
||||
# -- failure mapping (adapter overrides) -------------------------------
|
||||
|
||||
def _failure_result(self, exc: Exception, _result) -> DownloadResult:
|
||||
"""Map a platform client-error to a typed failed DownloadResult. The base
|
||||
gives a safe default; adapters override with their exception taxonomy."""
|
||||
log.warning("%s ingest failed: %s", self._platform, exc)
|
||||
return _result(
|
||||
success=False, return_code=1,
|
||||
error_type=ErrorType.UNKNOWN_ERROR, error_message=str(exc),
|
||||
)
|
||||
|
||||
# -- seen-ledger (short-lived sessions) --------------------------------
|
||||
|
||||
def _seen_keys(self, source_id: int, keys: list[str]) -> set[str]:
|
||||
"""Which of `keys` are already in the seen-ledger for this source.
|
||||
|
||||
One short SELECT on its own session — opened and closed without any
|
||||
network in between (the GETs happen after, in download_post).
|
||||
"""
|
||||
if not keys:
|
||||
return set()
|
||||
with self.session_factory() as session:
|
||||
rows = session.execute(
|
||||
select(self._seen_model.filehash).where(
|
||||
self._seen_model.source_id == source_id,
|
||||
self._seen_model.filehash.in_(keys),
|
||||
)
|
||||
).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
def _checkpoint_cursor(self, source_id: int, cursor: str) -> None:
|
||||
"""Persist the in-progress backfill cursor mid-walk (plan #705 #6).
|
||||
|
||||
ATOMIC, single-key UPDATE: cast the JSON column to jsonb, set just
|
||||
`_backfill_cursor`, cast back — so it never clobbers operator config or
|
||||
the other backfill keys (no read-modify-write race). The in-flight guard
|
||||
means only this source's one download runs at a time; a concurrent
|
||||
operator stop is benign (a stray cursor with no `_backfill_state` is
|
||||
ignored by tick mode and cleared on the next start).
|
||||
"""
|
||||
with self.session_factory() as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE source SET config_overrides = jsonb_set("
|
||||
" coalesce(config_overrides::jsonb, '{}'::jsonb),"
|
||||
" '{_backfill_cursor}', to_jsonb(cast(:cur AS text))"
|
||||
")::json WHERE id = :sid"
|
||||
),
|
||||
{"cur": cursor, "sid": source_id},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def _write_live_progress(self, event_id: int, counts: dict) -> None:
|
||||
"""Throttled mid-walk write of live counts to the RUNNING download_event
|
||||
(plan #709) so the Downloads view shows progress before the chunk
|
||||
finishes. A short session (never held across the walk); the `status =
|
||||
'running'` guard avoids clobbering an event phase 3 already finalized.
|
||||
`metadata` is JSONB — jsonb_set sets just the `live` key, leaving the rest
|
||||
for phase 3 to overwrite with the final run_stats."""
|
||||
with self.session_factory() as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE download_event SET metadata = jsonb_set("
|
||||
" coalesce(metadata, '{}'::jsonb), '{live}',"
|
||||
" cast(:live AS jsonb)) "
|
||||
"WHERE id = :eid AND status = 'running'"
|
||||
),
|
||||
{"live": json.dumps(counts), "eid": event_id},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def _still_running(self, source_id: int) -> bool:
|
||||
"""True while the source is armed for a deep walk (plan #708 B4).
|
||||
|
||||
An operator Stop (`source_service.stop_backfill`) pops `_backfill_state`,
|
||||
so a False here means "cancel this chunk now". One short SELECT on its own
|
||||
session — never held across the walk
|
||||
([[db-connection-held-across-subprocess]])."""
|
||||
with self.session_factory() as session:
|
||||
state = session.execute(
|
||||
text(
|
||||
"SELECT config_overrides::jsonb ->> '_backfill_state' "
|
||||
"FROM source WHERE id = :sid"
|
||||
),
|
||||
{"sid": source_id},
|
||||
).scalar_one_or_none()
|
||||
return state == "running"
|
||||
|
||||
def _checkpoint_posts(self, source_id: int, posts: int) -> None:
|
||||
"""Persist the live backfill posts-processed count mid-walk (plan #704 #5).
|
||||
|
||||
Same atomic single-key jsonb_set dance as _checkpoint_cursor, on the
|
||||
`_backfill_posts` key (cast to a JSON number) — so the progress badge
|
||||
climbs DURING a chunk without clobbering operator config or the cursor.
|
||||
The ingester OWNS this key now; download_service no longer accumulates it
|
||||
post-chunk (which lagged a whole chunk and over-counted the resume page).
|
||||
"""
|
||||
with self.session_factory() as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE source SET config_overrides = jsonb_set("
|
||||
" coalesce(config_overrides::jsonb, '{}'::jsonb),"
|
||||
" '{_backfill_posts}', to_jsonb(cast(:posts AS int))"
|
||||
")::json WHERE id = :sid"
|
||||
),
|
||||
{"posts": posts, "sid": source_id},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def _mark_seen(self, source_id: int, items: list[tuple[str, str]]) -> None:
|
||||
"""Idempotent upsert of (filehash, post_id) seen-ledger rows for a page.
|
||||
|
||||
ON CONFLICT DO NOTHING against the (source_id, filehash) UNIQUE so a
|
||||
re-sighting — or a concurrent walk — is a harmless no-op
|
||||
([[scalar_one_or_none-duplicates]]: never check-then-insert without the
|
||||
DB constraint backing it). De-dup the batch locally first so a single
|
||||
page can't present the same key twice to one INSERT.
|
||||
"""
|
||||
seen_local: set[str] = set()
|
||||
values = []
|
||||
for key, post_id in items:
|
||||
if key in seen_local:
|
||||
continue
|
||||
seen_local.add(key)
|
||||
values.append(
|
||||
{"source_id": source_id, "filehash": key, "post_id": post_id}
|
||||
)
|
||||
if not values:
|
||||
return
|
||||
with self.session_factory() as session:
|
||||
stmt = pg_insert(self._seen_model).values(values)
|
||||
stmt = stmt.on_conflict_do_nothing(constraint=self._seen_constraint)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
# -- dead-letter ledger (plan #705 #7) ---------------------------------
|
||||
|
||||
def _dead_keys(self, source_id: int, keys: list[str]) -> set[str]:
|
||||
"""Which of `keys` have failed >= DEAD_LETTER_THRESHOLD times (dead).
|
||||
One short SELECT; recovery never calls this (it re-attempts dead media)."""
|
||||
if not keys:
|
||||
return set()
|
||||
with self.session_factory() as session:
|
||||
rows = session.execute(
|
||||
select(self._failed_model.filehash).where(
|
||||
self._failed_model.source_id == source_id,
|
||||
self._failed_model.filehash.in_(keys),
|
||||
self._failed_model.attempts >= DEAD_LETTER_THRESHOLD,
|
||||
)
|
||||
).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
def _record_failures(
|
||||
self, source_id: int, items: list[tuple[str, str, str]]
|
||||
) -> None:
|
||||
"""Upsert-increment the dead-letter ledger for failed media. On conflict
|
||||
bump `attempts` and refresh last_error/last_failed_at (UNIQUE backs the
|
||||
upsert — no check-then-insert). De-dup the batch (one row/key, last error
|
||||
wins)."""
|
||||
by_key: dict[str, str] = {}
|
||||
for key, _post_id, err in items:
|
||||
by_key[key] = (err or "")[:_ERROR_MAX]
|
||||
if not by_key:
|
||||
return
|
||||
values = [
|
||||
{"source_id": source_id, "filehash": k, "attempts": 1, "last_error": e}
|
||||
for k, e in by_key.items()
|
||||
]
|
||||
with self.session_factory() as session:
|
||||
stmt = pg_insert(self._failed_model).values(values)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
constraint=self._failed_constraint,
|
||||
set_={
|
||||
"attempts": self._failed_model.attempts + 1,
|
||||
"last_error": stmt.excluded.last_error,
|
||||
"last_failed_at": func.now(),
|
||||
},
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
def _clear_failures(self, source_id: int, keys: list[str]) -> None:
|
||||
"""Drop dead-letter rows for media that just downloaded cleanly — they
|
||||
recovered. A no-op DELETE for keys that were never failing."""
|
||||
unique = list(dict.fromkeys(keys))
|
||||
if not unique:
|
||||
return
|
||||
with self.session_factory() as session:
|
||||
session.execute(
|
||||
delete(self._failed_model).where(
|
||||
self._failed_model.source_id == source_id,
|
||||
self._failed_model.filehash.in_(unique),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@@ -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,604 @@
|
||||
"""Native Patreon JSON:API client (build step 1 of the native ingester).
|
||||
|
||||
Clean-room reimplementation of the Patreon `/api/posts` read path. This is a
|
||||
plain, synchronous client over `requests` — the orchestrator already wraps
|
||||
sync importer calls, so nothing here needs to be async (mirrors
|
||||
patreon_resolver.py, which wraps its sync lookup in run_in_executor at the
|
||||
call site).
|
||||
|
||||
Scope (build step 1): fetch + page + parse only. This module is NOT wired into
|
||||
download_service yet — that is a later step. The public surface here exists so
|
||||
the later step can drive it:
|
||||
- PatreonClient(cookies_path).iter_posts(campaign_id)
|
||||
→ (post, included_index, page_cursor)
|
||||
- extract_media(post, included_index) → list[MediaItem]
|
||||
- parse_cursor_from_url(url) → cursor
|
||||
|
||||
Drift detection is loud on purpose: Patreon ships JSON:API and the shapes we
|
||||
depend on (top-level `data`, media resources carrying `file_name`/`url`) are
|
||||
the contract. If a response comes back as an HTML login page or a media
|
||||
resource is missing the fields we resolve against, we raise PatreonDriftError
|
||||
rather than silently yielding empty media — so the later import step surfaces
|
||||
"Patreon changed something" instead of "creator has no posts".
|
||||
|
||||
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.cookiejar
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
from ..utils.paths import safe_ext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_POSTS_URL = "https://www.patreon.com/api/posts"
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# 429 backoff (plan #703): ride out a transient API rate-limit instead of
|
||||
# failing the whole walk (which would stamp RATE_LIMITED → platform-wide
|
||||
# cooldown → every Patreon source dark). Honor the server's `Retry-After`;
|
||||
# otherwise exponential base·2^(n-1), capped. Only after the retries are
|
||||
# exhausted does the 429 propagate as terminal RATE_LIMITED.
|
||||
_MAX_429_RETRIES = 3
|
||||
_BACKOFF_BASE_SECONDS = 2.0
|
||||
_BACKOFF_CAP_SECONDS = 30.0
|
||||
|
||||
|
||||
def _retry_after_seconds(
|
||||
resp: requests.Response,
|
||||
attempt: int,
|
||||
*,
|
||||
base: float = _BACKOFF_BASE_SECONDS,
|
||||
cap: float = _BACKOFF_CAP_SECONDS,
|
||||
) -> float:
|
||||
"""Backoff delay for a 429: the `Retry-After` seconds header if present and
|
||||
numeric, else exponential `base·2^(attempt-1)`, both capped. (HTTP-date form
|
||||
of Retry-After is rare here and falls through to exponential.)"""
|
||||
header = resp.headers.get("Retry-After")
|
||||
if header:
|
||||
try:
|
||||
return min(float(header), cap)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return min(base * (2 ** max(0, attempt - 1)), cap)
|
||||
|
||||
# JSON:API request contract (observed from real traffic — see module plan).
|
||||
_INCLUDE = (
|
||||
"campaign,access_rules,attachments,attachments_media,audio,images,media,"
|
||||
"native_video_insights,user,user_defined_tags,ti_checks"
|
||||
)
|
||||
_FIELDS_POST = (
|
||||
"content,post_file,image,post_type,published_at,title,url,patreon_url,"
|
||||
"current_user_can_view"
|
||||
)
|
||||
_FIELDS_MEDIA = "id,image_urls,download_url,metadata,file_name"
|
||||
_FIELDS_CAMPAIGN = "name,url"
|
||||
|
||||
# A CDN download URL embeds a 32-char hex (MD5) path segment; that segment is
|
||||
# Patreon's stable per-file identity and is what we dedup + ledger against.
|
||||
# Same role gallery-dl's _filehash plays. Match the FIRST 32-hex run anywhere
|
||||
# in the URL (path or query); real Patreon CDN URLs carry exactly one.
|
||||
_FILEHASH_RE = re.compile(r"([0-9a-fA-F]{32})")
|
||||
|
||||
# Inline post `content` is HTML; images are emitted as <img ... src="...">.
|
||||
# Pull every src; downstream dedup collapses any that duplicate a gallery item
|
||||
# by filehash. Tolerant of attribute ordering and single/double quotes.
|
||||
_CONTENT_IMG_RE = re.compile(r"<img\b[^>]*?\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE)
|
||||
|
||||
|
||||
class PatreonAPIError(Exception):
|
||||
"""Base for native Patreon client failures.
|
||||
|
||||
`status_code` carries the HTTP status when the failure was an HTTP response
|
||||
(None for transport-level / parse failures), so the ingester can map it to a
|
||||
DownloadResult.error_type (429 → rate_limited, 404 → not_found, …).
|
||||
`retry_after` carries the server's `Retry-After` seconds on a terminal 429, so
|
||||
the platform cooldown can match the server's hint instead of a flat default
|
||||
(plan #708 B1).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
retry_after: float | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class PatreonAuthError(PatreonAPIError):
|
||||
"""Authentication / authorization failure — missing or expired session
|
||||
cookies, an insufficient pledge tier, or an HTML login/challenge page served
|
||||
where JSON was expected. DISTINCT from drift: the fix is rotating the
|
||||
credential, not updating the ingester. Maps to error_type 'auth_error'.
|
||||
"""
|
||||
|
||||
|
||||
class PatreonDriftError(PatreonAPIError):
|
||||
"""A JSON response did not match the JSON:API shape we depend on.
|
||||
|
||||
Raised for: a missing top-level `data` list, `data` not a list, or a media
|
||||
resource lacking the `file_name`/`url` fields we resolve against. Fail loud
|
||||
so the import step flags API drift (ingester needs update) instead of
|
||||
silently importing nothing. An HTML-login / non-JSON body is auth, not
|
||||
drift — that raises PatreonAuthError.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaItem:
|
||||
"""One resolved downloadable item belonging to a post.
|
||||
|
||||
Fields:
|
||||
url — the CDN/download URL to fetch.
|
||||
filename — media `file_name` when present; otherwise the URL basename
|
||||
(NEVER a network call). Bounded/sane extension.
|
||||
kind — one of: "images", "image_large", "attachments", "postfile",
|
||||
"content". Mirrors gallery-dl's `files` content-type names so
|
||||
the later step can honor the same per-source content_types.
|
||||
filehash — the 32-char hex (MD5) segment from the CDN URL, or None if
|
||||
the URL carries no such segment. Used for in-post dedup and
|
||||
the cross-run seen-ledger.
|
||||
post_id — the owning post's id (so a flattened media list stays
|
||||
traceable to its post).
|
||||
"""
|
||||
|
||||
url: str
|
||||
filename: str
|
||||
kind: str
|
||||
filehash: str | None
|
||||
post_id: str
|
||||
|
||||
|
||||
def _load_session(cookies_path: str | Path | None) -> requests.Session:
|
||||
session = requests.Session()
|
||||
session.headers.update(
|
||||
{
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept": "application/vnd.api+json",
|
||||
}
|
||||
)
|
||||
if cookies_path and os.path.isfile(str(cookies_path)):
|
||||
try:
|
||||
jar = http.cookiejar.MozillaCookieJar(str(cookies_path))
|
||||
jar.load(ignore_discard=True, ignore_expires=True)
|
||||
session.cookies = jar # type: ignore[assignment]
|
||||
except (OSError, http.cookiejar.LoadError) as exc:
|
||||
log.warning("Could not load Patreon cookies from %s: %s", cookies_path, exc)
|
||||
return session
|
||||
|
||||
|
||||
def _filehash(url: str) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
match = _FILEHASH_RE.search(url)
|
||||
return match.group(1).lower() if match else None
|
||||
|
||||
|
||||
def _basename_from_url(url: str) -> str:
|
||||
"""Derive a sane filename from a URL when the media has no file_name.
|
||||
|
||||
Strips query/fragment, takes the path basename, and drops a junk
|
||||
extension (the importer._safe_ext gotcha) so we never write base64 noise
|
||||
as a name. Falls back to the filehash, then to "file".
|
||||
"""
|
||||
path = urlsplit(url).path
|
||||
base = os.path.basename(path)
|
||||
if base:
|
||||
ext = safe_ext(base)
|
||||
stem = base[: -len(Path(base).suffix)] if Path(base).suffix else base
|
||||
# Keep the stem bounded; URL-encoded stems can be enormous.
|
||||
stem = stem[:120] or "file"
|
||||
return f"{stem}{ext}"
|
||||
fh = _filehash(url)
|
||||
return fh or "file"
|
||||
|
||||
|
||||
def parse_cursor_from_url(url: str | None) -> str | None:
|
||||
"""Extract the `page[cursor]` query param from a links.next URL."""
|
||||
if not url:
|
||||
return None
|
||||
query = urlsplit(url).query
|
||||
values = parse_qs(query).get("page[cursor]")
|
||||
if values and values[0]:
|
||||
return values[0]
|
||||
return None
|
||||
|
||||
|
||||
class PatreonClient:
|
||||
"""Synchronous Patreon JSON:API read client.
|
||||
|
||||
Construct with a path to a Netscape cookies.txt (the same file
|
||||
CredentialService.get_cookies_path materializes). Cookies are loaded into a
|
||||
requests.Session; no secure-context APIs are used.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cookies_path: str | Path | None,
|
||||
*,
|
||||
request_sleep: float = 0.0,
|
||||
max_retries: int = _MAX_429_RETRIES,
|
||||
):
|
||||
self.cookies_path = str(cookies_path) if cookies_path else None
|
||||
self._session = _load_session(cookies_path)
|
||||
# Politeness: seconds to sleep before each /api/posts page fetch (paces
|
||||
# the rate-limited API endpoint). 0 = no pacing. plan #703.
|
||||
self._request_sleep = request_sleep or 0.0
|
||||
self._max_retries = max_retries
|
||||
|
||||
# -- request -----------------------------------------------------------
|
||||
|
||||
def _params(self, campaign_id: str, cursor: str | None) -> dict[str, str]:
|
||||
params = {
|
||||
"include": _INCLUDE,
|
||||
"fields[post]": _FIELDS_POST,
|
||||
"fields[media]": _FIELDS_MEDIA,
|
||||
"fields[campaign]": _FIELDS_CAMPAIGN,
|
||||
"filter[campaign_id]": campaign_id,
|
||||
"filter[contains_exclusive_posts]": "true",
|
||||
"filter[is_draft]": "false",
|
||||
"sort": "-published_at",
|
||||
"json-api-version": "1.0",
|
||||
}
|
||||
if cursor:
|
||||
params["page[cursor]"] = cursor
|
||||
return params
|
||||
|
||||
def _fetch(self, campaign_id: str, cursor: str | None) -> dict:
|
||||
if self._request_sleep > 0:
|
||||
time.sleep(self._request_sleep) # pace the API endpoint
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
resp = self._session.get(
|
||||
_POSTS_URL,
|
||||
params=self._params(campaign_id, cursor),
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise PatreonAPIError(
|
||||
f"Patreon posts request failed (campaign_id={campaign_id}): {exc}"
|
||||
) from exc
|
||||
|
||||
# Transient rate-limit: back off and retry rather than failing the
|
||||
# whole walk. Only a PERSISTENT 429 (retries exhausted) falls
|
||||
# through to the terminal RATE_LIMITED raise below.
|
||||
if resp.status_code == 429 and attempt < self._max_retries:
|
||||
attempt += 1
|
||||
delay = _retry_after_seconds(resp, attempt)
|
||||
log.warning(
|
||||
"Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)",
|
||||
campaign_id, delay, attempt, self._max_retries,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
break
|
||||
|
||||
if resp.status_code in (401, 403):
|
||||
# Auth rejected — expired/missing cookies or an insufficient tier.
|
||||
# Actionable as "rotate credentials", so it's auth, not drift/http.
|
||||
raise PatreonAuthError(
|
||||
f"Patreon posts API returned HTTP {resp.status_code} — auth "
|
||||
f"rejected (cookies expired or tier insufficient; "
|
||||
f"campaign_id={campaign_id})",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
# A persistent 429 (retries exhausted) is terminal RATE_LIMITED — carry
|
||||
# the server's raw Retry-After seconds so the cooldown matches its hint
|
||||
# (plan #708 B1). Header is uncapped here; the cooldown clamps it.
|
||||
retry_after = None
|
||||
if resp.status_code == 429:
|
||||
hdr = resp.headers.get("Retry-After")
|
||||
if hdr:
|
||||
try:
|
||||
retry_after = float(hdr)
|
||||
except (TypeError, ValueError):
|
||||
retry_after = None
|
||||
raise PatreonAPIError(
|
||||
f"Patreon posts API returned HTTP {resp.status_code} "
|
||||
f"(campaign_id={campaign_id})",
|
||||
status_code=resp.status_code,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
# A non-JSON body here is almost always the HTML login/challenge
|
||||
# page served when cookies are missing/expired — that is an AUTH
|
||||
# failure (rotate cookies), not API drift (update the ingester) and
|
||||
# not a transient network error.
|
||||
raise PatreonAuthError(
|
||||
"Patreon posts API returned a non-JSON response (likely an "
|
||||
f"HTML login/challenge page — session expired; "
|
||||
f"campaign_id={campaign_id}): {exc}"
|
||||
) from exc
|
||||
return payload
|
||||
|
||||
# -- parsing -----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _transform(response: dict) -> dict:
|
||||
"""Flatten the JSON:API `included` array for relationship resolution.
|
||||
|
||||
Returns a dict keyed by `(type, id)` → that resource's `attributes`
|
||||
(so a post's relationships can be resolved in O(1)). Missing/oddly
|
||||
shaped `included` entries are skipped rather than fatal — drift
|
||||
detection for the top-level shape lives in _validate_response.
|
||||
"""
|
||||
index: dict[tuple[str, str], dict] = {}
|
||||
for inc in response.get("included") or []:
|
||||
if not isinstance(inc, dict):
|
||||
continue
|
||||
rtype = inc.get("type")
|
||||
rid = inc.get("id")
|
||||
if rtype is None or rid is None:
|
||||
continue
|
||||
index[(str(rtype), str(rid))] = inc.get("attributes") or {}
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def _validate_response(response: dict) -> None:
|
||||
if not isinstance(response, dict):
|
||||
raise PatreonDriftError("Patreon response was not a JSON object")
|
||||
if "data" not in response:
|
||||
raise PatreonDriftError("Patreon response missing top-level 'data' key")
|
||||
data = response.get("data")
|
||||
if not isinstance(data, list):
|
||||
raise PatreonDriftError("Patreon response 'data' was not a list")
|
||||
|
||||
def _related_ids(self, post: dict, rel_name: str) -> list[str]:
|
||||
rels = post.get("relationships") or {}
|
||||
rel = rels.get(rel_name) or {}
|
||||
data = rel.get("data")
|
||||
if isinstance(data, dict): # to-one relationship
|
||||
data = [data]
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
ids: list[str] = []
|
||||
for ref in data:
|
||||
if isinstance(ref, dict) and ref.get("id") is not None:
|
||||
ids.append(str(ref["id"]))
|
||||
return ids
|
||||
|
||||
@staticmethod
|
||||
def _media_url(attrs: dict) -> str | None:
|
||||
"""Pick the best fetchable URL for a media resource.
|
||||
|
||||
Prefer the full-size `download_url`; fall back to the largest
|
||||
`image_urls` size. gallery-dl prefers download_url too, only dipping
|
||||
into image_urls when a smaller configured size is requested — FC
|
||||
always wants the original, so download_url first.
|
||||
"""
|
||||
download_url = attrs.get("download_url")
|
||||
if isinstance(download_url, str) and download_url:
|
||||
return download_url
|
||||
image_urls = attrs.get("image_urls")
|
||||
if isinstance(image_urls, dict):
|
||||
for key in ("original", "full", "large", "default"):
|
||||
candidate = image_urls.get(key)
|
||||
if isinstance(candidate, str) and candidate:
|
||||
return candidate
|
||||
# Otherwise take any non-empty string value.
|
||||
for candidate in image_urls.values():
|
||||
if isinstance(candidate, str) and candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _media_item(self, attrs: dict, kind: str, post_id: str) -> MediaItem:
|
||||
url = self._media_url(attrs)
|
||||
if not url:
|
||||
raise PatreonDriftError(
|
||||
f"Patreon media (post {post_id}, kind={kind}) had no resolvable URL "
|
||||
f"(no download_url / image_urls)"
|
||||
)
|
||||
# file_name is OPTIONAL: Patreon legitimately serves some gallery images
|
||||
# without it (operator-flagged 2026-06-07, BlenderKnight post 73665615),
|
||||
# and the URL basename is a fine fallback — the same thing gallery-dl
|
||||
# uses. A genuine schema change shows up as no URL (above) or a media id
|
||||
# absent from `included` (caller), not a missing name.
|
||||
file_name = attrs.get("file_name")
|
||||
filename = file_name if isinstance(file_name, str) and file_name else _basename_from_url(url)
|
||||
return MediaItem(
|
||||
url=url,
|
||||
filename=filename,
|
||||
kind=kind,
|
||||
filehash=_filehash(url),
|
||||
post_id=post_id,
|
||||
)
|
||||
|
||||
def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]:
|
||||
"""Resolve all downloadable media for one post.
|
||||
|
||||
Walks the kinds in the same order gallery-dl does — images,
|
||||
image_large (post cover), attachments, postfile, content (inline
|
||||
<img>) — and dedups within the post by filehash (first wins). The
|
||||
image_large cover commonly duplicates a gallery image; deduping by
|
||||
filehash collapses them to the gallery item (encountered first).
|
||||
"""
|
||||
post_id = str(post.get("id") or "")
|
||||
attrs = post.get("attributes") or {}
|
||||
items: list[MediaItem] = []
|
||||
|
||||
def _resolve_rel(rel_name: str, kind: str) -> None:
|
||||
for mid in self._related_ids(post, rel_name):
|
||||
media_attrs = included_index.get(("media", mid))
|
||||
if media_attrs is None:
|
||||
# Referenced but not in `included`: a media id with no
|
||||
# resource is drift (we asked for include=media).
|
||||
raise PatreonDriftError(
|
||||
f"Patreon post {post_id} references media {mid} "
|
||||
f"({rel_name}) not present in 'included'"
|
||||
)
|
||||
items.append(self._media_item(media_attrs, kind, post_id))
|
||||
|
||||
# 1. gallery images
|
||||
_resolve_rel("images", "images")
|
||||
|
||||
# 2. image_large — the post-level cover (`image.large_url`). Not a
|
||||
# media relationship; it lives on the post attributes.
|
||||
image = attrs.get("image")
|
||||
if isinstance(image, dict):
|
||||
large_url = image.get("large_url") or image.get("url")
|
||||
if isinstance(large_url, str) and large_url:
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=large_url,
|
||||
filename=_basename_from_url(large_url),
|
||||
kind="image_large",
|
||||
filehash=_filehash(large_url),
|
||||
post_id=post_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 3. attachments
|
||||
_resolve_rel("attachments_media", "attachments")
|
||||
|
||||
# 4. postfile — the post's primary attached file (`post_file`).
|
||||
post_file = attrs.get("post_file")
|
||||
if isinstance(post_file, dict):
|
||||
pf_url = post_file.get("url") or post_file.get("download_url")
|
||||
if isinstance(pf_url, str) and pf_url:
|
||||
pf_name = post_file.get("name")
|
||||
filename = (
|
||||
pf_name
|
||||
if isinstance(pf_name, str) and pf_name
|
||||
else _basename_from_url(pf_url)
|
||||
)
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=pf_url,
|
||||
filename=filename,
|
||||
kind="postfile",
|
||||
filehash=_filehash(pf_url),
|
||||
post_id=post_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. content — inline <img> in the post HTML body.
|
||||
content = attrs.get("content")
|
||||
if isinstance(content, str) and content:
|
||||
for raw_src in _CONTENT_IMG_RE.findall(content):
|
||||
src = unescape(raw_src)
|
||||
if not src:
|
||||
continue
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=src,
|
||||
filename=_basename_from_url(src),
|
||||
kind="content",
|
||||
filehash=_filehash(src),
|
||||
post_id=post_id,
|
||||
)
|
||||
)
|
||||
|
||||
return _dedup_by_filehash(items)
|
||||
|
||||
@staticmethod
|
||||
def post_meta(post: dict) -> dict:
|
||||
"""Title + published date for a post — for the preview sample (plan #708
|
||||
B4). Part of the client contract `ingest_core.Ingester.preview` calls."""
|
||||
attrs = post.get("attributes") or {}
|
||||
title = attrs.get("title")
|
||||
published = attrs.get("published_at")
|
||||
return {
|
||||
"title": title if isinstance(title, str) else None,
|
||||
"date": published if isinstance(published, str) else None,
|
||||
}
|
||||
|
||||
# -- iteration ---------------------------------------------------------
|
||||
|
||||
def iter_posts(
|
||||
self, campaign_id: str, cursor: str | None = None
|
||||
) -> Iterator[tuple[dict, dict, str | None]]:
|
||||
"""Yield (post, included_index, page_cursor) for every post in the feed.
|
||||
|
||||
Pages newest→oldest via `links.next`, validating each response for
|
||||
drift before yielding. The triple gives a caller everything it needs to
|
||||
resolve and checkpoint without re-fetching:
|
||||
- post — the raw post resource.
|
||||
- included_index — the page's flattened `included` (the same object
|
||||
for every post on a page), to pass straight to
|
||||
extract_media(post, included_index).
|
||||
- page_cursor — the cursor that FETCHED this post's page (None for
|
||||
the first page). The caller checkpoints THIS value,
|
||||
matching the existing backfill cursor logic where
|
||||
the saved cursor re-fetches the page being
|
||||
processed (so a chunk cut mid-page resumes the page,
|
||||
not the one after it).
|
||||
"""
|
||||
current_cursor = cursor
|
||||
while True:
|
||||
response = self._fetch(campaign_id, current_cursor)
|
||||
self._validate_response(response)
|
||||
page_cursor = current_cursor
|
||||
included_index = self._transform(response)
|
||||
for post in response.get("data") or []:
|
||||
if isinstance(post, dict):
|
||||
yield post, included_index, page_cursor
|
||||
next_url = (response.get("links") or {}).get("next")
|
||||
next_cursor = parse_cursor_from_url(next_url)
|
||||
if not next_cursor:
|
||||
return
|
||||
current_cursor = next_cursor
|
||||
|
||||
# -- verify ------------------------------------------------------------
|
||||
|
||||
def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]:
|
||||
"""Cheap auth probe: fetch the first `/api/posts` page and report whether
|
||||
the credential authenticated, WITHOUT downloading anything.
|
||||
|
||||
Returns `(ok, message)` matching the credential-verify contract:
|
||||
- True — authenticated (the feed returned a valid JSON:API page).
|
||||
- False — the credential was rejected (PatreonAuthError: 401/403, or an
|
||||
HTML login page → cookies expired / tier insufficient).
|
||||
- None — inconclusive: API drift (our parser is stale, not a cred
|
||||
problem) or a transient network/HTTP error.
|
||||
"""
|
||||
try:
|
||||
response = self._fetch(campaign_id, None)
|
||||
self._validate_response(response)
|
||||
except PatreonAuthError as exc:
|
||||
return False, f"Patreon rejected the credential — {exc}"
|
||||
except PatreonDriftError as exc:
|
||||
return None, f"Couldn't verify — Patreon's API shape changed: {exc}"
|
||||
except PatreonAPIError as exc:
|
||||
return None, f"Couldn't verify (network/HTTP issue): {exc}"
|
||||
return True, "Credentials valid — the Patreon feed authenticated."
|
||||
|
||||
|
||||
def _dedup_by_filehash(items: list[MediaItem]) -> list[MediaItem]:
|
||||
"""Drop later items sharing a filehash with an earlier one (first wins).
|
||||
|
||||
Items with no filehash (None) are never deduped against each other — we
|
||||
can't prove they're the same file, so keep them all.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
out: list[MediaItem] = []
|
||||
for item in items:
|
||||
if item.filehash is not None:
|
||||
if item.filehash in seen:
|
||||
continue
|
||||
seen.add(item.filehash)
|
||||
out.append(item)
|
||||
return out
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Native Patreon media downloader (build step 2b of the native ingester).
|
||||
|
||||
Given a Patreon post and its already-resolved `MediaItem`s (from
|
||||
patreon_client.extract_media), download the media to the EXACT on-disk layout
|
||||
gallery-dl produces, write a sidecar JSON the existing importer consumes, and
|
||||
report per-media outcomes.
|
||||
|
||||
This module is PURE: no DB. The cross-run seen-ledger and the iter_posts
|
||||
orchestration are a LATER step. The tier-1 (seen) skip is an INJECTED predicate
|
||||
(`is_seen`), so this module needs no DB and is unit-testable without network.
|
||||
|
||||
On-disk layout (matches gallery-dl):
|
||||
<images_root>/<artist_slug>/patreon/<DIR>/<NN>_<filename>
|
||||
where <DIR> = "<YYYY-MM-DD>_<post_id>_<title40>" (date prefix omitted when the
|
||||
post's published_at is unparseable), <NN> is the 1-based index of the item in
|
||||
the post zero-padded to 2, and <filename> is MediaItem.filename. The sidecar
|
||||
is written next to the media as <NN>_<stem>.json (media_path.with_suffix).
|
||||
|
||||
Video: a MediaItem whose URL is a Mux/HLS stream (host stream.mux.com or path
|
||||
endswith .m3u8) is fetched with yt-dlp (subprocess), passing the same
|
||||
Referer/Origin headers gallery-dl forwards for Mux playback (see gallery_dl.py
|
||||
_get_default_config). yt-dlp may remux to a container of its own choosing, so we
|
||||
accept the actual output extension and record the real path.
|
||||
|
||||
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
from .file_validator import is_validatable, quarantine_file, validate_file
|
||||
from .patreon_client import (
|
||||
_BACKOFF_CAP_SECONDS,
|
||||
_load_session,
|
||||
_retry_after_seconds,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TITLE_MAX = 40
|
||||
_TIMEOUT_SECONDS = 120.0
|
||||
_CHUNK = 1 << 16
|
||||
# Retry a media GET that hits a TRANSIENT failure within the same pass (plan
|
||||
# #705 #8): a transport blip (connection reset / timeout / truncated stream), a
|
||||
# 429, or a 5xx. PERMANENT failures (404 gone, 403 forbidden) fail fast straight
|
||||
# to the error/dead-letter path — no point re-fetching them. Keeps a momentary
|
||||
# network hiccup from becoming a per-item error that waits for the next walk.
|
||||
_MAX_MEDIA_RETRIES = 3
|
||||
# requests transport errors worth retrying (vs. an HTTPError, which is a real
|
||||
# server response and is classified by status code).
|
||||
_TRANSIENT_TRANSPORT_EXC = (
|
||||
requests.ConnectionError,
|
||||
requests.Timeout,
|
||||
requests.exceptions.ChunkedEncodingError,
|
||||
)
|
||||
|
||||
# Referer/Origin yt-dlp must send for Mux-hosted Patreon video. Mux's JWT
|
||||
# playback policy checks Referer/Origin on every request, so yt-dlp must send
|
||||
# Patreon's, not its own default. (gallery-dl forwarded the same headers before
|
||||
# the #697 cutover removed its Patreon path; this is now the only place they
|
||||
# live.)
|
||||
_VIDEO_HEADERS = {
|
||||
"Referer": "https://www.patreon.com/",
|
||||
"Origin": "https://www.patreon.com",
|
||||
}
|
||||
|
||||
# Characters Windows/gallery-dl path-restrict forbids, plus path separators.
|
||||
_FORBIDDEN = set('<>:"/\\|?*')
|
||||
|
||||
|
||||
def _sanitize(name: str) -> str:
|
||||
"""Make `name` safe for a single filesystem path segment.
|
||||
|
||||
Replaces path separators, the Windows-forbidden set <>:"/\\|?* and control
|
||||
characters with `_`, then strips trailing dots/spaces (gallery-dl
|
||||
path-restrict behavior). Never returns empty (falls back to "_").
|
||||
"""
|
||||
out = []
|
||||
for ch in name:
|
||||
if ch in _FORBIDDEN or ord(ch) < 32:
|
||||
out.append("_")
|
||||
else:
|
||||
out.append(ch)
|
||||
cleaned = "".join(out).rstrip(". ")
|
||||
return cleaned or "_"
|
||||
|
||||
|
||||
def _is_video_url(url: str) -> bool:
|
||||
parts = urlsplit(url)
|
||||
if parts.hostname and parts.hostname.lower() == "stream.mux.com":
|
||||
return True
|
||||
return parts.path.lower().endswith(".m3u8")
|
||||
|
||||
|
||||
def _post_dir_name(post: dict) -> str:
|
||||
"""Build the post directory name matching gallery-dl's layout."""
|
||||
post_id = str(post.get("id") or "")
|
||||
attrs = post.get("attributes") or {}
|
||||
title = attrs.get("title")
|
||||
title = title if isinstance(title, str) else ""
|
||||
title40 = title[:_TITLE_MAX]
|
||||
|
||||
published = attrs.get("published_at")
|
||||
date_prefix = None
|
||||
if isinstance(published, str) and published:
|
||||
s = published.strip()
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
dt = None
|
||||
if dt is not None:
|
||||
date_prefix = f"{dt:%Y-%m-%d}"
|
||||
|
||||
if date_prefix:
|
||||
raw = f"{date_prefix}_{post_id}_{title40}"
|
||||
else:
|
||||
raw = f"{post_id}_{title40}"
|
||||
return _sanitize(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaOutcome:
|
||||
"""Per-media result of a download_post pass.
|
||||
|
||||
status is one of: "downloaded", "skipped_seen", "skipped_disk",
|
||||
"quarantined", "error". `path` is the final on-disk path for "downloaded"
|
||||
(the actual yt-dlp output for video), the path that already existed for
|
||||
"skipped_disk", or the _quarantine destination for "quarantined"; None for
|
||||
"skipped_seen" and (usually) "error". `error` carries the failure/validation
|
||||
reason for "error"/"quarantined", else None.
|
||||
"""
|
||||
|
||||
media: object # MediaItem (avoid importing the name for a bare annotation)
|
||||
status: str
|
||||
path: Path | None
|
||||
error: str | None
|
||||
|
||||
|
||||
class PatreonDownloader:
|
||||
"""Download resolved Patreon media to gallery-dl's on-disk layout.
|
||||
|
||||
PURE: no DB. The HTTP session and the yt-dlp invocation are injectable seams
|
||||
so tests run without network or a real subprocess:
|
||||
- pass `session=` to stub `session.get`, or monkeypatch `_fetch_to_file`.
|
||||
- monkeypatch `_run_ytdlp` to avoid spawning yt-dlp.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
images_root: Path,
|
||||
cookies_path: str | None = None,
|
||||
*,
|
||||
validate: bool = True,
|
||||
rate_limit: float = 0.0,
|
||||
session: requests.Session | None = None,
|
||||
):
|
||||
self.images_root = Path(images_root)
|
||||
self.cookies_path = str(cookies_path) if cookies_path else None
|
||||
self._validate = validate
|
||||
# Politeness: seconds to sleep before each actual media download (paces
|
||||
# the CDN; honors ImportSettings.download_rate_limit_seconds, the same
|
||||
# value gallery-dl used as its between-downloads `sleep`). 0 = no pacing.
|
||||
# Applied only to real downloads, not to seen/disk skips. plan #703.
|
||||
self._rate_limit = rate_limit or 0.0
|
||||
# Build a cookie-loaded session the same way patreon_client does, so the
|
||||
# CDN GETs carry the creator's auth.
|
||||
self.session = session if session is not None else _load_session(cookies_path)
|
||||
|
||||
# -- public ------------------------------------------------------------
|
||||
|
||||
def download_post(
|
||||
self,
|
||||
post: dict,
|
||||
media_items: list,
|
||||
artist_slug: str,
|
||||
*,
|
||||
is_seen: Callable[[object], bool] = lambda m: False,
|
||||
should_stop: Callable[[], bool] = lambda: False,
|
||||
) -> list[MediaOutcome]:
|
||||
"""Download every media item of one post; return per-item outcomes.
|
||||
|
||||
Builds the post directory, iterates media (1-based NN), applies the
|
||||
two-tier skip (injected is_seen, then disk), downloads (plain GET or
|
||||
yt-dlp for video), writes the sidecar for each freshly-downloaded item,
|
||||
validates, and returns outcomes. Resilient: one media's failure yields
|
||||
an "error" outcome for that item; the rest proceed.
|
||||
|
||||
`should_stop()` is polled BEFORE each media item: a media-dense post can
|
||||
otherwise run a backfill chunk far past its time-box (the engine only
|
||||
re-checks the budget between posts), so we honour the deadline mid-post
|
||||
and return the items done so far — the rest re-fetch next chunk (they
|
||||
were never marked seen). Bounds chunk overrun to one media download.
|
||||
"""
|
||||
post_dir = self.images_root / artist_slug / "patreon" / _post_dir_name(post)
|
||||
outcomes: list[MediaOutcome] = []
|
||||
|
||||
for i, media in enumerate(media_items, start=1):
|
||||
if should_stop():
|
||||
break
|
||||
try:
|
||||
outcomes.append(
|
||||
self._download_one(post, media, post_dir, artist_slug, i, is_seen)
|
||||
)
|
||||
except Exception as exc: # resilient: isolate one item's failure
|
||||
log.warning(
|
||||
"Patreon media failed (post %s, item %d): %s",
|
||||
post.get("id"), i, exc,
|
||||
)
|
||||
outcomes.append(
|
||||
MediaOutcome(media=media, status="error", path=None, error=str(exc))
|
||||
)
|
||||
return outcomes
|
||||
|
||||
# -- per-item ----------------------------------------------------------
|
||||
|
||||
def _download_one(
|
||||
self,
|
||||
post: dict,
|
||||
media,
|
||||
post_dir: Path,
|
||||
artist_slug: str,
|
||||
index: int,
|
||||
is_seen: Callable[[object], bool],
|
||||
) -> MediaOutcome:
|
||||
# tier-1: seen ledger (injected; no DB here).
|
||||
if is_seen(media):
|
||||
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
|
||||
|
||||
nn = f"{index:02d}"
|
||||
final_name = _sanitize(f"{nn}_{media.filename}")
|
||||
media_path = post_dir / final_name
|
||||
|
||||
# tier-2: already on disk.
|
||||
if media_path.exists():
|
||||
return MediaOutcome(
|
||||
media=media, status="skipped_disk", path=media_path, error=None
|
||||
)
|
||||
# Video may land at a different extension; honor a pre-existing remux.
|
||||
if _is_video_url(media.url):
|
||||
existing = self._existing_video_output(media_path)
|
||||
if existing is not None:
|
||||
return MediaOutcome(
|
||||
media=media, status="skipped_disk", path=existing, error=None
|
||||
)
|
||||
|
||||
post_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pace real downloads only (the skips above already returned). plan #703.
|
||||
if self._rate_limit > 0:
|
||||
time.sleep(self._rate_limit)
|
||||
|
||||
if _is_video_url(media.url):
|
||||
out_path = self._run_ytdlp(media.url, media_path, _VIDEO_HEADERS)
|
||||
if out_path is None or not Path(out_path).exists():
|
||||
return MediaOutcome(
|
||||
media=media,
|
||||
status="error",
|
||||
path=None,
|
||||
error="yt-dlp produced no output",
|
||||
)
|
||||
out_path = Path(out_path)
|
||||
else:
|
||||
out_path = self._fetch_get(media.url, media_path)
|
||||
reason, quarantine_dest = self._validate_path(
|
||||
out_path, artist_slug, media.url
|
||||
)
|
||||
if reason is not None:
|
||||
# Quarantined (corrupt/invalid) — distinct from a download error
|
||||
# so the run can report a real files_quarantined count + paths.
|
||||
return MediaOutcome(
|
||||
media=media, status="quarantined",
|
||||
path=quarantine_dest, error=reason,
|
||||
)
|
||||
|
||||
self._write_sidecar(post, out_path)
|
||||
return MediaOutcome(media=media, status="downloaded", path=out_path, error=None)
|
||||
|
||||
# -- download seams ----------------------------------------------------
|
||||
|
||||
def _fetch_get(self, url: str, dest: Path) -> Path:
|
||||
"""Stream `url` to a .part file then atomic-rename to `dest`.
|
||||
|
||||
Thin wrapper over `_fetch_to_file` so tests can stub either the whole
|
||||
GET path (`_fetch_to_file`) or just `session.get`.
|
||||
"""
|
||||
part = dest.with_name(dest.name + ".part")
|
||||
try:
|
||||
self._fetch_to_file(url, part)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
part.unlink()
|
||||
raise
|
||||
os.replace(part, dest)
|
||||
return dest
|
||||
|
||||
def _fetch_to_file(self, url: str, dest: Path) -> None:
|
||||
"""Stream a non-video URL to `dest` via the (stubbable) session, retrying
|
||||
TRANSIENT failures within the same pass (plan #705 #8) and RESUMING from
|
||||
the bytes already on disk via a Range request when a retry follows a
|
||||
mid-download cut (plan #708 B5).
|
||||
|
||||
Retried (backoff): transport blips (connection reset / timeout /
|
||||
truncated stream — incl. mid-download), HTTP 429 (honoring Retry-After),
|
||||
and 5xx. Failed fast (no retry → HTTPError → per-item error → dead-letter
|
||||
path): 4xx other than 429 (404 gone, 403 forbidden) — re-fetching a
|
||||
permanent failure is pointless.
|
||||
|
||||
Resume: on a retry, if bytes already landed in `dest`, ask for the rest
|
||||
with `Range: bytes=<have>-`. A 206 means the server honored it → append; a
|
||||
200 means it ignored it (served the whole file) → start clean. The caller
|
||||
(_fetch_get) stages into a `.part`, so a non-range server never corrupts
|
||||
the output — the worst case is re-downloading from zero, as before.
|
||||
"""
|
||||
attempt = 0
|
||||
while True:
|
||||
have = dest.stat().st_size if dest.exists() else 0
|
||||
headers = {"Range": f"bytes={have}-"} if have > 0 else None
|
||||
try:
|
||||
resp = self.session.get(
|
||||
url, stream=True, timeout=_TIMEOUT_SECONDS, headers=headers,
|
||||
)
|
||||
if (resp.status_code == 429 or resp.status_code >= 500) \
|
||||
and attempt < _MAX_MEDIA_RETRIES:
|
||||
attempt += 1
|
||||
delay = _retry_after_seconds(resp, attempt)
|
||||
log.warning(
|
||||
"Patreon media transient HTTP %d (%s) — backing off "
|
||||
"%.1fs (retry %d/%d)",
|
||||
resp.status_code, url, delay, attempt, _MAX_MEDIA_RETRIES,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
# A Range that starts at/past EOF (we already have the whole file)
|
||||
# comes back 416 — the bytes we kept ARE the file.
|
||||
if have > 0 and resp.status_code == 416:
|
||||
return
|
||||
# 2xx → ok; 4xx-non-429 (or an exhausted 429/5xx) → HTTPError
|
||||
# (permanent for this pass) → not caught below → per-item error.
|
||||
resp.raise_for_status()
|
||||
# 206 → server honored the Range; append after the kept bytes.
|
||||
# Anything else (200) → it served the whole file → start clean.
|
||||
mode = "ab" if (have > 0 and resp.status_code == 206) else "wb"
|
||||
with open(dest, mode) as fh:
|
||||
for chunk in resp.iter_content(chunk_size=_CHUNK):
|
||||
if chunk:
|
||||
fh.write(chunk)
|
||||
return
|
||||
except _TRANSIENT_TRANSPORT_EXC as exc:
|
||||
if attempt >= _MAX_MEDIA_RETRIES:
|
||||
raise # exhausted → terminal error outcome
|
||||
attempt += 1
|
||||
delay = min(2.0 * (2 ** (attempt - 1)), _BACKOFF_CAP_SECONDS)
|
||||
log.warning(
|
||||
"Patreon media transport error (%s) — backing off %.1fs "
|
||||
"(retry %d/%d): %s",
|
||||
url, delay, attempt, _MAX_MEDIA_RETRIES, exc,
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
def _run_ytdlp(self, url: str, dest: Path, headers: dict) -> Path | None:
|
||||
"""Invoke yt-dlp to fetch a Mux/HLS stream to (around) `dest`.
|
||||
|
||||
Returns the actual output path (yt-dlp may remux to a different
|
||||
container, so we resolve the real file afterward). Overridden/
|
||||
monkeypatched in tests to avoid spawning a real subprocess.
|
||||
|
||||
The output template uses `dest` without its extension; yt-dlp appends
|
||||
the chosen container extension. We pass Referer/Origin (Mux JWT policy)
|
||||
and the cookies file.
|
||||
|
||||
Mirrors the GET path's transient/permanent split (plan #705 #8): a hung
|
||||
fetch (TimeoutExpired) or a spawn failure (OSError) is TRANSIENT — back
|
||||
off and retry. A non-zero yt-dlp exit (CalledProcessError) is treated as
|
||||
PERMANENT for this pass — yt-dlp already does its OWN internal network
|
||||
retries, so a non-zero exit is effectively a real failure (private/gone/
|
||||
geo-blocked), like a 4xx on the GET path: fail fast to the per-item error
|
||||
→ dead-letter path.
|
||||
"""
|
||||
dest = Path(dest)
|
||||
out_template = str(dest.with_suffix("")) + ".%(ext)s"
|
||||
cmd = ["yt-dlp", "--no-progress", "-o", out_template]
|
||||
for key, value in headers.items():
|
||||
cmd += ["--add-header", f"{key}:{value}"]
|
||||
if self.cookies_path and os.path.isfile(self.cookies_path):
|
||||
cmd += ["--cookies", self.cookies_path]
|
||||
cmd.append(url)
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
break
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# Permanent for this pass — fail fast (no retry).
|
||||
log.warning(
|
||||
"yt-dlp failed (exit %s) for %s: %s",
|
||||
exc.returncode, url, (exc.stderr or "").strip() or exc,
|
||||
)
|
||||
return None
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
# Transient — back off and retry, like a transport blip on a GET.
|
||||
if attempt >= _MAX_MEDIA_RETRIES:
|
||||
log.warning(
|
||||
"yt-dlp transient failure exhausted for %s: %s", url, exc
|
||||
)
|
||||
return None
|
||||
attempt += 1
|
||||
delay = min(2.0 * (2 ** (attempt - 1)), _BACKOFF_CAP_SECONDS)
|
||||
log.warning(
|
||||
"yt-dlp transient failure (%s) — backing off %.1fs "
|
||||
"(retry %d/%d): %s",
|
||||
url, delay, attempt, _MAX_MEDIA_RETRIES, exc,
|
||||
)
|
||||
time.sleep(delay)
|
||||
return self._existing_video_output(dest)
|
||||
|
||||
def _existing_video_output(self, dest: Path) -> Path | None:
|
||||
"""Find a yt-dlp output for `dest` regardless of chosen extension.
|
||||
|
||||
Returns `dest` itself if present, else any sibling sharing the same
|
||||
stem (the remuxed container). None if nothing matched.
|
||||
"""
|
||||
dest = Path(dest)
|
||||
if dest.exists():
|
||||
return dest
|
||||
stem = dest.with_suffix("").name
|
||||
parent = dest.parent
|
||||
if not parent.is_dir():
|
||||
return None
|
||||
for cand in sorted(parent.iterdir()):
|
||||
if cand.is_file() and cand.stem == stem and cand.name != dest.name:
|
||||
return cand
|
||||
return None
|
||||
|
||||
# -- validation --------------------------------------------------------
|
||||
|
||||
def _validate_path(
|
||||
self, path: Path, artist_slug: str, source_url: str | None = None
|
||||
) -> tuple[str | None, Path | None]:
|
||||
"""Validate a freshly-written file; quarantine if bad.
|
||||
|
||||
Uses the shared `file_validator.quarantine_file` — same move + provenance
|
||||
sidecar gallery-dl writes (the native path used to skip the sidecar; that
|
||||
parity gap is closed here). Returns `(reason, quarantine_dest)` when
|
||||
quarantined (dest is the original path if the move itself failed), else
|
||||
`(None, None)` (ok / not validatable / disabled). plan #704: the dest is
|
||||
surfaced so the run reports a real quarantined-paths list.
|
||||
"""
|
||||
if not self._validate or not is_validatable(path):
|
||||
return None, None
|
||||
try:
|
||||
result = validate_file(path)
|
||||
except Exception as exc:
|
||||
log.warning("Validator raised on %s: %s", path, exc)
|
||||
return None, None
|
||||
if result.ok:
|
||||
return None, None
|
||||
dest = quarantine_file(
|
||||
self.images_root, path, artist_slug, "patreon",
|
||||
url=source_url, result=result,
|
||||
)
|
||||
return (result.reason or "validation failed"), (dest or path)
|
||||
|
||||
# -- sidecar -----------------------------------------------------------
|
||||
|
||||
def _write_sidecar(self, post: dict, media_path: Path) -> Path:
|
||||
"""Write the importer-consumed sidecar next to `media_path`.
|
||||
|
||||
Patreon uses base-default sidecar keys (parse_sidecar maps
|
||||
category->platform, id->external_post_id, title->post_title,
|
||||
content->description, published_at->post_date, url->post_url). Patreon
|
||||
registers no derive_post_url, so `url` is trusted as the permalink — we
|
||||
pass the post's attributes.url.
|
||||
"""
|
||||
attrs = post.get("attributes") or {}
|
||||
title = attrs.get("title")
|
||||
content = attrs.get("content")
|
||||
published = attrs.get("published_at")
|
||||
url = attrs.get("url")
|
||||
data = {
|
||||
"category": "patreon",
|
||||
"id": str(post.get("id") or ""),
|
||||
"title": title if isinstance(title, str) else "",
|
||||
"content": content if isinstance(content, str) else "",
|
||||
"published_at": published if isinstance(published, str) else None,
|
||||
"url": url if isinstance(url, str) else None,
|
||||
}
|
||||
sidecar_path = media_path.with_suffix(".json")
|
||||
sidecar_path.write_text(json.dumps(data, indent=2))
|
||||
return sidecar_path
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Native Patreon ingester — the Patreon ADAPTER over the platform-agnostic core.
|
||||
|
||||
The orchestration that drives a native subscription walk (page a feed → extract
|
||||
media → tiered skip → download → mark-seen / record-failures / checkpoint-cursor
|
||||
→ return a gallery-dl-shaped `DownloadResult`, across tick/backfill/recovery)
|
||||
now lives in `ingest_core.Ingester` — it's identical for every platform. This
|
||||
module is the thin Patreon adapter: it wires the Patreon `client`/`downloader`/
|
||||
ledger models/constraints/key into the core and supplies the Patreon-specific
|
||||
failure mapping. `download_service.download_source` calls `PatreonIngester.run`
|
||||
exactly as before; the public surface (this class, `_ledger_key`,
|
||||
`DEAD_LETTER_THRESHOLD`, `verify_patreon_credential`) is unchanged.
|
||||
|
||||
Three modes (selected by `download_service` from `config_overrides` state):
|
||||
- tick — newest→oldest, skip seen (tier-1 ledger + tier-2 disk), early-out
|
||||
after N contiguous already-have-it items (the cheap native
|
||||
equivalent of gallery-dl's `exit:20`, now free of per-file HEADs).
|
||||
- backfill — full-history walk in a time-boxed chunk, resuming from the
|
||||
pagination cursor checkpoint; reaches the bottom → "complete".
|
||||
- recovery — like backfill but BYPASSES the tier-1 seen-ledger AND the
|
||||
dead-letter ledger, so deliberately-dropped-and-deleted near-dups
|
||||
get re-fetched and re-evaluated under the current pHash threshold
|
||||
(tier-2 disk skip still spares files we kept).
|
||||
|
||||
The seen/dead-letter ledgers live in Postgres (`patreon_seen_media` /
|
||||
`patreon_failed_media`); the core opens SHORT-LIVED sync sessions per page batch
|
||||
— never held across a network fetch ([[db-connection-held-across-subprocess]]).
|
||||
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from ..models import PatreonFailedMedia, PatreonSeenMedia
|
||||
from .gallery_dl import DownloadResult, ErrorType
|
||||
from .ingest_core import DEAD_LETTER_THRESHOLD, Ingester
|
||||
from .patreon_client import (
|
||||
MediaItem,
|
||||
PatreonAPIError,
|
||||
PatreonAuthError,
|
||||
PatreonClient,
|
||||
PatreonDriftError,
|
||||
)
|
||||
from .patreon_downloader import PatreonDownloader
|
||||
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
|
||||
|
||||
__all__ = [
|
||||
"DEAD_LETTER_THRESHOLD",
|
||||
"PatreonIngester",
|
||||
"_ledger_key",
|
||||
"verify_patreon_credential",
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Ledger keys are stored in patreon_seen_media.filehash VARCHAR(128); bound any
|
||||
# synthesized key so a pathologically long file_name can't overflow the column.
|
||||
_LEDGER_KEY_MAX = 128
|
||||
|
||||
|
||||
def _ledger_key(media: MediaItem) -> str:
|
||||
"""Stable per-media identity for the cross-run seen-ledger.
|
||||
|
||||
A Patreon CDN URL carries a 32-char MD5 (`media.filehash`) — that is the
|
||||
natural key. Some media have none: Mux/HLS video (`stream.mux.com`, no
|
||||
content hash at discovery) and the odd inline-content `<img>` pointing at a
|
||||
hashless URL. The plan calls the video case the ``video:<post_id>:<media_id>``
|
||||
sentinel; `MediaItem` carries no media_id, so the post-scoped filename is the
|
||||
stable proxy. Bounded to the column width.
|
||||
"""
|
||||
if media.filehash:
|
||||
return media.filehash
|
||||
return f"{media.post_id}:{media.filename}"[:_LEDGER_KEY_MAX]
|
||||
|
||||
|
||||
class PatreonIngester(Ingester):
|
||||
"""Walk a Patreon campaign's posts, download unseen media, return a
|
||||
`DownloadResult`. A thin adapter over `ingest_core.Ingester`.
|
||||
|
||||
Construct with the per-source `cookies_path` and a sync sessionmaker for the
|
||||
ledgers. `client` / `downloader` are injectable seams so unit tests run
|
||||
without network, subprocess, or a real CDN.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
images_root: Path,
|
||||
cookies_path: str | None,
|
||||
session_factory: Callable[[], object],
|
||||
*,
|
||||
validate: bool = True,
|
||||
rate_limit: float = 0.0,
|
||||
request_sleep: float = 0.0,
|
||||
client: PatreonClient | None = None,
|
||||
downloader: PatreonDownloader | None = None,
|
||||
):
|
||||
self.images_root = Path(images_root)
|
||||
self.cookies_path = str(cookies_path) if cookies_path else None
|
||||
# Pacing (plan #703): request_sleep paces the API page fetches,
|
||||
# rate_limit paces the media downloads. Injected client/downloader (in
|
||||
# tests) already carry their own pacing, so these only apply to the
|
||||
# default-constructed ones.
|
||||
resolved_client = (
|
||||
client
|
||||
if client is not None
|
||||
else PatreonClient(cookies_path, request_sleep=request_sleep)
|
||||
)
|
||||
resolved_downloader = (
|
||||
downloader
|
||||
if downloader is not None
|
||||
else PatreonDownloader(
|
||||
self.images_root, cookies_path, validate=validate, rate_limit=rate_limit
|
||||
)
|
||||
)
|
||||
super().__init__(
|
||||
client=resolved_client,
|
||||
downloader=resolved_downloader,
|
||||
session_factory=session_factory,
|
||||
seen_model=PatreonSeenMedia,
|
||||
failed_model=PatreonFailedMedia,
|
||||
seen_constraint="uq_patreon_seen_media_source_id",
|
||||
failed_constraint="uq_patreon_failed_media_source_id",
|
||||
ledger_key=_ledger_key,
|
||||
platform="patreon",
|
||||
error_base=PatreonAPIError,
|
||||
)
|
||||
|
||||
# -- failure mapping (Patreon exception taxonomy) ----------------------
|
||||
|
||||
def _failure_result(self, exc: Exception, _result) -> DownloadResult:
|
||||
"""Map a client-level exception to a loud, typed failed DownloadResult.
|
||||
|
||||
We NEVER return a silent zero-download "success" — the whole point of the
|
||||
native ingester is to fail RED when Patreon's API shape or our auth
|
||||
changes. The typed mapping lets FailingSourcesCard render the right chip
|
||||
and tells the operator what to do:
|
||||
- PatreonAuthError → AUTH_ERROR (rotate cookies)
|
||||
- PatreonDriftError → API_DRIFT (ingester field-set/parser needs update)
|
||||
- HTTP 429 / 404 → RATE_LIMITED / NOT_FOUND
|
||||
- other HTTP status → HTTP_ERROR; transport failure → NETWORK_ERROR
|
||||
|
||||
PatreonAuthError and PatreonDriftError both subclass PatreonAPIError, so
|
||||
they must be matched before the generic HTTP/transport fallthrough.
|
||||
"""
|
||||
message = str(exc)
|
||||
if isinstance(exc, PatreonAuthError):
|
||||
error_type = ErrorType.AUTH_ERROR
|
||||
elif isinstance(exc, PatreonDriftError):
|
||||
error_type = ErrorType.API_DRIFT
|
||||
message = f"Patreon API changed — ingester needs update: {message}"
|
||||
else: # generic PatreonAPIError: HTTP non-2xx (status_code set) or transport
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status == 429:
|
||||
error_type = ErrorType.RATE_LIMITED
|
||||
elif status == 404:
|
||||
error_type = ErrorType.NOT_FOUND
|
||||
elif status is not None:
|
||||
error_type = ErrorType.HTTP_ERROR
|
||||
else:
|
||||
error_type = ErrorType.NETWORK_ERROR
|
||||
log.warning("Patreon ingest failed (%s): %s", error_type.value, message)
|
||||
result = _result(
|
||||
success=False, return_code=1,
|
||||
error_type=error_type, error_message=message,
|
||||
)
|
||||
# plan #708 B1: carry the server's Retry-After up to the cooldown.
|
||||
if error_type == ErrorType.RATE_LIMITED:
|
||||
result.retry_after_seconds = getattr(exc, "retry_after", None)
|
||||
return result
|
||||
|
||||
|
||||
async def verify_patreon_credential(
|
||||
url: str,
|
||||
cookies_path: str | None,
|
||||
overrides: dict | None,
|
||||
) -> tuple[bool | None, str]:
|
||||
"""Native Patreon credential probe — the verify counterpart to the ingester's
|
||||
download path, sharing its campaign-id resolution. Resolves the campaign id
|
||||
(override / id: URL / vanity) then does ONE authenticated `/api/posts` fetch
|
||||
via PatreonClient.verify_auth. Returns the uniform `(ok, message)` contract
|
||||
(True / False / None) so download_backends.verify_credential can treat it
|
||||
interchangeably with the gallery-dl probe. No download, no DB.
|
||||
"""
|
||||
campaign_id, _ = await resolve_campaign_id_for_source(url, cookies_path, overrides)
|
||||
if not campaign_id:
|
||||
vanity = extract_vanity(url)
|
||||
return None, (
|
||||
f"Couldn't resolve the Patreon campaign id — can't verify. "
|
||||
f"source_url={url!r}; vanity={vanity!r} "
|
||||
"(cookies expired, or the creator moved/renamed?)."
|
||||
)
|
||||
client = PatreonClient(cookies_path)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, client.verify_auth, campaign_id)
|
||||
@@ -19,18 +19,56 @@ import asyncio
|
||||
import http.cookiejar
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns"
|
||||
_POSTS_API = "https://www.patreon.com/api/posts"
|
||||
|
||||
# A source URL of the form `.../id:<digits>` already carries the campaign id
|
||||
# (no lookup needed). The vanity regex deliberately EXCLUDES the id: form so the
|
||||
# two paths don't overlap.
|
||||
#
|
||||
# Patreon serves the creator vanity under several path prefixes — bare
|
||||
# (`patreon.com/Atole`), `c/` (`patreon.com/c/Atole`), and `cw/`
|
||||
# (`patreon.com/cw/Atole`, its current "creator workspace" URL). The optional
|
||||
# prefix group must list `cw/` BEFORE `c/` so the longer prefix wins — otherwise
|
||||
# `cw/Atole` matches the bare branch and yields vanity="cw" (operator-flagged
|
||||
# 2026-06-07: every `/cw/` source failed resolution on vanity="cw").
|
||||
_ID_URL_RE = re.compile(r"/id:(\d+)")
|
||||
# A single-post permalink — patreon.com/posts/<slug>-<post_id> (or bare
|
||||
# /posts/<post_id>). The trailing digits are the post id; the creator's
|
||||
# campaign is resolved from the post itself (operator-flagged 2026-06-07: a
|
||||
# /posts/ source resolved vanity="posts"). `posts/` is excluded from the vanity
|
||||
# regex so it never masquerades as a creator slug.
|
||||
_POST_URL_RE = re.compile(r"/posts/(?:[^/?#]*-)?(\d+)(?:[/?#]|$)")
|
||||
_VANITY_PREFIXES = ("cw/", "c/")
|
||||
_VANITY_RE = re.compile(
|
||||
r"^https?://(?:www\.)?patreon\.com/(?:cw/|c/)?(?!(?:id:|posts/))([^/?#]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Fallback resolution: Patreon's `/api/campaigns?filter[vanity]=` lookup has
|
||||
# proven unreliable (returns empty `data` for creators that clearly exist —
|
||||
# operator-flagged 2026-06-06). gallery-dl never used that endpoint; it scrapes
|
||||
# the campaign id out of the creator page's bootstrap JSON. We do the same as a
|
||||
# fallback: fetch the creator page HTML and pull the first campaign id out of
|
||||
# any of these embeddings (ordered most- to least-specific).
|
||||
_PAGE_CAMPAIGN_ID_PATTERNS = (
|
||||
re.compile(r'"id":\s*"(\d+)",\s*"type":\s*"campaign"'),
|
||||
re.compile(r'"campaign":\s*\{\s*"data":\s*\{\s*"id":\s*"(\d+)"'),
|
||||
re.compile(r"/api/campaigns/(\d+)"),
|
||||
re.compile(r'"campaign_id":\s*"?(\d+)'),
|
||||
)
|
||||
|
||||
|
||||
def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None:
|
||||
if not cookies_path or not os.path.isfile(cookies_path):
|
||||
@@ -45,6 +83,15 @@ def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJa
|
||||
|
||||
|
||||
def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
|
||||
"""Resolve a vanity to a campaign id: try the campaigns API first (cheap,
|
||||
structured), then fall back to scraping the creator page (robust against the
|
||||
API's empty-data failures). Returns None only when both miss."""
|
||||
return _lookup_via_api(vanity, cookies_path) or _lookup_via_page(
|
||||
vanity, cookies_path
|
||||
)
|
||||
|
||||
|
||||
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
|
||||
jar = _load_cookie_jar(cookies_path)
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
@@ -92,6 +139,87 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
|
||||
return campaign_id
|
||||
|
||||
|
||||
def _scrape_campaign_id(html: str) -> str | None:
|
||||
"""First campaign id found in creator-page HTML via the known embeddings."""
|
||||
if not isinstance(html, str):
|
||||
return None
|
||||
for pat in _PAGE_CAMPAIGN_ID_PATTERNS:
|
||||
m = pat.search(html)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None:
|
||||
"""Fallback: GET the creator page and scrape the campaign id from the page
|
||||
bootstrap (gallery-dl's method). Tries both the bare and `/c/` vanity paths
|
||||
Patreon redirects between. Never raises."""
|
||||
jar = _load_cookie_jar(cookies_path)
|
||||
headers = {"User-Agent": _USER_AGENT, "Accept": "text/html"}
|
||||
# Try the bare vanity and every known creator-path prefix Patreon
|
||||
# redirects between (c/, cw/) — the one the source used isn't known here
|
||||
# (extract_vanity already stripped it).
|
||||
page_urls = [f"https://www.patreon.com/{vanity}"]
|
||||
page_urls += [f"https://www.patreon.com/{p}{vanity}" for p in _VANITY_PREFIXES]
|
||||
for page_url in page_urls:
|
||||
try:
|
||||
resp = requests.get(
|
||||
page_url,
|
||||
headers=headers,
|
||||
cookies=jar,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
allow_redirects=True,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
log.warning("Patreon creator-page fetch failed for %s: %s", page_url, exc)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
campaign_id = _scrape_campaign_id(resp.text)
|
||||
if campaign_id:
|
||||
log.info(
|
||||
"Resolved Patreon vanity=%s → campaign_id=%s via creator page",
|
||||
vanity, campaign_id,
|
||||
)
|
||||
return campaign_id
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_campaign_from_post(post_id: str, cookies_path: str | None) -> str | None:
|
||||
"""Resolve the owning campaign id from a single post id via the Patreon
|
||||
post API (`/api/posts/<id>?include=campaign`). A `/posts/` source URL points
|
||||
at one post, but a subscription walks the whole creator — so we follow the
|
||||
post to its campaign. Never raises."""
|
||||
jar = _load_cookie_jar(cookies_path)
|
||||
headers = {"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"}
|
||||
params = {"include": "campaign", "fields[campaign]": "name"}
|
||||
url = f"{_POSTS_API}/{post_id}"
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, params=params, headers=headers, cookies=jar,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
log.warning("Patreon post lookup failed for post=%s: %s", post_id, exc)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
log.warning("Patreon post API returned HTTP %d for post=%s", resp.status_code, post_id)
|
||||
return None
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
campaign = ((data.get("relationships") or {}).get("campaign") or {}).get("data") or {}
|
||||
campaign_id = campaign.get("id")
|
||||
if isinstance(campaign_id, str) and campaign_id:
|
||||
log.info("Resolved Patreon post=%s → campaign_id=%s", post_id, campaign_id)
|
||||
return campaign_id
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_campaign_id(
|
||||
vanity: str,
|
||||
cookies_path: str | None,
|
||||
@@ -100,3 +228,55 @@ async def resolve_campaign_id(
|
||||
Never raises."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _sync_lookup, vanity, cookies_path)
|
||||
|
||||
|
||||
async def resolve_campaign_from_post(
|
||||
post_id: str, cookies_path: str | None,
|
||||
) -> str | None:
|
||||
"""Async wrapper around _lookup_campaign_from_post. Never raises."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, _lookup_campaign_from_post, post_id, cookies_path
|
||||
)
|
||||
|
||||
|
||||
def extract_vanity(url: str) -> str | None:
|
||||
"""The vanity slug from a Patreon creator URL, or None for an `id:` URL."""
|
||||
m = _VANITY_RE.match(url or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
async def resolve_campaign_id_for_source(
|
||||
url: str,
|
||||
cookies_path: str | None,
|
||||
overrides: dict | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve a Patreon source to its campaign id — the single resolution path
|
||||
shared by the download ingester and the credential-verify probe.
|
||||
|
||||
Order: cached `patreon_campaign_id` override → an `id:<digits>` URL → a
|
||||
`/posts/<id>` permalink (resolve the owning campaign from the post) → a
|
||||
vanity lookup against the campaigns API. Returns
|
||||
`(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None whenever
|
||||
a lookup actually ran, so the caller caches it on the source (the
|
||||
override/id: paths needed no lookup). `(None, None)` when unresolvable.
|
||||
Never raises.
|
||||
"""
|
||||
overrides = overrides or {}
|
||||
cached = overrides.get("patreon_campaign_id")
|
||||
if cached:
|
||||
return cached, None
|
||||
id_match = _ID_URL_RE.search(url or "")
|
||||
if id_match:
|
||||
return id_match.group(1), None
|
||||
# A single-post URL → follow the post to its creator's campaign so the
|
||||
# source subscribes to the whole feed (a subscription isn't one post).
|
||||
post_match = _POST_URL_RE.search(url or "")
|
||||
if post_match:
|
||||
resolved = await resolve_campaign_from_post(post_match.group(1), cookies_path)
|
||||
return resolved, resolved
|
||||
vanity = extract_vanity(url)
|
||||
if vanity:
|
||||
resolved = await resolve_campaign_id(vanity, cookies_path)
|
||||
return resolved, resolved
|
||||
return None, None
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Per-platform download concurrency cap.
|
||||
|
||||
Some platforms (Patreon) are API-rate-sensitive enough that two *simultaneous*
|
||||
walks can trip the server's rate limit even with each source pacing its own
|
||||
requests. The platform-cooldown handles the AFTERMATH of a 429; this is the
|
||||
preventive half — it serializes downloads PER PLATFORM to one at a time.
|
||||
|
||||
Different platforms still run concurrently up to the worker's concurrency; only
|
||||
a second walk on the SAME serialized platform waits. The lock lives in Redis
|
||||
(the Celery broker) with a TTL, so a SIGKILL'd worker can't wedge a platform —
|
||||
the lock auto-expires shortly after the download hard time limit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import redis
|
||||
|
||||
from ..config import get_config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Platforms walked one-at-a-time. gallery-dl platforms are intentionally NOT
|
||||
# here: each runs as a self-pacing subprocess and they're lower-volume. Add a
|
||||
# platform here to cap it to a single concurrent walk.
|
||||
SERIALIZED_PLATFORMS = frozenset({"patreon"})
|
||||
|
||||
_LOCK_PREFIX = "fc:download_lock:"
|
||||
|
||||
_client: redis.Redis | None = None
|
||||
|
||||
|
||||
def _redis() -> redis.Redis:
|
||||
# One client per worker process (Celery prefork forks before tasks run, so
|
||||
# each process lazily builds its own). redis-py pools connections.
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = redis.from_url(get_config().celery_broker_url)
|
||||
return _client
|
||||
|
||||
|
||||
def platform_lock(platform: str, *, ttl_seconds: int):
|
||||
"""A non-blocking Redis lock for `platform`, or None when the platform is
|
||||
not serialized. Caller does `.acquire(blocking=False)` / `.release()`.
|
||||
|
||||
Returns None (rather than raising) on any Redis error so a broker hiccup
|
||||
degrades to the prior behaviour (uncapped) instead of stalling downloads.
|
||||
"""
|
||||
if platform not in SERIALIZED_PLATFORMS:
|
||||
return None
|
||||
try:
|
||||
return _redis().lock(
|
||||
f"{_LOCK_PREFIX}{platform}",
|
||||
timeout=ttl_seconds,
|
||||
blocking=False,
|
||||
)
|
||||
except redis.RedisError as exc: # pragma: no cover - broker outage
|
||||
log.warning("platform_lock unavailable for %s: %s", platform, exc)
|
||||
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,217 @@ 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
|
||||
|
||||
@staticmethod
|
||||
def _part_gaps(chapters: list[dict]) -> list[dict]:
|
||||
"""Missing-Part gaps between consecutive chapters whose stated_part
|
||||
numbers jump by more than 1 (e.g. a series with Part 1 and Part 3, or one
|
||||
authored straight from a Part 2 post). Mirrors _gaps but on stated_part —
|
||||
only chapters that actually carry a stated_part participate."""
|
||||
out: list[dict] = []
|
||||
prev = None
|
||||
for ch in chapters:
|
||||
cur = ch["stated_part"]
|
||||
if cur is None:
|
||||
continue
|
||||
if prev is not None and cur > prev["stated_part"] + 1:
|
||||
out.append(
|
||||
{
|
||||
"after_chapter_id": prev["id"],
|
||||
"start": prev["stated_part"] + 1,
|
||||
"end": cur - 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.stated_part,
|
||||
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,
|
||||
ImageRecord.primary_post_id,
|
||||
Post.post_title,
|
||||
)
|
||||
.select_from(SeriesChapter)
|
||||
.outerjoin(SeriesPage, SeriesPage.chapter_id == SeriesChapter.id)
|
||||
.outerjoin(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
||||
.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
|
||||
.where(SeriesChapter.series_tag_id == series_tag_id)
|
||||
.order_by(
|
||||
SeriesChapter.chapter_number.asc(),
|
||||
SeriesPage.page_number.asc(),
|
||||
)
|
||||
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
||||
.where(SeriesPage.series_tag_id == series_tag_id)
|
||||
.order_by(SeriesPage.page_number.asc())
|
||||
)
|
||||
).all()
|
||||
|
||||
chapters: list[dict] = []
|
||||
flat: list[dict] = []
|
||||
by_id: dict[int, dict] = {}
|
||||
# chapter_id -> {post_id: title} seen across its pages, so we can label a
|
||||
# chapter with its source post when all its pages come from one post.
|
||||
posts_seen: dict[int, dict[int, str | None]] = {}
|
||||
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,
|
||||
"stated_part": r.stated_part,
|
||||
"title": r.title,
|
||||
"is_placeholder": r.is_placeholder,
|
||||
"stated_page_start": r.stated_page_start,
|
||||
"stated_page_end": r.stated_page_end,
|
||||
"source_post": None,
|
||||
"pages": [],
|
||||
}
|
||||
by_id[r.chapter_id] = ch
|
||||
posts_seen[r.chapter_id] = {}
|
||||
chapters.append(ch)
|
||||
if r.image_id is None:
|
||||
continue # placeholder / empty chapter
|
||||
if r.primary_post_id is not None:
|
||||
posts_seen[r.chapter_id][r.primary_post_id] = r.post_title
|
||||
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)
|
||||
|
||||
# A chapter's source_post is set only when every page shares one post —
|
||||
# the common case (a series authored from a post). Mixed chapters stay
|
||||
# null rather than guessing.
|
||||
for ch in chapters:
|
||||
seen = posts_seen.get(ch["id"], {})
|
||||
if len(seen) == 1:
|
||||
pid, title = next(iter(seen.items()))
|
||||
ch["source_post"] = {"id": pid, "title": title}
|
||||
|
||||
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),
|
||||
"part_gaps": self._part_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 +270,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 +316,469 @@ 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_part: int | None = None,
|
||||
stated_page_start: int | None = None,
|
||||
stated_page_end: int | None = None,
|
||||
set_title: bool = False,
|
||||
set_part: 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 or part)."""
|
||||
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_part:
|
||||
values["stated_part"] = stated_part
|
||||
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
|
||||
|
||||
@@ -20,11 +20,20 @@ class ShowcaseService:
|
||||
async def random_sample(self, limit: int = 60) -> list[dict]:
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
# Over-sample then random-order (#699): SYSTEM_ROWS reads CONTIGUOUS rows
|
||||
# from each sampled page, so sequentially-imported near-duplicates
|
||||
# (multi-image posts, variant sets) come back adjacent and cluster in the
|
||||
# showcase ("three near-identical in a row"). Sampling a multiple of
|
||||
# `limit` spans more pages, and ORDER BY random() before taking `limit`
|
||||
# breaks the physical adjacency — far better spread, still cheap
|
||||
# (random() over a few hundred rows, not the whole table).
|
||||
oversample = min(limit * 5, 1000)
|
||||
stmt = select(ImageRecord).from_statement(
|
||||
text(
|
||||
"SELECT * FROM image_record "
|
||||
"TABLESAMPLE SYSTEM_ROWS(:n)"
|
||||
).bindparams(n=limit)
|
||||
"SELECT * FROM ("
|
||||
" SELECT * FROM image_record TABLESAMPLE SYSTEM_ROWS(:o)"
|
||||
") sub ORDER BY random() LIMIT :n"
|
||||
).bindparams(o=oversample, n=limit)
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
return [
|
||||
|
||||
@@ -64,6 +64,15 @@ class SourceRecord:
|
||||
consecutive_failures: int
|
||||
next_check_at: str | None
|
||||
backfill_runs_remaining: int
|
||||
# plan #693: derived from config_overrides for the UI badge.
|
||||
backfill_state: str | None # "running" | "complete" | "stalled" | None (idle)
|
||||
backfill_chunks: int
|
||||
# plan #697: a running deep-walk that bypasses the Patreon seen-ledger
|
||||
# (recovery) vs. a normal backfill. Lets the badge label it "Recovering".
|
||||
backfill_bypass_seen: bool
|
||||
# plan #704: cumulative posts processed across the walk's chunks — live
|
||||
# progress for the badge.
|
||||
backfill_posts: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -82,6 +91,10 @@ class SourceRecord:
|
||||
"consecutive_failures": self.consecutive_failures,
|
||||
"next_check_at": self.next_check_at,
|
||||
"backfill_runs_remaining": self.backfill_runs_remaining,
|
||||
"backfill_state": self.backfill_state,
|
||||
"backfill_chunks": self.backfill_chunks,
|
||||
"backfill_bypass_seen": self.backfill_bypass_seen,
|
||||
"backfill_posts": self.backfill_posts,
|
||||
}
|
||||
|
||||
|
||||
@@ -89,10 +102,13 @@ class SourceRecord:
|
||||
|
||||
_EDITABLE = {"enabled", "url", "config_overrides", "check_interval_override", "platform"}
|
||||
|
||||
# Plan #544 follow-up: newly created enabled sources pre-arm backfill so
|
||||
# their first N polls walk gallery-dl's full post history with the longer
|
||||
# timeout (matches the manual "Deep scan" button's default).
|
||||
NEW_SOURCE_BACKFILL_RUNS = 3
|
||||
# Plan #693: backfill safety cap. "Start backfill" (and a newly created
|
||||
# enabled source) arms a run-until-done walk; this caps how many time-boxed
|
||||
# chunks it may spend before pausing as "stalled", so a pathological walk that
|
||||
# never reaches the bottom can't run forever. Generous on purpose — at
|
||||
# BACKFILL_CHUNK_SECONDS (600s) per chunk this is ~33h of cumulative walk, far
|
||||
# beyond any real catalog; the cursor stall-guard is the real terminator.
|
||||
BACKFILL_MAX_CHUNKS = 200
|
||||
|
||||
|
||||
class SourceService:
|
||||
@@ -135,6 +151,7 @@ class SourceService:
|
||||
self, source: Source, artist: Artist, settings: ImportSettings,
|
||||
) -> SourceRecord:
|
||||
nxt = compute_next_check_at(source, artist, settings)
|
||||
co = source.config_overrides or {}
|
||||
return SourceRecord(
|
||||
id=source.id,
|
||||
artist_id=source.artist_id,
|
||||
@@ -151,6 +168,10 @@ class SourceService:
|
||||
consecutive_failures=source.consecutive_failures or 0,
|
||||
next_check_at=nxt.isoformat() if nxt else None,
|
||||
backfill_runs_remaining=source.backfill_runs_remaining or 0,
|
||||
backfill_state=co.get("_backfill_state"),
|
||||
backfill_chunks=int(co.get("_backfill_chunks", 0)),
|
||||
backfill_bypass_seen=bool(co.get("_backfill_bypass_seen")),
|
||||
backfill_posts=int(co.get("_backfill_posts", 0)),
|
||||
)
|
||||
|
||||
async def _row_to_record(self, source: Source) -> SourceRecord:
|
||||
@@ -212,16 +233,17 @@ class SourceService:
|
||||
select(func.count(Source.id)).where(Source.artist_id == artist_id)
|
||||
)).scalar_one()
|
||||
|
||||
# Plan #544 follow-up: a freshly added subscription has no archive
|
||||
# yet, so the first few polls would walk the full post history in
|
||||
# tick mode and trip exit:20 after ~20 contiguous archive hits —
|
||||
# except there are none yet, so tick mode would walk forever and
|
||||
# blow the wall-clock cap. Pre-arm backfill so the initial syncs
|
||||
# use the longer timeout + skip:True walk. Tick mode resumes once
|
||||
# the budget is spent or the queue drains.
|
||||
# Disabled sources (incl. sidecar synthetics, url='sidecar:...')
|
||||
# are never polled, so leave their counter at 0.
|
||||
backfill_runs = NEW_SOURCE_BACKFILL_RUNS if enabled else 0
|
||||
# Plan #693: a freshly added subscription has no archive yet, so it
|
||||
# should walk its full post history once. Arm run-until-done backfill
|
||||
# (state="running" + the chunk cap); the time-boxed chunks march to the
|
||||
# bottom across ticks, then flip to "complete" and tick mode takes over.
|
||||
# Disabled sources (incl. sidecar synthetics, url='sidecar:...') are
|
||||
# never polled, so leave them idle.
|
||||
if enabled:
|
||||
config_overrides = {**(config_overrides or {}), "_backfill_state": "running"}
|
||||
backfill_runs = BACKFILL_MAX_CHUNKS
|
||||
else:
|
||||
backfill_runs = 0
|
||||
source = Source(
|
||||
artist_id=artist_id, platform=platform, url=url,
|
||||
enabled=enabled, config_overrides=config_overrides,
|
||||
@@ -286,22 +308,71 @@ class SourceService:
|
||||
await self.session.commit()
|
||||
return await self._row_to_record(source)
|
||||
|
||||
async def set_backfill_runs(
|
||||
self, source_id: int, runs: int,
|
||||
) -> SourceRecord:
|
||||
"""Plan #544: arm a source for backfill mode. The next `runs`
|
||||
download runs will use gallery-dl's full-walk config (skip: True
|
||||
+ 30-min timeout) instead of the catch-up default. Runs must be
|
||||
1..10 — bigger is rejected to keep the operator from accidentally
|
||||
setting a runaway budget."""
|
||||
if not isinstance(runs, int) or runs < 1 or runs > 10:
|
||||
raise ValueError("runs must be an integer in [1, 10]")
|
||||
async def start_backfill(self, source_id: int) -> SourceRecord:
|
||||
"""Plan #693: arm a run-until-done backfill. Sets state="running" and
|
||||
the chunk cap; download runs then walk the full post history in
|
||||
time-boxed chunks (skip:True + BACKFILL_CHUNK_SECONDS), resuming from
|
||||
the cursor each chunk, until gallery-dl reaches the bottom (→ state
|
||||
"complete") or the cap/stall-guard pauses it (→ "stalled"). Clears any
|
||||
prior cursor/chunk/stall state so a re-start walks fresh from the top."""
|
||||
source = (await self.session.execute(
|
||||
select(Source).where(Source.id == source_id)
|
||||
)).scalar_one_or_none()
|
||||
if source is None:
|
||||
raise LookupError(f"source id={source_id} not found")
|
||||
source.backfill_runs_remaining = runs
|
||||
co = dict(source.config_overrides or {})
|
||||
co["_backfill_state"] = "running"
|
||||
for k in ("_backfill_cursor", "_backfill_cursor_stalls", "_backfill_chunks",
|
||||
"_backfill_posts"):
|
||||
co.pop(k, None)
|
||||
source.config_overrides = co
|
||||
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
|
||||
await self.session.commit()
|
||||
return await self._row_to_record(source)
|
||||
|
||||
async def start_recovery(self, source_id: int) -> SourceRecord:
|
||||
"""Plan #697: arm a RECOVERY walk — a backfill that bypasses the Patreon
|
||||
seen-ledger so deliberately-dropped-and-deleted near-dups get re-fetched
|
||||
and re-evaluated under the CURRENT pHash threshold (tier-2 disk still
|
||||
spares files we kept). Reuses the entire #693 backfill state machine
|
||||
(time-boxed chunks, cursor checkpoint, complete/stall lifecycle) plus the
|
||||
`_backfill_bypass_seen` flag that flips download mode to recovery. Clears
|
||||
any prior cursor/chunk/stall state so it walks fresh from the top. The
|
||||
flag is cleared on completion (download_service) and on stop.
|
||||
|
||||
Recovery is Patreon-only (the seen-ledger is Patreon's); for other
|
||||
platforms the flag is inert (download_service ignores it) and the walk
|
||||
runs as a plain backfill. The UI gates the action to Patreon sources."""
|
||||
source = (await self.session.execute(
|
||||
select(Source).where(Source.id == source_id)
|
||||
)).scalar_one_or_none()
|
||||
if source is None:
|
||||
raise LookupError(f"source id={source_id} not found")
|
||||
co = dict(source.config_overrides or {})
|
||||
co["_backfill_state"] = "running"
|
||||
co["_backfill_bypass_seen"] = True
|
||||
for k in ("_backfill_cursor", "_backfill_cursor_stalls", "_backfill_chunks",
|
||||
"_backfill_posts"):
|
||||
co.pop(k, None)
|
||||
source.config_overrides = co
|
||||
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
|
||||
await self.session.commit()
|
||||
return await self._row_to_record(source)
|
||||
|
||||
async def stop_backfill(self, source_id: int) -> SourceRecord:
|
||||
"""Plan #693: cancel an in-progress backfill — back to idle/tick mode.
|
||||
Clears the running state + cursor/chunk/stall bookkeeping."""
|
||||
source = (await self.session.execute(
|
||||
select(Source).where(Source.id == source_id)
|
||||
)).scalar_one_or_none()
|
||||
if source is None:
|
||||
raise LookupError(f"source id={source_id} not found")
|
||||
co = dict(source.config_overrides or {})
|
||||
for k in ("_backfill_state", "_backfill_cursor", "_backfill_cursor_stalls",
|
||||
"_backfill_chunks", "_backfill_bypass_seen", "_backfill_posts"):
|
||||
co.pop(k, None)
|
||||
source.config_overrides = co
|
||||
source.backfill_runs_remaining = 0
|
||||
await self.session.commit()
|
||||
return await self._row_to_record(source)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tag CRUD + autocomplete + image-tag association."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -12,6 +14,25 @@ from ..models import Tag, TagKind, image_tag
|
||||
from ..models.tag_allowlist import TagAllowlist
|
||||
from ..models.tag_reference_embedding import TagReferenceEmbedding
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_tag_name(name: str) -> str:
|
||||
"""Canonical tag form (#701): collapse whitespace + capitalize the first
|
||||
letter of each word, PRESERVING the rest of the word.
|
||||
|
||||
Per-word capitalize (NOT str.title(), which mangles apostrophes:
|
||||
don't → Don'T). The word tail is left untouched so acronyms survive
|
||||
(DC stays DC, NSFW stays NSFW) — operator-revised 2026-06-06: protecting
|
||||
acronyms matters more than folding ALL-CAPS input. This MATCHES
|
||||
ml/tag_name._title_word, so a tag suggested by the Camie tagger keeps the
|
||||
exact casing the suggestion UI showed when it round-trips through the
|
||||
create endpoint on Accept.
|
||||
"""
|
||||
return " ".join(
|
||||
w[:1].upper() + w[1:] for w in (name or "").split()
|
||||
)
|
||||
|
||||
|
||||
class TagValidationError(ValueError):
|
||||
"""Raised when tag construction breaks the kind/fandom rules."""
|
||||
@@ -71,6 +92,10 @@ class TagService:
|
||||
- if fandom_id is set, the referenced tag must exist and have
|
||||
kind == TagKind.fandom
|
||||
"""
|
||||
# NOTE: case is NOT normalized here — find_or_create is the shared path
|
||||
# the ML tagger / allowlist also use, and Title-Casing the booru
|
||||
# vocabulary breaks allowlist matching. Display-casing (Title Case) is
|
||||
# applied at the user-entry layer (api/tags create_tag) only (#701).
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise TagValidationError("Tag name cannot be empty")
|
||||
@@ -93,13 +118,17 @@ class TagService:
|
||||
# inserts; without the savepoint the outer transaction would
|
||||
# poison and the calling request crashes. Mirrors
|
||||
# importer._get_or_create.
|
||||
# Case-insensitive match (#701): a normalized (Title Case) input must
|
||||
# find an existing differently-cased tag instead of forking a duplicate.
|
||||
stmt = (
|
||||
select(Tag)
|
||||
.where(Tag.name == name)
|
||||
.where(func.lower(Tag.name) == name.lower())
|
||||
.where(Tag.kind == kind)
|
||||
.where(
|
||||
Tag.fandom_id.is_(None) if fandom_id is None else Tag.fandom_id == fandom_id
|
||||
)
|
||||
.order_by(Tag.id)
|
||||
.limit(1)
|
||||
)
|
||||
existing = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if existing:
|
||||
@@ -249,9 +278,11 @@ class TagService:
|
||||
if tag is None:
|
||||
raise TagValidationError(f"Tag {tag_id} not found")
|
||||
|
||||
# Case-insensitive clash (#701) — renaming onto a differently-cased tag
|
||||
# is still a merge, not a silent fork.
|
||||
clash_stmt = (
|
||||
select(Tag)
|
||||
.where(Tag.name == new_name)
|
||||
.where(func.lower(Tag.name) == new_name.lower())
|
||||
.where(Tag.kind == tag.kind)
|
||||
.where(
|
||||
Tag.fandom_id.is_(None)
|
||||
@@ -259,6 +290,8 @@ class TagService:
|
||||
else Tag.fandom_id == tag.fandom_id
|
||||
)
|
||||
.where(Tag.id != tag_id)
|
||||
.order_by(Tag.id)
|
||||
.limit(1)
|
||||
)
|
||||
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
|
||||
if clash is not None:
|
||||
@@ -482,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(
|
||||
@@ -501,6 +544,21 @@ class TagService:
|
||||
update(Tag).where(Tag.fandom_id == src).values(fandom_id=tgt)
|
||||
)
|
||||
|
||||
async def _image_assoc_counts(self, tag_ids: list[int]) -> dict[int, int]:
|
||||
"""image_tag row counts keyed by tag_id, for the survivor heuristic
|
||||
(the best-connected tag in a collision group survives → fewest
|
||||
FK repoints). Tags with zero associations are absent from the map."""
|
||||
if not tag_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(image_tag.c.tag_id, func.count())
|
||||
.where(image_tag.c.tag_id.in_(tag_ids))
|
||||
.group_by(image_tag.c.tag_id)
|
||||
)
|
||||
).all()
|
||||
return {tid: int(n) for tid, n in rows}
|
||||
|
||||
async def _create_protective_aliases(
|
||||
self, src_name: str, src_kind: TagKind, tgt: int
|
||||
) -> bool:
|
||||
@@ -548,3 +606,203 @@ class TagService:
|
||||
if res.rowcount:
|
||||
created = True
|
||||
return created
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #714: retro-normalize existing tags to the #701 canonical (Title Case +
|
||||
# collapsed whitespace) and merge case/whitespace-variant duplicates.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NORMALIZE_SAMPLE_CAP = 50
|
||||
|
||||
|
||||
def _group_existing_tags(
|
||||
rows,
|
||||
) -> dict[tuple, list[tuple[int, str]]]:
|
||||
"""Group (id, name, kind, fandom_id) rows by their post-normalization
|
||||
identity: (kind, COALESCE(fandom_id, -1), canonical_name). Every tag that
|
||||
would collapse to the same canonical lives in ONE group, so the canonical
|
||||
form is unique within (kind, fandom) once each group is resolved."""
|
||||
groups: dict[tuple, list[tuple[int, str]]] = {}
|
||||
for tag_id, name, kind, fandom_id in rows:
|
||||
canonical = normalize_tag_name(name)
|
||||
key = (kind, fandom_id if fandom_id is not None else -1, canonical)
|
||||
groups.setdefault(key, []).append((tag_id, name))
|
||||
return groups
|
||||
|
||||
|
||||
def _group_needs_change(canonical: str, members: list[tuple[int, str]]) -> bool:
|
||||
"""A group is already canonical iff it's a single member whose name equals
|
||||
the canonical form. Anything else (a collision, or a lone mis-cased tag)
|
||||
needs work."""
|
||||
if len(members) > 1:
|
||||
return True
|
||||
return members[0][1] != canonical
|
||||
|
||||
|
||||
def _best_connected(tag_ids: list[int], counts: dict[int, int]) -> int:
|
||||
"""The tag with the most image associations (→ fewest FK repoints when it
|
||||
survives), tie-broken to the lowest id for determinism. Module-level so the
|
||||
key closes over its parameter, not a loop variable (ruff B023)."""
|
||||
return max(tag_ids, key=lambda tid: (counts.get(tid, 0), -tid))
|
||||
|
||||
|
||||
async def normalize_existing_tags(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
time_budget_seconds: float | None = None,
|
||||
) -> dict:
|
||||
"""Convert the back-catalog to the #701 canonical tag form.
|
||||
|
||||
For each (kind, fandom, canonical) group: pick a survivor, merge any
|
||||
case/whitespace-variant siblings INTO it via the tested merge path
|
||||
(TagService._do_merge — FK repoints + protective aliases), then rename the
|
||||
survivor to the canonical form. Idempotent: a group that is already a lone
|
||||
canonical tag is a no-op, so re-running is safe.
|
||||
|
||||
A first run over a fresh back-catalog can touch tens of thousands of tags
|
||||
(the whole booru-derived vocabulary needs recasing) and won't finish inside
|
||||
one Celery time limit — it timed out at 40 min (operator-flagged 2026-06-07).
|
||||
`time_budget_seconds` time-boxes the live run: it stops cleanly at the budget
|
||||
and reports `partial`/`remaining` so the caller can re-enqueue and continue.
|
||||
Because it commits per group and is idempotent, the next run just picks up
|
||||
the groups still needing change.
|
||||
|
||||
dry_run=True returns a projection (counts + a sample of the changes) with no
|
||||
mutations. Live runs commit per group and isolate failures per group so one
|
||||
bad group can't strand the rest.
|
||||
|
||||
Returns (dry_run):
|
||||
{"groups": N, "collisions": M, "tags_to_merge": K, "tags_to_rename": R,
|
||||
"total_changes": T, "sample": [{"to", "from": [...], "kind", "merge"}]}
|
||||
Returns (live):
|
||||
{"groups_processed", "merged", "renamed", "aliases_created", "errors",
|
||||
"total_changes", "remaining", "partial", "sample": [...]}
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Tag.id, Tag.name, Tag.kind, Tag.fandom_id)
|
||||
)
|
||||
).all()
|
||||
groups = _group_existing_tags(rows)
|
||||
|
||||
# Deterministic sample/ordering: by kind then canonical name.
|
||||
touched = sorted(
|
||||
(
|
||||
(key, members)
|
||||
for key, members in groups.items()
|
||||
if _group_needs_change(key[2], members)
|
||||
),
|
||||
key=lambda km: (
|
||||
km[0][0].value if hasattr(km[0][0], "value") else str(km[0][0]),
|
||||
km[0][2].lower(),
|
||||
),
|
||||
)
|
||||
|
||||
sample = [
|
||||
{
|
||||
"to": key[2],
|
||||
"from": [name for _id, name in members],
|
||||
"kind": key[0].value if hasattr(key[0], "value") else str(key[0]),
|
||||
"merge": len(members) > 1,
|
||||
}
|
||||
for key, members in touched[:_NORMALIZE_SAMPLE_CAP]
|
||||
]
|
||||
|
||||
if dry_run:
|
||||
collisions = sum(1 for key, m in touched if len(m) > 1)
|
||||
tags_to_merge = sum(len(m) - 1 for key, m in touched if len(m) > 1)
|
||||
# A group renames iff the canonical form isn't already one of its
|
||||
# members' exact names (else that member is picked as survivor → no
|
||||
# rename, the rest merge into it).
|
||||
tags_to_rename = sum(
|
||||
1
|
||||
for key, m in touched
|
||||
if key[2] not in {name for _id, name in m}
|
||||
)
|
||||
return {
|
||||
"groups": len(groups),
|
||||
"collisions": collisions,
|
||||
"tags_to_merge": tags_to_merge,
|
||||
"tags_to_rename": tags_to_rename,
|
||||
"total_changes": len(touched),
|
||||
"sample": sample,
|
||||
}
|
||||
|
||||
svc = TagService(session)
|
||||
summary = {
|
||||
"groups_processed": 0,
|
||||
"merged": 0,
|
||||
"renamed": 0,
|
||||
"aliases_created": 0,
|
||||
"errors": 0,
|
||||
"total_changes": len(touched),
|
||||
"remaining": len(touched),
|
||||
"partial": False,
|
||||
"sample": sample,
|
||||
}
|
||||
start = time.monotonic()
|
||||
log.info(
|
||||
"normalize_existing_tags: %d group(s) need changes (budget=%ss)",
|
||||
len(touched), time_budget_seconds,
|
||||
)
|
||||
for done, (key, members) in enumerate(touched):
|
||||
# Time-box: stop cleanly before the Celery limit kills us mid-group and
|
||||
# strands the run as a timeout. The caller re-enqueues to finish the
|
||||
# rest (idempotent — already-canonical groups are skipped next pass).
|
||||
if (
|
||||
time_budget_seconds is not None
|
||||
and time.monotonic() - start >= time_budget_seconds
|
||||
):
|
||||
summary["partial"] = True
|
||||
summary["remaining"] = len(touched) - done
|
||||
break
|
||||
# Heartbeat so a long run is diagnosable instead of silent — the timeout
|
||||
# operator-flagged 2026-06-07 produced zero logs because the only log was
|
||||
# per finished group and it was stuck mid-group on a lock.
|
||||
if done and done % 25 == 0:
|
||||
log.info(
|
||||
"normalize_existing_tags: %d/%d groups (%d merged, %d errors, "
|
||||
"%.0fs elapsed)",
|
||||
done, len(touched), summary["merged"], summary["errors"],
|
||||
time.monotonic() - start,
|
||||
)
|
||||
canonical = key[2]
|
||||
names_by_id = dict(members)
|
||||
# Survivor: prefer a member already named canonically (no rename, no
|
||||
# self-alias); else the best-connected (fewest FK repoints); else
|
||||
# lowest id for determinism.
|
||||
survivor_id = next(
|
||||
(tid for tid, name in members if name == canonical), None
|
||||
)
|
||||
if survivor_id is None:
|
||||
counts = await svc._image_assoc_counts(list(names_by_id))
|
||||
survivor_id = _best_connected(list(names_by_id), counts)
|
||||
loser_ids = [tid for tid in names_by_id if tid != survivor_id]
|
||||
try:
|
||||
survivor = await session.get(Tag, survivor_id)
|
||||
for loser_id in loser_ids:
|
||||
loser = await session.get(Tag, loser_id)
|
||||
if loser is None:
|
||||
continue
|
||||
result = await svc._do_merge(loser, survivor)
|
||||
if result.alias_created:
|
||||
summary["aliases_created"] += 1
|
||||
if survivor.name != canonical:
|
||||
survivor.name = canonical
|
||||
await session.flush()
|
||||
summary["renamed"] += 1
|
||||
await session.commit()
|
||||
summary["groups_processed"] += 1
|
||||
summary["merged"] += len(loser_ids)
|
||||
except Exception as exc: # one bad group must not strand the rest
|
||||
await session.rollback()
|
||||
summary["errors"] += 1
|
||||
log.warning(
|
||||
"tag normalize failed for group %r: %s", canonical, exc
|
||||
)
|
||||
else:
|
||||
# Loop finished without hitting the time budget — nothing left to do.
|
||||
summary["remaining"] = 0
|
||||
return summary
|
||||
|
||||
@@ -15,8 +15,15 @@ from sqlalchemy.pool import NullPool
|
||||
from ..config import get_config
|
||||
|
||||
|
||||
def async_session_factory():
|
||||
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine."""
|
||||
def async_session_factory(*, server_settings: dict | None = None):
|
||||
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine.
|
||||
|
||||
``server_settings`` (optional) are applied by asyncpg as per-connection GUCs
|
||||
on connect. Because NullPool opens a fresh real connection per checkout, every
|
||||
transaction in the task inherits them — used to set ``lock_timeout`` so a
|
||||
statement blocked on a lock fails fast instead of hanging to the Celery hard
|
||||
limit (operator-flagged normalize_tags timeout 2026-06-07).
|
||||
"""
|
||||
cfg = get_config()
|
||||
# NullPool: this engine lives for ONE task (created + disposed per
|
||||
# asyncio.run loop), so intra-task connection pooling buys nothing and
|
||||
@@ -26,5 +33,13 @@ def async_session_factory():
|
||||
# phase 3 (asyncpg ConnectionDoesNotExistError, Anduo #40014). NullPool
|
||||
# opens a fresh real connection on each checkout, so phase 3 always
|
||||
# reconnects clean; pre_ping is then redundant.
|
||||
engine = create_async_engine(cfg.database_url, future=True, poolclass=NullPool)
|
||||
connect_args = {}
|
||||
if server_settings:
|
||||
connect_args["server_settings"] = {
|
||||
k: str(v) for k, v in server_settings.items()
|
||||
}
|
||||
engine = create_async_engine(
|
||||
cfg.database_url, future=True, poolclass=NullPool,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
|
||||
|
||||
@@ -55,3 +55,155 @@ def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
|
||||
return cleanup_service.delete_images(
|
||||
session, image_ids=image_ids, images_root=IMAGES_ROOT,
|
||||
)
|
||||
|
||||
|
||||
# Time-box one chunk well under the soft limit so a large archive back-catalog
|
||||
# can't run the task into the Celery time limit (or hog the maintenance_long
|
||||
# lane). The task re-enqueues itself with the resume cursor until the scan is
|
||||
# exhausted — mirrors normalize_tags_task (operator-asked 2026-06-07: reasonable
|
||||
# timeout, then re-queue so other work keeps flowing).
|
||||
_REEXTRACT_CHUNK_SECONDS = 600
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.admin.reextract_archive_attachments_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 reextract_archive_attachments_task(self, after_id: int = 0) -> dict:
|
||||
"""Wraps cleanup_service.reextract_archive_attachments (#713 part 2):
|
||||
re-extract PostAttachments that are actually archives but were filed
|
||||
opaquely before the magic-byte gate, and link their members to the post.
|
||||
|
||||
Time-boxed + self-resuming: scans attachments after ``after_id`` and, on a
|
||||
chunk cut, re-enqueues from where it stopped so a big backlog finishes across
|
||||
chunks instead of dying at the soft limit."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
summary = cleanup_service.reextract_archive_attachments(
|
||||
session, images_root=IMAGES_ROOT,
|
||||
time_budget_seconds=_REEXTRACT_CHUNK_SECONDS, after_id=after_id,
|
||||
)
|
||||
# More attachments past this chunk's cursor — continue in the next.
|
||||
if summary.get("partial") and summary.get("resume_after_id", 0) > after_id:
|
||||
log.info(
|
||||
"reextract chunk done (%d scanned, %d archives, resume after id %s) "
|
||||
"— re-enqueuing to continue",
|
||||
summary.get("scanned", 0), summary.get("archives", 0),
|
||||
summary["resume_after_id"],
|
||||
)
|
||||
reextract_archive_attachments_task.delay(summary["resume_after_id"])
|
||||
return summary
|
||||
|
||||
|
||||
# Time-box one chunk well under the soft limit so a large back-catalog (the
|
||||
# first run recases the whole booru vocabulary) can't run the task into the
|
||||
# Celery time limit — it timed out at 40 min, operator-flagged 2026-06-07. The
|
||||
# task re-enqueues itself until nothing remains (idempotent — already-canonical
|
||||
# groups are skipped). 600s keeps each chunk short enough that the recovery
|
||||
# sweep and other maintenance tasks interleave on the concurrency-1 queue.
|
||||
_NORMALIZE_CHUNK_SECONDS = 600
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.admin.normalize_tags_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 normalize_tags_task(self) -> dict:
|
||||
"""Wraps tag_service.normalize_existing_tags (#714): Title-Case the
|
||||
back-catalog and merge case/whitespace-variant duplicate tags via the
|
||||
tested async merge path. Time-boxed + self-resuming so a huge first run
|
||||
finishes across chunks instead of timing out. Runs under its own asyncio
|
||||
loop + per-task async engine (NullPool), mirroring download_source."""
|
||||
import asyncio
|
||||
|
||||
from ..services.tag_service import normalize_existing_tags
|
||||
from ._async_session import async_session_factory
|
||||
|
||||
async def _run() -> dict:
|
||||
# lock_timeout=30s: a per-group merge repoints FKs across image_tag and
|
||||
# series_page; if a statement blocks on a lock (e.g. behind a schema
|
||||
# migration holding ACCESS EXCLUSIVE on series_page — the exact wedge that
|
||||
# made this task run to the 40-min hard limit with no progress,
|
||||
# operator-flagged 2026-06-07), it now fails fast. The per-group handler
|
||||
# catches it (rollback + error++) and the loop continues, so one blocked
|
||||
# group can't strand the whole chunk.
|
||||
async_factory, async_engine = async_session_factory(
|
||||
server_settings={"lock_timeout": "30s"}
|
||||
)
|
||||
try:
|
||||
async with async_factory() as session:
|
||||
# normalize_existing_tags commits per group internally.
|
||||
return await normalize_existing_tags(
|
||||
session, dry_run=False,
|
||||
time_budget_seconds=_NORMALIZE_CHUNK_SECONDS,
|
||||
)
|
||||
finally:
|
||||
await async_engine.dispose()
|
||||
|
||||
summary = asyncio.run(_run())
|
||||
# More groups to canonicalize than fit this chunk — continue in the next.
|
||||
if summary.get("partial") and summary.get("remaining", 0) > 0:
|
||||
log.info(
|
||||
"normalize_tags_task chunk done (%d processed, %d remaining) — "
|
||||
"re-enqueuing to continue",
|
||||
summary.get("groups_processed", 0), summary["remaining"],
|
||||
)
|
||||
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
|
||||
|
||||
@@ -41,7 +41,11 @@ def _mark_failed(session, row: BackupRun, exc: BaseException) -> None:
|
||||
bind=True,
|
||||
autoretry_for=(OperationalError, DBAPIError),
|
||||
retry_backoff=10, retry_backoff_max=120, max_retries=2,
|
||||
soft_time_limit=600, time_limit=720,
|
||||
# A pg_dump can't be chunked; the 12-min limit timed out once the DB grew
|
||||
# (operator-flagged 2026-06-07). 30/35 min gives real headroom. (A long
|
||||
# backup still briefly holds the concurrency-1 maintenance lane — the
|
||||
# structural fix is a dedicated lane for the long one-shots.)
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def backup_db_task(self, *, tag: str | None = None,
|
||||
triggered_by: str = "manual") -> dict:
|
||||
|
||||
@@ -31,7 +31,7 @@ _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
|
||||
# SoftTimeLimitExceeded in-process, whereas the HARD limit SIGKILLs the
|
||||
# worker (no chance to finalize). Both gallery-dl subprocess budgets
|
||||
# (gallery_dl.py: _DEFAULT_GDL_TIMEOUT_SECONDS=870 tick,
|
||||
# BACKFILL_TIMEOUT_SECONDS=1170 backfill) MUST sit below the soft limit
|
||||
# BACKFILL_CHUNK_SECONDS=600 per backfill chunk, plan #693) MUST sit below the soft limit
|
||||
# so subprocess.run raises its own TimeoutExpired first — that path
|
||||
# captures partial stdout/stderr and finalizes the DownloadEvent. soft is
|
||||
# max-subprocess (1170) + ~180s phase-3 persist headroom; hard is soft +
|
||||
@@ -44,6 +44,25 @@ _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
|
||||
DOWNLOAD_SOFT_TIME_LIMIT = 1350
|
||||
DOWNLOAD_HARD_TIME_LIMIT = 1500
|
||||
|
||||
# Per-platform serialization (plan: concurrency cap). When a serialized
|
||||
# platform (Patreon) is already walking, defer this run by re-enqueuing it a
|
||||
# little later rather than holding a worker slot or bowling into the same rate
|
||||
# limit. Bounded so a wedged platform eventually runs anyway. The lock TTL sits
|
||||
# just past the hard kill so a SIGKILL'd worker's lock auto-expires; a backfill
|
||||
# chunk only holds it ~10 min, so the bounded wait stays well under the 30-min
|
||||
# DownloadEvent recovery sweep.
|
||||
_PLATFORM_LOCK_TTL = DOWNLOAD_HARD_TIME_LIMIT + 120
|
||||
_SERIALIZE_COUNTDOWN = 20
|
||||
_MAX_SERIALIZE_WAITS = 45 # ~15 min ceiling, then run uncapped as a safety valve
|
||||
|
||||
|
||||
def _peek_platform(source_id: int) -> str | None:
|
||||
SyncFactory = _sync_session_factory()
|
||||
with SyncFactory() as session:
|
||||
return session.execute(
|
||||
select(Source.platform).where(Source.id == source_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _finalize_soft_limited(session: SyncSession, source_id: int) -> None:
|
||||
"""Defense in depth for the soft-time-limit kill path.
|
||||
@@ -75,10 +94,11 @@ def _finalize_soft_limited(session: SyncSession, source_id: int) -> None:
|
||||
ev.finished_at = now
|
||||
ev.error = (
|
||||
f"killed by Celery soft time limit ({DOWNLOAD_SOFT_TIME_LIMIT}s) "
|
||||
"before the gallery-dl subprocess returned — the run exceeded its "
|
||||
"budget and its stdout/stderr were lost with the worker thread. "
|
||||
"If this recurs, the source is too large for one run; the backfill "
|
||||
"budget was decremented so the next tick walks less."
|
||||
"before the download finished — the run exceeded its time budget. "
|
||||
"Progress is checkpointed per page, so the next tick resumes near "
|
||||
"the cut; the backfill budget was decremented so it walks less. If "
|
||||
"this recurs, a single post is heavy enough to overrun the chunk "
|
||||
"time-box on its own."
|
||||
)
|
||||
ev.metadata_ = {
|
||||
**(ev.metadata_ or {}),
|
||||
@@ -107,9 +127,48 @@ def _finalize_soft_limited(session: SyncSession, source_id: int) -> None:
|
||||
soft_time_limit=DOWNLOAD_SOFT_TIME_LIMIT,
|
||||
time_limit=DOWNLOAD_HARD_TIME_LIMIT,
|
||||
)
|
||||
def download_source(self, source_id: int) -> int:
|
||||
def download_source(self, source_id: int, _serialize_waits: int = 0) -> int:
|
||||
"""Returns the DownloadEvent.id."""
|
||||
|
||||
# Per-platform concurrency cap: only one Patreon walk runs at a time.
|
||||
from ..services.platform_lock import platform_lock
|
||||
|
||||
platform = _peek_platform(source_id)
|
||||
lock = (
|
||||
platform_lock(platform, ttl_seconds=_PLATFORM_LOCK_TTL)
|
||||
if platform is not None
|
||||
else None
|
||||
)
|
||||
if lock is not None:
|
||||
try:
|
||||
got_lock = bool(lock.acquire(blocking=False))
|
||||
except Exception: # noqa: BLE001 — broker hiccup → degrade to uncapped
|
||||
log.warning("platform lock acquire failed for %s; running uncapped", platform)
|
||||
lock = None
|
||||
got_lock = True
|
||||
if lock is not None and not got_lock:
|
||||
if _serialize_waits < _MAX_SERIALIZE_WAITS:
|
||||
# Another walk on this platform holds the lock — re-enqueue
|
||||
# ourselves shortly (the pending DownloadEvent stays; no new
|
||||
# event, no log spam) rather than running concurrently into
|
||||
# the rate limit.
|
||||
download_source.apply_async(
|
||||
(source_id,),
|
||||
{"_serialize_waits": _serialize_waits + 1},
|
||||
countdown=_SERIALIZE_COUNTDOWN,
|
||||
)
|
||||
log.info(
|
||||
"download_source(%s) deferred — %s already walking (wait %d/%d)",
|
||||
source_id, platform, _serialize_waits + 1, _MAX_SERIALIZE_WAITS,
|
||||
)
|
||||
return -1
|
||||
log.warning(
|
||||
"download_source(%s) running WITHOUT the %s lock — max serialize "
|
||||
"waits hit, proceeding uncapped",
|
||||
source_id, platform,
|
||||
)
|
||||
lock = None
|
||||
|
||||
async def _run():
|
||||
async_factory, async_engine = async_session_factory()
|
||||
SyncFactory = _sync_session_factory()
|
||||
@@ -143,6 +202,11 @@ def download_source(self, source_id: int) -> int:
|
||||
gdl=gdl,
|
||||
importer=importer,
|
||||
cred_service=cred_service,
|
||||
# The native Patreon ingester opens its own short-lived
|
||||
# sync sessions for the seen-ledger (never held across
|
||||
# the walk). Same factory the importer's sync session
|
||||
# comes from — a different DB connection per checkout.
|
||||
sync_session_factory=SyncFactory,
|
||||
)
|
||||
return await svc.download_source(source_id)
|
||||
finally:
|
||||
@@ -164,3 +228,12 @@ def download_source(self, source_id: int) -> int:
|
||||
except Exception: # noqa: BLE001 — cleanup must not swallow the kill
|
||||
log.exception("soft-limit finalize failed for source %s", source_id)
|
||||
raise
|
||||
finally:
|
||||
# Release the per-platform lock so the next walk can proceed. The TTL is
|
||||
# a backstop for a SIGKILL; here we free it the instant the run ends. A
|
||||
# late release after TTL expiry raises (lock already gone) — ignore it.
|
||||
if lock is not None:
|
||||
try:
|
||||
lock.release()
|
||||
except Exception: # noqa: BLE001 — TTL may have already freed it
|
||||
pass
|
||||
|
||||
@@ -13,6 +13,7 @@ State machine:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -31,6 +32,12 @@ log = logging.getLogger(__name__)
|
||||
_BATCH = 500
|
||||
_PROGRESS_TICK = 100
|
||||
_MAX_MATCHED = 50_000
|
||||
# One chunk's wall-clock budget. Was a single 2h pass that timed out on large
|
||||
# libraries and held the concurrency-1 maintenance queue the whole time
|
||||
# (operator-flagged 2026-06-07). Now: scan ~10 min, persist the keyset cursor +
|
||||
# matches, re-enqueue to continue — so backups/vacuum/normalize chunks can
|
||||
# interleave. soft/hard limits sit just above so the budget fires first.
|
||||
_CHUNK_SECONDS = 600
|
||||
|
||||
_RULES = {
|
||||
"transparency": transparency.evaluate,
|
||||
@@ -46,13 +53,16 @@ _RULES = {
|
||||
retry_backoff_max=60,
|
||||
retry_jitter=True,
|
||||
max_retries=3,
|
||||
soft_time_limit=7200,
|
||||
time_limit=7500,
|
||||
soft_time_limit=900,
|
||||
time_limit=1000,
|
||||
)
|
||||
def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||
"""See module docstring. Returns a small summary dict for eager-mode
|
||||
"""See module docstring. Time-boxed + self-resuming: one call scans a
|
||||
~10-min chunk, persists the resume cursor + matches, and re-enqueues itself
|
||||
until the library is exhausted. Returns a small summary dict for eager-mode
|
||||
test assertions (real workers ignore the return value)."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
start = time.monotonic()
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
audit = session.get(LibraryAuditRun, audit_id)
|
||||
@@ -63,9 +73,10 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||
_mark_error(session, audit_id, f"unknown rule {audit.rule!r}")
|
||||
return {"audit_id": audit_id, "status": "error"}
|
||||
params = dict(audit.params or {})
|
||||
matched: list[int] = []
|
||||
scanned = 0
|
||||
last_id = 0
|
||||
# Resume from the previous chunk's persisted state.
|
||||
matched: list[int] = list(audit.matched_ids or [])
|
||||
scanned = audit.scanned_count or 0
|
||||
last_id = audit.resume_after_id or 0
|
||||
while True:
|
||||
# Cancellation check between batches.
|
||||
current_status = session.execute(
|
||||
@@ -74,6 +85,15 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||
).scalar_one()
|
||||
if current_status == "cancelled":
|
||||
return {"audit_id": audit_id, "status": "cancelled"}
|
||||
# Time-box: persist the cursor + matches and re-enqueue so the
|
||||
# queue is freed between chunks. The next call resumes here.
|
||||
if time.monotonic() - start >= _CHUNK_SECONDS:
|
||||
_persist_chunk(session, audit_id, scanned, matched, last_id)
|
||||
scan_library_for_rule.delay(audit_id)
|
||||
return {
|
||||
"audit_id": audit_id, "status": "running",
|
||||
"partial": True, "scanned": scanned,
|
||||
}
|
||||
rows = session.execute(
|
||||
select(ImageRecord.id, ImageRecord.path)
|
||||
.where(ImageRecord.id > last_id)
|
||||
@@ -114,10 +134,16 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||
)
|
||||
return {"audit_id": audit_id, "status": "error"}
|
||||
if scanned % _PROGRESS_TICK == 0:
|
||||
# Cheap heartbeat: scanned_count + last_progress_at so the
|
||||
# recovery sweep sees the multi-chunk audit is alive. The
|
||||
# cursor + matches are persisted at chunk boundaries.
|
||||
session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.id == audit_id)
|
||||
.values(scanned_count=scanned)
|
||||
.values(
|
||||
scanned_count=scanned,
|
||||
last_progress_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
# Final state.
|
||||
@@ -128,8 +154,10 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||
scanned_count=scanned,
|
||||
matched_count=len(matched),
|
||||
matched_ids=matched,
|
||||
resume_after_id=last_id,
|
||||
status="ready",
|
||||
finished_at=datetime.now(UTC),
|
||||
last_progress_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@@ -140,9 +168,14 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||
"matched": len(matched),
|
||||
}
|
||||
except SoftTimeLimitExceeded:
|
||||
with SessionLocal() as session:
|
||||
_mark_error(session, audit_id, "soft_time_limit exceeded (>7200s)")
|
||||
raise
|
||||
# Backstop (the in-chunk budget should fire first): the audit stays
|
||||
# 'running' with its last committed cursor; re-enqueue to continue from
|
||||
# there rather than marking the whole run an error.
|
||||
log.warning(
|
||||
"audit %s: soft time limit hit — re-enqueuing to resume", audit_id,
|
||||
)
|
||||
scan_library_for_rule.delay(audit_id)
|
||||
return {"audit_id": audit_id, "status": "running", "partial": True}
|
||||
except (OperationalError, DBAPIError):
|
||||
# Retryable per the decorator; leave row in 'running' and let
|
||||
# autoretry try again. Recovery sweep catches if all retries fail.
|
||||
@@ -154,6 +187,23 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||
raise
|
||||
|
||||
|
||||
def _persist_chunk(session, audit_id, scanned, matched, last_id) -> None:
|
||||
"""Persist a chunk boundary: scanned count, matches so far, and the keyset
|
||||
cursor the next chunk resumes from. Keeps status='running'."""
|
||||
session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.id == audit_id)
|
||||
.values(
|
||||
scanned_count=scanned,
|
||||
matched_count=len(matched),
|
||||
matched_ids=list(matched),
|
||||
resume_after_id=last_id,
|
||||
last_progress_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _mark_error(session, audit_id: int, error_msg: str) -> None:
|
||||
session.execute(
|
||||
update(LibraryAuditRun)
|
||||
|
||||
@@ -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()
|
||||
@@ -647,19 +656,30 @@ def recover_stalled_library_audit_runs() -> int:
|
||||
guard in start_audit_run — a SIGKILL'd run would block all future
|
||||
audits until manual DB surgery. (The guard is now age-aware, but
|
||||
this sweep is what makes that work in practice.)
|
||||
|
||||
Measures staleness from last_progress_at (alembic 0039), NOT started_at:
|
||||
a chunked scan stays 'running' across many re-enqueued chunks and can
|
||||
legitimately run for hours on a big library — only flag one that hasn't
|
||||
made progress in the threshold window (a dead chunk that never re-enqueued).
|
||||
Falls back to started_at for pre-0039 / never-ticked rows.
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES)
|
||||
msg = (
|
||||
f"stranded by recovery sweep (no terminal status after "
|
||||
f"stranded by recovery sweep (no progress for "
|
||||
f"{LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES} min)"
|
||||
)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.status == "running")
|
||||
.where(LibraryAuditRun.started_at < cutoff)
|
||||
.where(
|
||||
func.coalesce(
|
||||
LibraryAuditRun.last_progress_at,
|
||||
LibraryAuditRun.started_at,
|
||||
) < cutoff
|
||||
)
|
||||
.values(status="error", finished_at=now, error=msg)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_MAX_EXT_LEN = 16
|
||||
|
||||
|
||||
def safe_ext(name: str | Path) -> str:
|
||||
"""Conservatively extract a short, alphanumeric file extension.
|
||||
|
||||
gallery-dl and Patreon CDN URLs produce basenames with URL-encoded
|
||||
query-string artifacts, so `Path.suffix` can return 50+ chars of base64-ish
|
||||
junk that blows bounded VARCHAR columns (e.g. PostAttachment.ext varchar(32)).
|
||||
Accept only a suffix ≤16 chars whose post-dot characters are all alphanumeric;
|
||||
otherwise return "" (no known extension). Operator-flagged 2026-05-25 — ONE
|
||||
impl for the importer and the native Patreon client.
|
||||
"""
|
||||
suffix = Path(name).suffix.lower()
|
||||
if not suffix or len(suffix) > _MAX_EXT_LEN:
|
||||
return ""
|
||||
if not all(c.isalnum() for c in suffix[1:]):
|
||||
return ""
|
||||
return suffix
|
||||
|
||||
|
||||
def derive_subdir(source_path: Path, import_root: Path) -> str:
|
||||
"""Returns the relative subdirectory of source_path under import_root.
|
||||
|
||||
@@ -149,9 +149,8 @@ def _run_probe(path_str: str) -> tuple[str, str | None]:
|
||||
call the same code path.
|
||||
"""
|
||||
path = Path(path_str)
|
||||
ext = path.suffix.lower()
|
||||
try:
|
||||
total, test_bad = _inspect_archive(path, ext)
|
||||
total, test_bad = _inspect_archive(path)
|
||||
except Exception as exc: # noqa: BLE001 — clean rejection
|
||||
return ("error", f"{type(exc).__name__}: {exc}")
|
||||
if total is not None and total > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
|
||||
@@ -162,24 +161,29 @@ def _run_probe(path_str: str) -> tuple[str, str | None]:
|
||||
return ("ok", None)
|
||||
|
||||
|
||||
def _inspect_archive(path: Path, ext: str):
|
||||
def _inspect_archive(path: Path):
|
||||
"""Return (total_uncompressed_bytes | None, first_bad_member | None)
|
||||
for the archive. Format-specific; raises on a structurally-broken
|
||||
container (caught by the child as a clean rejection)."""
|
||||
if ext in (".zip", ".cbz"):
|
||||
for the archive. Format detected by extension OR magic bytes (so a
|
||||
mis-named archive is still bomb-guarded + integrity-tested, matching the
|
||||
extractor's gate); raises on a structurally-broken container (caught by the
|
||||
child as a clean rejection)."""
|
||||
from ..services.archive_extractor import detect_archive_format
|
||||
|
||||
fmt = detect_archive_format(path)
|
||||
if fmt == "zip":
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
total = sum(zi.file_size for zi in zf.infolist())
|
||||
return total, zf.testzip()
|
||||
if ext == ".rar":
|
||||
if fmt == "rar":
|
||||
import rarfile
|
||||
|
||||
with rarfile.RarFile(path) as rf:
|
||||
total = sum(getattr(ri, "file_size", 0) for ri in rf.infolist())
|
||||
rf.testrar()
|
||||
return total, None
|
||||
if ext == ".7z":
|
||||
if fmt == "7z":
|
||||
import py7zr
|
||||
|
||||
with py7zr.SevenZipFile(path, "r") as zf:
|
||||
@@ -187,5 +191,5 @@ def _inspect_archive(path: Path, ext: str):
|
||||
total = getattr(info, "uncompressed", None)
|
||||
ok = zf.test() # True / None when all members pass
|
||||
return total, (None if ok in (True, None) else "7z test reported corruption")
|
||||
# Unknown extension — nothing to test; treat as clean.
|
||||
# Not a recognised archive — nothing to test; treat as clean.
|
||||
return None, None
|
||||
|
||||
@@ -35,6 +35,15 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app/backend
|
||||
|
||||
maintenance-long:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
LOG_LEVEL: DEBUG
|
||||
volumes:
|
||||
- ./backend:/app/backend
|
||||
|
||||
ml-worker:
|
||||
build:
|
||||
context: .
|
||||
|
||||
+32
-3
@@ -27,6 +27,17 @@ services:
|
||||
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
|
||||
@@ -52,7 +63,6 @@ services:
|
||||
volumes:
|
||||
- ./images:/images
|
||||
- ./import:/import
|
||||
- ./downloads:/downloads
|
||||
# FC-5 legacy migration: bind-mount the host's ImageRepo images dir
|
||||
# under /import (FC's existing filesystem scan picks them up). Read-only
|
||||
# is sufficient — FC copies into /images during the scan. The worker +
|
||||
@@ -71,10 +81,11 @@ services:
|
||||
<<: *app_env
|
||||
CELERY_QUEUES: default,import,thumbnail,download
|
||||
CELERY_CONCURRENCY: "2"
|
||||
# /downloads dropped — nothing in the app references it (operator-flagged
|
||||
# 2026-06-07: it wasn't mapped in prod and everything worked).
|
||||
volumes:
|
||||
- ./images:/images
|
||||
- ./import:/import
|
||||
- ./downloads:/downloads
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
@@ -88,7 +99,25 @@ services:
|
||||
volumes:
|
||||
- ./images:/images
|
||||
- ./import:/import
|
||||
- ./downloads:/downloads
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
# Dedicated lane for long one-shot maintenance (DB backups, library audits,
|
||||
# admin maintenance). Kept off the scheduler's quick `maintenance` lane so a
|
||||
# 30-min backup or a multi-chunk audit can never starve the 5-min recovery
|
||||
# sweeps / vacuum (operator-flagged 2026-06-07). One slot — these are heavy.
|
||||
maintenance-long:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
command: ["worker"]
|
||||
environment:
|
||||
<<: *app_env
|
||||
CELERY_QUEUES: maintenance_long
|
||||
CELERY_CONCURRENCY: "1"
|
||||
# Only /images: backups write to /images/_backups, audits read /images, and
|
||||
# the admin tasks (re-extract/cascade-delete/normalize) operate on /images.
|
||||
volumes:
|
||||
- ./images:/images
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
@@ -49,9 +49,11 @@ function onCardClick() {
|
||||
position: relative;
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2px; aspect-ratio: 3 / 1;
|
||||
/* Explicit floor + ceiling so tall source images can't escape the
|
||||
preview slot even on browsers where aspect-ratio doesn't compute. */
|
||||
min-height: 150px; max-height: 220px;
|
||||
/* Floor so tall source images can't escape the slot. NO max-height: an
|
||||
aspect-ratio box capped by max-height shrinks its own WIDTH to keep the
|
||||
ratio, leaving dead space beside the previews on a wide (single-column)
|
||||
card. The grid below keeps columns ≲400px so the strip stays a sane height. */
|
||||
min-height: 120px;
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
|
||||
@@ -2,17 +2,37 @@
|
||||
<v-card>
|
||||
<v-card-title>Pick a fandom</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- Keyboard flow (operator-specified 2026-06-07):
|
||||
1. Focus lands here (driven by the dialog's @after-enter calling the
|
||||
exposed focusSearch — `autofocus` is unreliable inside a v-dialog
|
||||
because the focus-trap activates after mount and steals it).
|
||||
2. Tab moves to the "new fandom" field below.
|
||||
3. Selecting a fandom (click, or arrow+Enter to pick from the menu)
|
||||
leaves it in the field; a SECOND Enter (menu closed) accepts it.
|
||||
We bind v-model:menu so the Enter handler can tell "pick from the open
|
||||
menu" (let Vuetify handle) apart from "accept the chosen value". -->
|
||||
<v-autocomplete
|
||||
ref="fandomRef"
|
||||
v-model="selectedId"
|
||||
v-model:menu="menuOpen"
|
||||
:items="store.fandomCache"
|
||||
:item-title="(f) => f.name"
|
||||
:item-value="(f) => f.id"
|
||||
label="Fandom" clearable density="compact"
|
||||
@keydown.enter.capture="onSearchEnter"
|
||||
@keydown.tab="onSearchTab"
|
||||
/>
|
||||
<v-divider class="my-3" />
|
||||
<p class="text-caption mb-2">Or create a new fandom:</p>
|
||||
<div class="d-flex" style="gap: 8px;">
|
||||
<v-text-field v-model="newName" placeholder="New fandom name" density="compact" hide-details />
|
||||
<!-- Enter creates; on success focus returns to the dropdown (onCreate),
|
||||
so the operator can immediately Enter-to-accept the new fandom. -->
|
||||
<v-text-field
|
||||
ref="newNameRef"
|
||||
v-model="newName" placeholder="New fandom name"
|
||||
density="compact" hide-details
|
||||
@keydown.enter.prevent="onCreate"
|
||||
/>
|
||||
<v-btn :disabled="!newName.trim()" rounded="pill" @click="onCreate">Create</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
@@ -29,7 +49,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
@@ -37,21 +57,69 @@ const store = useTagStore()
|
||||
|
||||
const selectedId = ref(null)
|
||||
const newName = ref('')
|
||||
const menuOpen = ref(false)
|
||||
const fandomRef = ref(null)
|
||||
const newNameRef = ref(null)
|
||||
|
||||
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
|
||||
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
||||
onMounted(() => store.loadFandoms())
|
||||
|
||||
async function onCreate() {
|
||||
const f = await store.createFandom(newName.value.trim())
|
||||
// Exposed so the parent dialog can focus the search field on @after-enter —
|
||||
// the reliable point to grab focus (the dialog transition + focus-trap are done).
|
||||
function focusSearch () {
|
||||
nextTick(() => {
|
||||
fandomRef.value?.focus?.()
|
||||
// Keep the menu closed after a programmatic focus so the very next Enter
|
||||
// accepts the value instead of being swallowed as a menu interaction.
|
||||
menuOpen.value = false
|
||||
})
|
||||
}
|
||||
defineExpose({ focusSearch })
|
||||
|
||||
async function onCreate () {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
const f = await store.createFandom(name)
|
||||
selectedId.value = f.id
|
||||
newName.value = ''
|
||||
// The created fandom is now the selection; hand focus back to the dropdown
|
||||
// so a single Enter accepts it (operator-specified flow 2026-06-07).
|
||||
focusSearch()
|
||||
}
|
||||
function onConfirm() {
|
||||
|
||||
function onConfirm () {
|
||||
const f = store.fandomCache.find(x => x.id === selectedId.value)
|
||||
if (f) emit('confirm', f)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
// Tab from the search field goes to the "new fandom" text field (operator-
|
||||
// specified). Shift+Tab keeps normal reverse traversal.
|
||||
function onSearchTab (e) {
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
menuOpen.value = false
|
||||
nextTick(() => newNameRef.value?.focus?.())
|
||||
}
|
||||
|
||||
// Create the character with no fandom. Emits null so the caller knows this
|
||||
// was a deliberate "unassigned", not a cancel.
|
||||
function onNoFandom() {
|
||||
function onNoFandom () {
|
||||
emit('confirm', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,19 +3,29 @@
|
||||
<v-card-title class="text-body-1">Fandom for “{{ tag.name }}”</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="!collision">
|
||||
<!-- Keyboard flow matches FandomPicker (operator-specified 2026-06-07):
|
||||
focus lands here via the parent dialog's @after-enter → focusSearch
|
||||
(autofocus is unreliable inside a v-dialog focus-trap); Tab moves to
|
||||
the new-fandom field; a SECOND Enter (menu closed) accepts the choice
|
||||
by Saving instead of re-opening the dropdown. -->
|
||||
<v-autocomplete
|
||||
ref="fandomRef"
|
||||
v-model="selectedId"
|
||||
v-model:menu="menuOpen"
|
||||
:items="store.fandomCache"
|
||||
:item-title="(f) => f.name" :item-value="(f) => f.id"
|
||||
label="Fandom" clearable density="compact"
|
||||
:hint="selectedId == null
|
||||
? 'No fandom — the character will be unassigned.' : ''"
|
||||
persistent-hint
|
||||
@keydown.enter.capture="onSearchEnter"
|
||||
@keydown.tab="onSearchTab"
|
||||
/>
|
||||
<v-divider class="my-3" />
|
||||
<p class="text-caption mb-2">Or create a new fandom:</p>
|
||||
<div class="d-flex" style="gap: 8px;">
|
||||
<v-text-field
|
||||
ref="newNameRef"
|
||||
v-model="newName" placeholder="New fandom name"
|
||||
density="compact" hide-details
|
||||
@keydown.enter.prevent="onCreate"
|
||||
@@ -70,7 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
const props = defineProps({ tag: { type: Object, required: true } })
|
||||
@@ -83,8 +93,24 @@ const newName = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref(null)
|
||||
const collision = ref(null)
|
||||
const menuOpen = ref(false)
|
||||
const fandomRef = ref(null)
|
||||
const newNameRef = ref(null)
|
||||
|
||||
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
|
||||
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
||||
onMounted(() => store.loadFandoms())
|
||||
|
||||
// Exposed so the parent dialog can focus the search field on @after-enter —
|
||||
// the reliable point to grab focus (the dialog transition + focus-trap are done).
|
||||
function focusSearch() {
|
||||
nextTick(() => {
|
||||
fandomRef.value?.focus?.()
|
||||
// Keep the menu closed after a programmatic focus so the very next Enter
|
||||
// accepts the value (Saves) instead of being swallowed as a menu interaction.
|
||||
menuOpen.value = false
|
||||
})
|
||||
}
|
||||
defineExpose({ focusSearch })
|
||||
|
||||
async function onCreate() {
|
||||
const name = newName.value.trim()
|
||||
@@ -95,6 +121,9 @@ async function onCreate() {
|
||||
const f = await store.createFandom(name)
|
||||
selectedId.value = f.id
|
||||
newName.value = ''
|
||||
// The created fandom is now the selection; hand focus back to the dropdown
|
||||
// so a single Enter saves it (matches FandomPicker's flow).
|
||||
focusSearch()
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e)
|
||||
} finally {
|
||||
@@ -102,6 +131,29 @@ async function onCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on the search field — bound in the CAPTURE phase so it runs BEFORE
|
||||
// Vuetify's own handler (which opens the menu on Enter). Menu open → let Vuetify
|
||||
// pick the highlighted item. Menu closed with a changed selection → Save and stop
|
||||
// the event so Vuetify never (re)opens the dropdown (the bug this fixes).
|
||||
function onSearchEnter(e) {
|
||||
if (menuOpen.value) return
|
||||
if (busy.value) return
|
||||
if (selectedId.value !== (props.tag.fandom_id ?? null)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
|
||||
// Tab from the search field goes to the "new fandom" text field (operator-
|
||||
// specified). Shift+Tab keeps normal reverse traversal.
|
||||
function onSearchTab(e) {
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
menuOpen.value = false
|
||||
nextTick(() => newNameRef.value?.focus?.())
|
||||
}
|
||||
|
||||
async function save(merge) {
|
||||
busy.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -8,6 +8,25 @@
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</button>
|
||||
|
||||
<!-- C8: keyboard cheatsheet. '?' toggles; the corner hint advertises it. -->
|
||||
<button
|
||||
class="fc-viewer__help-hint" aria-label="Keyboard shortcuts (press ?)"
|
||||
@click="showHelp = !showHelp"
|
||||
>?</button>
|
||||
<div v-if="showHelp" class="fc-viewer__help" @click.self="showHelp = false">
|
||||
<div class="fc-viewer__help-card" role="dialog" aria-label="Keyboard shortcuts">
|
||||
<h3>Keyboard shortcuts</h3>
|
||||
<dl>
|
||||
<div><dt>← / →</dt><dd>Previous / next image</dd></div>
|
||||
<div><dt>T or /</dt><dd>Jump to the tag input</dd></div>
|
||||
<div><dt>↑ / ↓</dt><dd>Move through tag suggestions</dd></div>
|
||||
<div><dt>Enter / Tab</dt><dd>Accept the highlighted tag</dd></div>
|
||||
<div><dt>Esc</dt><dd>Close a dialog, then the viewer</dd></div>
|
||||
<div><dt>?</dt><dd>Toggle this help</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-chip
|
||||
v-if="integrityBadge"
|
||||
:color="integrityBadge.color"
|
||||
@@ -28,12 +47,17 @@
|
||||
<v-icon size="32">mdi-chevron-right</v-icon>
|
||||
</button>
|
||||
|
||||
<!-- Large centered loading spinner (operator-flagged 2026-06-07: the old
|
||||
size-36 one sat tiny in the top-left). Dual counter-rotating accent
|
||||
rings, centered over the whole modal. pointer-events:none so the
|
||||
close/nav controls underneath stay clickable. -->
|
||||
<div v-if="modal.loading" class="fc-viewer__loading">
|
||||
<div class="fc-viewer__spinner" />
|
||||
</div>
|
||||
|
||||
<div class="fc-viewer__body">
|
||||
<div class="fc-viewer__media">
|
||||
<v-progress-circular
|
||||
v-if="modal.loading" indeterminate color="accent" size="36"
|
||||
/>
|
||||
<template v-else-if="modal.current">
|
||||
<template v-if="modal.current">
|
||||
<ImageCanvas
|
||||
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
|
||||
@close-request="$emit('close')"
|
||||
@@ -63,7 +87,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import { arrowNavAllowed } from '../../utils/textEntry.js'
|
||||
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
|
||||
import ImageCanvas from './ImageCanvas.vue'
|
||||
import VideoCanvas from './VideoCanvas.vue'
|
||||
import TagPanel from './TagPanel.vue'
|
||||
@@ -74,6 +98,7 @@ const emit = defineEmits(['close'])
|
||||
|
||||
const modal = useModalStore()
|
||||
const rootEl = ref(null)
|
||||
const showHelp = ref(false)
|
||||
|
||||
const isVideo = computed(() =>
|
||||
modal.current?.mime && modal.current.mime.startsWith('video/')
|
||||
@@ -96,17 +121,27 @@ let prevBodyOverflow = null
|
||||
// that. Filtered via isTextEntry so tag/comment inputs still get their
|
||||
// own keystrokes.
|
||||
function onKeyDown(ev) {
|
||||
if (ev.key === 'Escape' && showHelp.value) {
|
||||
// Close the cheatsheet first; a second Esc closes the modal.
|
||||
ev.preventDefault()
|
||||
showHelp.value = false
|
||||
return
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
// Escape closes the modal even from inside a text input — that's
|
||||
// the universal "get me out of here" expectation, and the
|
||||
// autofocused tag-entry field would otherwise trap focus with no
|
||||
// visible escape (operator-flagged 2026-06-01). EXCEPTION: when a
|
||||
// nested Vuetify overlay is open (v-menu autocomplete dropdown,
|
||||
// FandomPicker v-dialog, per-suggestion 3-dot menu), let that
|
||||
// overlay's own Esc handling fire instead of closing the whole
|
||||
// modal mid-interaction. Vuetify marks open overlays with
|
||||
// `.v-overlay--active`.
|
||||
if (document.querySelector('.v-overlay--active')) return
|
||||
// Escape closes the modal even from inside a text input — the universal
|
||||
// "get me out of here" expectation; the autofocused tag field would
|
||||
// otherwise trap focus (operator-flagged 2026-06-01). EXCEPTION: when the
|
||||
// keystroke originates INSIDE an open Vuetify overlay's content (a rename/
|
||||
// fandom/alias dialog, or a kebab menu), let that overlay handle its own
|
||||
// Esc and don't close the whole modal.
|
||||
//
|
||||
// #700 re-fix (2026-06-07): the prior guard queried for ANY active overlay
|
||||
// anywhere in the DOM and suppressed the close — so a lingering overlay
|
||||
// after accepting a suggestion (focus drops to <body>, not into any
|
||||
// overlay) wrongly blocked Esc. Keying off the event's origin instead means
|
||||
// a stray overlay no longer traps the modal: only an Esc pressed from
|
||||
// within overlay content defers to that overlay.
|
||||
if (ev.target?.closest?.('.v-overlay__content')) return
|
||||
ev.preventDefault()
|
||||
emit('close')
|
||||
} else if (ev.key === 'ArrowLeft') {
|
||||
@@ -119,6 +154,14 @@ function onKeyDown(ev) {
|
||||
if (!arrowNavAllowed(ev.target)) return
|
||||
ev.preventDefault()
|
||||
modal.goNext()
|
||||
} else if ((ev.key === '/' || ev.key === 't') && !isTextEntry(ev.target)) {
|
||||
// C9 (2026-06-07): jump focus to the tag input from anywhere in the modal.
|
||||
const input = document.querySelector('.fc-tag-autocomplete input')
|
||||
if (input) { ev.preventDefault(); input.focus() }
|
||||
} else if (ev.key === '?' && !isTextEntry(ev.target)) {
|
||||
// C8: toggle the keyboard cheatsheet.
|
||||
ev.preventDefault()
|
||||
showHelp.value = !showHelp.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +217,43 @@ function nextFrame() {
|
||||
}
|
||||
.fc-viewer__nav:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.fc-viewer__close { top: 16px; right: 16px; transform: none; }
|
||||
.fc-viewer__help-hint {
|
||||
position: absolute; top: 16px; right: 64px; z-index: 2;
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-weight: 700; cursor: pointer; opacity: 0.6;
|
||||
}
|
||||
.fc-viewer__help-hint:hover { opacity: 1; }
|
||||
.fc-viewer__help {
|
||||
position: absolute; inset: 0; z-index: 4;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.fc-viewer__help-card {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 10px; padding: 20px 24px; min-width: 320px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.fc-viewer__help-card h3 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 18px; margin-bottom: 12px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-viewer__help-card dl { display: flex; flex-direction: column; gap: 8px; }
|
||||
.fc-viewer__help-card dl > div {
|
||||
display: flex; gap: 16px; align-items: baseline;
|
||||
}
|
||||
.fc-viewer__help-card dt {
|
||||
flex: 0 0 96px; text-align: right;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-viewer__help-card dd {
|
||||
flex: 1; font-size: 14px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-viewer__integrity {
|
||||
position: absolute; top: 72px; right: 16px; z-index: 3;
|
||||
}
|
||||
@@ -184,6 +264,34 @@ function nextFrame() {
|
||||
right: calc(var(--fc-side-w) + 16px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
/* Centered loading overlay — sits over the media band; non-interactive so the
|
||||
close/nav controls underneath keep working. */
|
||||
.fc-viewer__loading {
|
||||
position: absolute; inset: 0; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Two concentric accent rings counter-rotating, each lit on a different arc —
|
||||
reads as one orbiting object. ~108px so it's unmistakably the focal point. */
|
||||
.fc-viewer__spinner {
|
||||
width: 108px; height: 108px; border-radius: 50%;
|
||||
border: 5px solid rgb(var(--v-theme-accent), 0.16);
|
||||
border-top-color: rgb(var(--v-theme-accent));
|
||||
animation: fc-viewer-spin 0.95s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
|
||||
position: relative;
|
||||
box-shadow: 0 0 24px rgb(var(--v-theme-accent), 0.18);
|
||||
}
|
||||
.fc-viewer__spinner::after {
|
||||
content: ''; position: absolute; inset: 14px; border-radius: 50%;
|
||||
border: 5px solid rgb(var(--v-theme-accent), 0.10);
|
||||
border-bottom-color: rgb(var(--v-theme-accent));
|
||||
animation: fc-viewer-spin 1.5s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite reverse;
|
||||
}
|
||||
@keyframes fc-viewer-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-viewer__spinner, .fc-viewer__spinner::after { animation-duration: 3s; }
|
||||
}
|
||||
|
||||
.fc-viewer__body {
|
||||
flex: 1; display: flex; min-height: 0;
|
||||
}
|
||||
@@ -203,14 +311,37 @@ function nextFrame() {
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fc-viewer__body { flex-direction: column; }
|
||||
/* Side panel drops below the image — the next arrow uses the full width. */
|
||||
.fc-viewer__nav--next { right: 16px; }
|
||||
/* Stacked layout (operator-flagged 2026-06-05): pin the image pane and
|
||||
its controls at the top while the metadata panel scrolls beneath it.
|
||||
Previously the image + a 40vh panel shared one fixed viewport and the
|
||||
controls could be pushed out of view; now the body scrolls and the
|
||||
media is sticky, so the image + prev/next/close (and the integrity
|
||||
badge) stay visible no matter how far the panel is scrolled. */
|
||||
.fc-viewer__body {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.fc-viewer__media {
|
||||
flex: none;
|
||||
height: 55vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
/* Opaque obsidian so the scrolling panel never bleeds through the
|
||||
haze behind the pinned image. */
|
||||
background: rgb(20, 23, 26);
|
||||
}
|
||||
.fc-viewer__side {
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
max-height: none;
|
||||
border-left: none;
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
/* Re-center the prev/next arrows over the 55vh image band (their base
|
||||
top:50% would land on the scrolling panel); next uses the full width
|
||||
now that the panel is below, not beside. Close + integrity badge keep
|
||||
their top:16px/72px — already within the pinned band. */
|
||||
.fc-viewer__nav { top: 27.5vh; }
|
||||
.fc-viewer__nav--next { right: 16px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
/>
|
||||
<v-menu
|
||||
v-model="menuOpen" activator="parent" :open-on-click="false"
|
||||
:z-index="2400"
|
||||
>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="$emit('alias', suggestion)">
|
||||
|
||||
@@ -8,46 +8,87 @@
|
||||
@keydown.down.prevent="moveHighlight(1)"
|
||||
@keydown.up.prevent="moveHighlight(-1)"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
@keydown.tab="onTab"
|
||||
@keydown.esc="$emit('cancel')"
|
||||
/>
|
||||
<v-list
|
||||
v-if="hits.length || allowCreate"
|
||||
v-if="rows.length"
|
||||
ref="listRef"
|
||||
density="compact" class="fc-tag-autocomplete__list"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(h, idx) in hits" :key="h.id"
|
||||
:active="idx === highlight" @click="onPick(h)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(h.kind)">
|
||||
{{ iconFor(h.kind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ h.name }}
|
||||
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ h.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="allowCreate" :active="highlight === hits.length"
|
||||
@click="onCreate"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(parsedKind)">
|
||||
{{ iconFor(parsedKind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
Create "{{ parsedName }}" as {{ parsedKind }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<template v-for="(row, idx) in rows" :key="row.key">
|
||||
<!-- Existing tag match (server autocomplete). -->
|
||||
<v-list-item
|
||||
v-if="row.type === 'hit'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(row.hit.kind)">
|
||||
{{ iconFor(row.hit.kind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ row.hit.name }}
|
||||
<span v-if="row.hit.fandom_name" class="text-caption">— {{ row.hit.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ row.hit.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- ML suggestion for THIS image that matches the typed query. Picking
|
||||
it routes through the same accept path as the Suggestions panel, so
|
||||
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
|
||||
<v-list-item
|
||||
v-else-if="row.type === 'suggestion'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
class="fc-tag-autocomplete__sugg"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(row.sugg.category)">
|
||||
{{ iconFor(row.sugg.category) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ row.sugg.display_name }}
|
||||
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="fc-tag-autocomplete__sugg-tag">
|
||||
<v-icon size="x-small">mdi-auto-fix</v-icon>
|
||||
{{ scorePct(row.sugg) }}
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Create-new row. -->
|
||||
<v-list-item
|
||||
v-else
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(parsedKind)">
|
||||
{{ iconFor(parsedKind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
Create "{{ parsedName }}" as {{ parsedKind }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
<v-dialog v-model="fandomDialog" max-width="480">
|
||||
<FandomPicker @confirm="onFandomChosen" @cancel="fandomDialog = false" />
|
||||
<!-- @after-enter is the reliable moment to focus the picker: the dialog's
|
||||
open transition + focus-trap have settled, so focusSearch lands in the
|
||||
fandom field instead of fighting the trap (operator-flagged 2026-06-07). -->
|
||||
<v-dialog
|
||||
v-model="fandomDialog" max-width="480"
|
||||
@after-enter="fandomPickerRef?.focusSearch?.()"
|
||||
>
|
||||
<FandomPicker
|
||||
ref="fandomPickerRef"
|
||||
@confirm="onFandomChosen" @cancel="onFandomCancel"
|
||||
/>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,18 +96,33 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||
import FandomPicker from './FandomPicker.vue'
|
||||
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
|
||||
const store = useTagStore()
|
||||
// The image's ML (Camie) suggestions, surfaced inline in this dropdown so the
|
||||
// operator can pick a suggestion while typing instead of hunting for it in the
|
||||
// Suggestions panel below (operator-asked 2026-06-07).
|
||||
const suggestions = useSuggestionsStore()
|
||||
|
||||
// Autofocus on modal open so the operator can type the moment the view
|
||||
// modal renders, no extra click required (operator-asked 2026-06-01).
|
||||
// Vuetify's v-text-field exposes .focus() on the component instance;
|
||||
// nextTick waits for the modal's mount to finish so the inner <input>
|
||||
// element exists.
|
||||
//
|
||||
// EXCEPTION — not on mobile (operator-flagged 2026-06-05): focusing the
|
||||
// field pops the soft keyboard, which shrinks the visual viewport and
|
||||
// shoves the pinned image + its nav/close controls out of view. 900px is
|
||||
// the modal's stacked-layout breakpoint (ImageViewer.vue). matchMedia is
|
||||
// safe on plain HTTP (not a secure-context API).
|
||||
const inputRef = ref(null)
|
||||
onMounted(() => { nextTick(() => inputRef.value?.focus?.()) })
|
||||
function focusInput () {
|
||||
if (window.matchMedia?.('(max-width: 900px)')?.matches) return
|
||||
nextTick(() => inputRef.value?.focus?.())
|
||||
}
|
||||
onMounted(focusInput)
|
||||
|
||||
// Single text input; no kind dropdown. Client-side mirror of the
|
||||
// backend's parse_kind_prefix lives below — kept in sync with
|
||||
@@ -76,7 +132,9 @@ onMounted(() => { nextTick(() => inputRef.value?.focus?.()) })
|
||||
const query = ref('')
|
||||
const hits = ref([])
|
||||
const highlight = ref(0)
|
||||
const listRef = ref(null)
|
||||
const fandomDialog = ref(false)
|
||||
const fandomPickerRef = ref(null)
|
||||
let pendingNewName = null
|
||||
|
||||
const KNOWN_KINDS = new Set([
|
||||
@@ -124,13 +182,66 @@ const allowCreate = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
|
||||
|
||||
// This image's suggestions that match the typed query, minus any the server
|
||||
// autocomplete already returned (same name+kind) so a tag never shows twice.
|
||||
const suggestionHits = computed(() => {
|
||||
const q = parsedName.value.toLowerCase()
|
||||
if (!q) return []
|
||||
const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
|
||||
const out = []
|
||||
for (const list of Object.values(suggestions.byCategory)) {
|
||||
for (const s of list || []) {
|
||||
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
||||
if (!s.display_name.toLowerCase().includes(q)) continue
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(s)
|
||||
}
|
||||
}
|
||||
// Best matches first; cap so the dropdown stays scannable.
|
||||
out.sort((a, b) => b.score - a.score)
|
||||
return out.slice(0, 6)
|
||||
})
|
||||
|
||||
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
||||
// highlight index maps 1:1 to a row regardless of which section it's in.
|
||||
const rows = computed(() => {
|
||||
const r = hits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h }))
|
||||
for (const s of suggestionHits.value) {
|
||||
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
|
||||
}
|
||||
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
|
||||
return r
|
||||
})
|
||||
|
||||
function moveHighlight (delta) {
|
||||
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
||||
const total = rows.value.length
|
||||
if (total === 0) return
|
||||
highlight.value = (highlight.value + delta + total) % total
|
||||
// Keep the highlighted row visible — the list is capped at 240px and arrowing
|
||||
// past the fold otherwise left the active item off-screen (operator-flagged
|
||||
// 2026-06-07). block:'nearest' scrolls the minimum needed.
|
||||
nextTick(() => {
|
||||
listRef.value?.$el
|
||||
?.querySelector('.v-list-item--active')
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
|
||||
function onPick (hit) { emit('pick-existing', hit); reset() }
|
||||
// Dispatch a chosen dropdown row by its type.
|
||||
function onPickRow (row) {
|
||||
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
|
||||
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
|
||||
else { onCreate() }
|
||||
}
|
||||
|
||||
// Picking an ML suggestion hands it to the parent, which runs the same accept
|
||||
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
|
||||
// drops it from the panel). reset() clears the query; the panel-drop makes it
|
||||
// fall out of suggestionHits reactively.
|
||||
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
|
||||
|
||||
function onCreate () {
|
||||
const name = parsedName.value
|
||||
@@ -158,13 +269,31 @@ function onFandomChosen (fandom) {
|
||||
})
|
||||
pendingNewName = null
|
||||
reset()
|
||||
// Return focus to the tag input so the keyboard flow continues straight into
|
||||
// the next tag instead of dropping to <body> (operator-flagged 2026-06-07).
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// Cancelling the fandom dialog drops the pending character and hands focus back
|
||||
// to the tag input, same as a successful pick.
|
||||
function onFandomCancel () {
|
||||
fandomDialog.value = false
|
||||
pendingNewName = null
|
||||
focusInput()
|
||||
}
|
||||
|
||||
function onEnter () {
|
||||
if (highlight.value < hits.value.length) {
|
||||
onPick(hits.value[highlight.value])
|
||||
} else if (allowCreate.value) {
|
||||
onCreate()
|
||||
const row = rows.value[highlight.value]
|
||||
if (row) onPickRow(row)
|
||||
}
|
||||
|
||||
// B5 (2026-06-07): when the dropdown is open, Tab accepts the highlighted row
|
||||
// (standard autocomplete convention) instead of leaving the field. With the
|
||||
// list closed it falls through to normal focus traversal.
|
||||
function onTab (e) {
|
||||
if (rows.value.length) {
|
||||
e.preventDefault()
|
||||
onEnter()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +309,14 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
|
||||
.fc-tag-autocomplete__sugg {
|
||||
border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
|
||||
}
|
||||
.fc-tag-autocomplete__sugg-tag {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<!-- One tag chip + its kebab. The kebab uses the SAME explicit-menu pattern
|
||||
as SuggestionItem (activator="parent" + :open-on-click="false" + a manual
|
||||
v-model), because the #activator / v-bind="props" pattern never toggles a
|
||||
v-menu inside the teleported ImageViewer modal (#711, re-fixed 2026-06-07
|
||||
after the first attempt used the broken pattern). -->
|
||||
<span class="fc-tag-chip">
|
||||
<v-chip
|
||||
size="small" closable
|
||||
:color="store.colorFor(tag.kind)" variant="tonal"
|
||||
@click:close="$emit('remove', tag.id)"
|
||||
>
|
||||
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
|
||||
{{ tag.name }}<span v-if="tag.fandom_id">→</span>
|
||||
</v-chip>
|
||||
<span class="fc-tag-chip__menu-wrap">
|
||||
<v-btn
|
||||
class="fc-tag-chip__kebab"
|
||||
icon="mdi-dots-vertical" size="x-small"
|
||||
variant="text" density="comfortable"
|
||||
:aria-label="`More actions for ${tag.name}`"
|
||||
@click.stop="menuOpen = !menuOpen"
|
||||
/>
|
||||
<!-- :z-index above the modal's 2000 — the teleported menu otherwise lands
|
||||
BEHIND .fc-viewer and gets blurred by its backdrop-filter (operator
|
||||
saw it ghosted behind the sidebar, 2026-06-07). THIS is what made the
|
||||
kebab look dead the whole time: it opened, just underneath. -->
|
||||
<v-menu v-model="menuOpen" activator="parent" :open-on-click="false" :z-index="2400">
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="$emit('rename', tag)">
|
||||
<v-list-item-title>Rename…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="tag.kind === 'character'" @click="$emit('set-fandom', tag)">
|
||||
<v-list-item-title>Set fandom…</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
defineProps({ tag: { type: Object, required: true } })
|
||||
defineEmits(['remove', 'rename', 'set-fandom'])
|
||||
|
||||
const store = useTagStore()
|
||||
const menuOpen = ref(false)
|
||||
|
||||
const KIND_ICONS = {
|
||||
general: 'mdi-tag', character: 'mdi-account-circle',
|
||||
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
|
||||
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline',
|
||||
}
|
||||
function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; }
|
||||
.fc-tag-chip__menu-wrap { display: inline-flex; align-items: center; }
|
||||
.fc-tag-chip__kebab { opacity: 0.7; }
|
||||
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
||||
</style>
|
||||
@@ -2,45 +2,11 @@
|
||||
<aside class="fc-tag-panel" aria-label="Tags for this image">
|
||||
<h3 class="fc-tag-panel__title">Tags</h3>
|
||||
<div class="fc-tag-panel__chips">
|
||||
<v-chip
|
||||
<TagChip
|
||||
v-for="tag in modal.current?.tags || []"
|
||||
:key="tag.id" size="small" closable
|
||||
:color="store.colorFor(tag.kind)" variant="tonal"
|
||||
@click:close="onRemove(tag.id)"
|
||||
>
|
||||
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
|
||||
{{ tag.name }}<span v-if="tag.fandom_id">→</span>
|
||||
<!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
|
||||
never opened inside this teleported modal. Drive it explicitly
|
||||
instead (same mechanism as the dialogs below, which work): the
|
||||
icon toggles `openTagId` with @click.stop (shielding the chip's
|
||||
close button), and `activator="parent"` + `:open-on-click=false`
|
||||
anchors the menu for positioning only. One tag's menu open at a
|
||||
time, so a single id is enough. -->
|
||||
<span class="kebab-wrap">
|
||||
<v-icon
|
||||
size="x-small" class="ml-1 kebab-icon"
|
||||
icon="mdi-dots-vertical"
|
||||
@click.stop="openTagId = openTagId === tag.id ? null : tag.id"
|
||||
/>
|
||||
<v-menu
|
||||
:model-value="openTagId === tag.id"
|
||||
activator="parent" :open-on-click="false"
|
||||
@update:model-value="v => { if (!v) openTagId = null }"
|
||||
>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="openRename(tag)">
|
||||
<v-list-item-title>Rename…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
|
||||
>
|
||||
<v-list-item-title>Set fandom…</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</span>
|
||||
</v-chip>
|
||||
:key="tag.id" :tag="tag"
|
||||
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
|
||||
/>
|
||||
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +14,7 @@
|
||||
|
||||
<TagAutocomplete
|
||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||
@accept-suggestion="onAcceptSuggestion"
|
||||
/>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
||||
@@ -67,8 +34,12 @@
|
||||
/>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="fandomDialog" max-width="460">
|
||||
<v-dialog
|
||||
v-model="fandomDialog" max-width="460"
|
||||
@after-enter="fandomSetRef?.focusSearch?.()"
|
||||
>
|
||||
<FandomSetDialog
|
||||
ref="fandomSetRef"
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialog = false"
|
||||
/>
|
||||
@@ -79,25 +50,16 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||
import TagChip from './TagChip.vue'
|
||||
import TagAutocomplete from './TagAutocomplete.vue'
|
||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||
import TagRenameDialog from './TagRenameDialog.vue'
|
||||
import FandomSetDialog from './FandomSetDialog.vue'
|
||||
|
||||
const modal = useModalStore()
|
||||
const store = useTagStore()
|
||||
const suggestions = useSuggestionsStore()
|
||||
const errorMsg = ref(null)
|
||||
// Which tag chip's kebab menu is open (only one at a time). Drives each
|
||||
// chip menu's v-model so opening never depends on Vuetify's activator click.
|
||||
const openTagId = ref(null)
|
||||
|
||||
const KIND_ICONS = {
|
||||
general: 'mdi-tag', character: 'mdi-account-circle',
|
||||
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
|
||||
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline'
|
||||
}
|
||||
function iconFor(k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
|
||||
async function onRemove(tagId) {
|
||||
errorMsg.value = null
|
||||
@@ -114,6 +76,16 @@ async function onPickNew(payload) {
|
||||
try { await modal.createAndAdd(payload) }
|
||||
catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
||||
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
|
||||
// acceptance, and drops it from the panel; then we refresh the chip rail.
|
||||
async function onAcceptSuggestion(s) {
|
||||
errorMsg.value = null
|
||||
try {
|
||||
await suggestions.accept(s)
|
||||
await modal.reloadTags()
|
||||
} catch (e) { errorMsg.value = e.message }
|
||||
}
|
||||
|
||||
const renameDialog = ref(false)
|
||||
const renameTarget = ref(null)
|
||||
@@ -130,6 +102,7 @@ async function onRenamed() {
|
||||
|
||||
const fandomDialog = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
const fandomSetRef = ref(null)
|
||||
function openSetFandom(tag) {
|
||||
fandomTarget.value = tag
|
||||
fandomDialog.value = true
|
||||
@@ -153,6 +126,4 @@ async function onFandomUpdated() {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.kebab-wrap { display: inline-flex; align-items: center; }
|
||||
.kebab-icon { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -2,25 +2,55 @@
|
||||
<v-card>
|
||||
<v-card-title>Rename tag</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newName" label="New name" density="compact"
|
||||
autofocus @keydown.enter="submit"
|
||||
/>
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-2">
|
||||
{{ errorMsg }}
|
||||
<div v-if="isCollision" class="text-caption mt-1">
|
||||
Merging two tags into one lands in FC-2c.
|
||||
</div>
|
||||
</v-alert>
|
||||
<template v-if="!collision">
|
||||
<v-text-field
|
||||
v-model="newName" label="New name" density="compact"
|
||||
autofocus @keydown.enter="submit"
|
||||
/>
|
||||
<v-alert
|
||||
v-if="errorMsg" type="error" variant="tonal" density="compact"
|
||||
class="mt-2"
|
||||
>{{ errorMsg }}</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- A tag of this (kind, fandom) already has that name. Renaming onto it
|
||||
is a merge, not a fork — same resolution the Tags view offers. -->
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
A {{ tag.kind }} tag named “{{ collision.target.name }}” already exists.
|
||||
</v-alert>
|
||||
<p class="text-body-2">
|
||||
Merge “{{ tag.name }}” into “{{ collision.target.name }}”?
|
||||
{{ collision.source_image_count }} image
|
||||
association{{ collision.source_image_count === 1 ? '' : 's' }}
|
||||
will move over and this tag will be deleted{{
|
||||
collision.will_alias ? ' (its name kept as a tagger alias)' : '' }}.
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="errorMsg" type="error" variant="tonal" density="compact"
|
||||
class="mt-3"
|
||||
>{{ errorMsg }}</v-alert>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('cancel')">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill"
|
||||
:disabled="!newName.trim() || newName === tag.name"
|
||||
:loading="busy" @click="submit"
|
||||
>Rename</v-btn>
|
||||
<template v-if="!collision">
|
||||
<v-btn :disabled="busy" @click="$emit('cancel')">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill"
|
||||
:disabled="!newName.trim() || newName === tag.name"
|
||||
:loading="busy" @click="submit"
|
||||
>Rename</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn :disabled="busy" @click="collision = null; errorMsg = null">
|
||||
Back
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning" variant="flat" rounded="pill"
|
||||
:loading="busy" @click="onMerge"
|
||||
>Merge</v-btn>
|
||||
</template>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -35,22 +65,41 @@ const emit = defineEmits(['renamed', 'cancel'])
|
||||
const api = useApi()
|
||||
const newName = ref(props.tag.name)
|
||||
const errorMsg = ref(null)
|
||||
const isCollision = ref(false)
|
||||
const collision = ref(null)
|
||||
const busy = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (!newName.value.trim() || newName.value === props.tag.name) return
|
||||
busy.value = true
|
||||
errorMsg.value = null
|
||||
isCollision.value = false
|
||||
try {
|
||||
const updated = await api.patch(`/api/tags/${props.tag.id}`, {
|
||||
body: { name: newName.value.trim() }
|
||||
})
|
||||
emit('renamed', updated)
|
||||
} catch (e) {
|
||||
// 409 → the new name collides with an existing tag of the same
|
||||
// (kind, fandom). Offer to merge into it rather than dead-ending.
|
||||
if (e.status === 409 && e.body && e.body.target) {
|
||||
collision.value = e.body
|
||||
} else {
|
||||
errorMsg.value = e.message
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onMerge() {
|
||||
busy.value = true
|
||||
errorMsg.value = null
|
||||
try {
|
||||
await api.post(`/api/tags/${props.tag.id}/merge`, {
|
||||
body: { target_id: collision.value.target.id }
|
||||
})
|
||||
emit('renamed', { id: collision.value.target.id, name: collision.value.target.name })
|
||||
} catch (e) {
|
||||
errorMsg.value = e.message
|
||||
isCollision.value = e.status === 409
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -34,7 +35,10 @@
|
||||
>
|
||||
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
|
||||
</button>
|
||||
<div v-if="rail.length || moreCount" class="fc-post-card__rail">
|
||||
<div
|
||||
v-if="rail.length || moreCount" class="fc-post-card__rail"
|
||||
:style="{ '--fc-rail-cols': railCols }"
|
||||
>
|
||||
<button
|
||||
v-for="t in rail" :key="t.image_id" type="button"
|
||||
class="fc-post-card__rail-cell"
|
||||
@@ -98,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 },
|
||||
@@ -117,13 +122,29 @@ const totalImages = computed(() => images.value.length + (props.post.thumbnails_
|
||||
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
||||
|
||||
const hero = computed(() => images.value[0])
|
||||
const rail = computed(() => images.value.slice(1, 4))
|
||||
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
|
||||
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
|
||||
// more images than fit, the last cell becomes a "+N" overflow tile so the
|
||||
// count stays accurate.
|
||||
const RAIL_MAX = 5
|
||||
const serverMore = computed(() => props.post.thumbnails_more || 0)
|
||||
const afterHero = computed(() => images.value.slice(1))
|
||||
const hasOverflow = computed(
|
||||
() => serverMore.value > 0 || afterHero.value.length > RAIL_MAX,
|
||||
)
|
||||
const rail = computed(() =>
|
||||
hasOverflow.value
|
||||
? afterHero.value.slice(0, RAIL_MAX - 1)
|
||||
: afterHero.value.slice(0, RAIL_MAX),
|
||||
)
|
||||
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
||||
const moreCount = computed(() => {
|
||||
const more = props.post.thumbnails_more || 0
|
||||
const extraShown = Math.max(0, images.value.length - visibleCount.value)
|
||||
return more + extraShown
|
||||
if (!hasOverflow.value) return 0
|
||||
return serverMore.value + Math.max(0, images.value.length - visibleCount.value)
|
||||
})
|
||||
// Columns = thumbnails shown plus the overflow tile, so the strip fills the
|
||||
// hero's full width with equal cells.
|
||||
const railCols = computed(() => rail.value.length + (moreCount.value > 0 ? 1 : 0))
|
||||
|
||||
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
|
||||
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
||||
@@ -279,11 +300,16 @@ function formatBytes (n) {
|
||||
.fc-post-card__hero:hover img,
|
||||
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
|
||||
|
||||
/* Equal columns spanning the hero's full width — cells stretch horizontally
|
||||
(1fr) at a fixed height, so the strip always reaches the edge of the hero
|
||||
regardless of how many thumbnails the post has. */
|
||||
.fc-post-card__rail {
|
||||
display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--fc-rail-cols, 3), 1fr);
|
||||
gap: 6px; margin-top: 6px;
|
||||
}
|
||||
.fc-post-card__rail-cell {
|
||||
width: 80px; height: 80px;
|
||||
width: 100%; height: 84px;
|
||||
overflow: hidden; border-radius: 4px;
|
||||
}
|
||||
.fc-post-card__rail-cell img {
|
||||
@@ -291,7 +317,7 @@ function formatBytes (n) {
|
||||
object-fit: cover; display: block;
|
||||
}
|
||||
.fc-post-card__rail-more {
|
||||
width: 80px; height: 80px;
|
||||
width: 100%; height: 84px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<!-- #713: re-extract PostAttachments that are really archives but were filed
|
||||
opaquely before the magic-byte gate, and attach their images to the post. -->
|
||||
<v-card>
|
||||
<v-card-title>Re-extract archive attachments</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">
|
||||
Some posts attached an archive (zip) whose images weren't extracted
|
||||
because the downloaded file had no usable extension. This scans existing
|
||||
attachments, extracts any that are really archives, and attaches their
|
||||
images to the post. Idempotent — safe to run more than once.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-icon start>mdi-folder-zip-outline</v-icon> Re-extract archives now
|
||||
</v-btn>
|
||||
<span v-if="queued" class="ml-3 text-caption text-success">Queued ✓</span>
|
||||
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
|
||||
const api = useApi()
|
||||
const busy = ref(false)
|
||||
const queued = ref(false)
|
||||
|
||||
async function run () {
|
||||
busy.value = true
|
||||
queued.value = false
|
||||
try {
|
||||
await api.post('/api/admin/maintenance/reextract-archives')
|
||||
queued.value = true
|
||||
toast({ text: 'Archive re-extract queued', type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: e?.body?.detail || e?.message || 'Failed to queue', type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -15,6 +15,7 @@
|
||||
<AllowlistTable class="mt-4" />
|
||||
<AliasTable class="mt-4" />
|
||||
<DbMaintenanceCard class="mt-6" />
|
||||
<ArchiveReextractCard class="mt-6" />
|
||||
<BackupCard class="mt-6" />
|
||||
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
|
||||
operates on the existing library which fits the Cleanup-tab
|
||||
@@ -33,6 +34,7 @@ import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import AllowlistTable from './AllowlistTable.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||
import BackupCard from './BackupCard.vue'
|
||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
|
||||
|
||||
@@ -136,6 +136,60 @@
|
||||
>Delete {{ resetPreview.count }} content tag(s) +
|
||||
{{ resetPreview.applications }} application(s)</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<!-- #714: standardize existing tags to the canonical Title-Case form
|
||||
and merge case/whitespace-variant duplicates. -->
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>Standardize tag casing.</strong>
|
||||
New tags are saved Title Case, but older tags keep whatever casing they
|
||||
were created with. This renames every existing tag to
|
||||
<code>Title Case</code> (collapsing extra spaces) and
|
||||
<strong>merges</strong> tags that differ only by case or spacing
|
||||
(e.g. <code>hatsune miku</code> + <code>Hatsune Miku</code>)
|
||||
into one — repointing their images, allowlist entries and series pages.
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
The merges are irreversible — back up first
|
||||
(Settings → Maintenance → Backup). Safe to run more than once.
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingNormPreview"
|
||||
class="mb-3"
|
||||
@click="onNormPreview"
|
||||
>Preview tag standardization</v-btn>
|
||||
|
||||
<div v-if="normPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ normPreview.total_changes }}</strong> tag group(s) to
|
||||
change — <strong>{{ normPreview.tags_to_rename }}</strong> rename(s),
|
||||
<strong>{{ normPreview.collisions }}</strong> collision(s) merging
|
||||
<strong>{{ normPreview.tags_to_merge }}</strong> tag(s) away.
|
||||
</p>
|
||||
<div v-if="normPreview.sample?.length" class="fc-name-grid mb-3">
|
||||
<span
|
||||
v-for="s in normPreview.sample" :key="s.to + s.kind"
|
||||
class="fc-name"
|
||||
>
|
||||
<template v-if="s.merge">{{ s.from.join(' + ') }} → </template>{{ s.to }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-format-letter-case"
|
||||
:disabled="!normPreview.total_changes || normResult === 'queued'"
|
||||
:loading="normCommitting"
|
||||
@click="onNormCommit"
|
||||
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
|
||||
<span v-if="normResult === 'queued'" class="ml-3 text-caption text-success">
|
||||
Queued ✓ — runs in the background (you can leave this page). It
|
||||
processes in chunks, so re-run “Preview” later to confirm it's all done.
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -155,6 +209,10 @@ const kindCommitting = ref(false)
|
||||
const resetPreview = ref(null)
|
||||
const loadingResetPreview = ref(false)
|
||||
const resetCommitting = ref(false)
|
||||
const normPreview = ref(null)
|
||||
const loadingNormPreview = ref(false)
|
||||
const normCommitting = ref(false)
|
||||
const normResult = ref(null)
|
||||
|
||||
async function onPreview() {
|
||||
loadingPreview.value = true
|
||||
@@ -212,6 +270,36 @@ async function onResetCommit() {
|
||||
resetCommitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onNormPreview() {
|
||||
loadingNormPreview.value = true
|
||||
normResult.value = null
|
||||
try {
|
||||
normPreview.value = await store.normalizeTags({ dryRun: true })
|
||||
} finally {
|
||||
loadingNormPreview.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onNormCommit() {
|
||||
normCommitting.value = true
|
||||
normResult.value = null
|
||||
try {
|
||||
// Fire-and-forget: the task is time-boxed and self-resuming across chunks
|
||||
// (a large back-catalog can't finish in one run), so we DON'T poll-until-
|
||||
// done — that would falsely report "complete" after the first chunk. Just
|
||||
// confirm it's queued; the operator can re-run Preview later to verify.
|
||||
await store.normalizeTags({ dryRun: false })
|
||||
normResult.value = 'queued'
|
||||
// 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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
<span class="fc-active__dot" />
|
||||
<PlatformChip :platform="e.platform" size="x-small" />
|
||||
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
|
||||
<!-- #709: live per-file progress for native (Patreon) downloads. -->
|
||||
<span v-if="e.live" class="fc-active__counts">
|
||||
<span class="fc-active__count" title="downloaded">↓ {{ e.live.downloaded }}</span>
|
||||
<span v-if="e.live.skipped" class="fc-active__count" title="skipped">
|
||||
⤼ {{ e.live.skipped }}</span>
|
||||
<span
|
||||
v-if="e.live.errors" class="fc-active__count fc-active__count--err"
|
||||
title="errors"
|
||||
>✕ {{ e.live.errors }}</span>
|
||||
<span class="fc-active__count fc-active__count--posts" title="posts scanned">
|
||||
{{ e.live.posts }} posts</span>
|
||||
</span>
|
||||
<v-spacer />
|
||||
<span class="fc-active__timer" :title="`started ${e.started_at}`">
|
||||
{{ elapsed(e.started_at) }}
|
||||
@@ -123,6 +135,13 @@ function elapsed (startedIso) {
|
||||
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-active__counts {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-variant-numeric: tabular-nums; font-size: 0.78rem;
|
||||
}
|
||||
.fc-active__count { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-active__count--err { color: rgb(var(--v-theme-error)); }
|
||||
.fc-active__count--posts { opacity: 0.7; }
|
||||
@keyframes fc-active-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.7); }
|
||||
|
||||
@@ -93,6 +93,7 @@ const ERROR_TYPE_COLOR = {
|
||||
unsupported_url: 'error',
|
||||
http_error: 'error',
|
||||
unknown_error: 'error',
|
||||
api_drift: 'error',
|
||||
partial: 'info',
|
||||
tier_limited: 'info',
|
||||
no_new_content: 'info',
|
||||
@@ -108,6 +109,7 @@ const ERROR_TYPE_HINT = {
|
||||
http_error: 'Generic HTTP error — see Logs.',
|
||||
unsupported_url: 'gallery-dl does not support this URL pattern.',
|
||||
unknown_error: 'Could not classify — see Logs.',
|
||||
api_drift: 'Patreon changed its API shape — the native ingester needs a code update.',
|
||||
}
|
||||
function errorTypeColor(t) { return ERROR_TYPE_COLOR[t] || 'error' }
|
||||
function errorTypeHint(t) { return ERROR_TYPE_HINT[t] || '' }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<!-- Plan #708 B4: dry-run preview — what a backfill WOULD download, without
|
||||
downloading. Self-fetches on open; loading / error / empty / result. -->
|
||||
<v-dialog
|
||||
:model-value="modelValue" max-width="520"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card v-if="source">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-eye-outline</v-icon>
|
||||
Preview · {{ source.artist_name }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="fc-preview__url">{{ source.url }}</v-card-subtitle>
|
||||
|
||||
<v-card-text>
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate color="accent" />
|
||||
<div class="text-caption mt-3 text-medium-emphasis">Walking the feed…</div>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-else-if="error" type="error" variant="tonal" density="comfortable"
|
||||
>{{ error }}</v-alert>
|
||||
|
||||
<div v-else-if="result">
|
||||
<div class="text-h5">
|
||||
{{ result.total_new }} new item{{ result.total_new === 1 ? '' : 's' }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
in the {{ result.posts_scanned }} most recent
|
||||
post{{ result.posts_scanned === 1 ? '' : 's' }}<span
|
||||
v-if="result.has_more"> · more pages not scanned</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result.total_new === 0"
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
>You’re caught up — nothing new to download.</div>
|
||||
|
||||
<v-list v-else density="compact" class="fc-preview__list" bg-color="transparent">
|
||||
<v-list-item v-for="(row, i) in result.sample" :key="i" class="px-0">
|
||||
<template #prepend>
|
||||
<v-chip
|
||||
size="x-small" color="info" variant="tonal" label class="mr-3"
|
||||
>+{{ row.new }}</v-chip>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">{{ row.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="row.date" class="text-caption">
|
||||
{{ formatRelative(row.date) }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
|
||||
<v-btn
|
||||
v-if="result && result.total_new > 0"
|
||||
color="accent" variant="flat"
|
||||
@click="$emit('backfill', source)"
|
||||
>Start backfill</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
source: { type: Object, default: null },
|
||||
})
|
||||
defineEmits(['update:modelValue', 'backfill'])
|
||||
|
||||
const store = useSourcesStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (open) => {
|
||||
if (!open || !props.source) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
result.value = await store.previewSource(props.source.id)
|
||||
} catch (e) {
|
||||
error.value = e?.body?.detail || e?.message || 'Preview failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-preview__url {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fc-preview__list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<!-- Shared per-source action cluster (desktop SourceRow + mobile SourceCard).
|
||||
Frequent actions stay as buttons; low-frequency / destructive ones move
|
||||
into a labelled overflow menu so they can't be mis-clicked, and the
|
||||
labels spell out what each one does (recover vs backfill). -->
|
||||
<div class="fc-src-actions">
|
||||
<v-btn
|
||||
icon size="small" variant="text" :loading="checking"
|
||||
@click.stop="emit('check', source)"
|
||||
>
|
||||
<v-icon>mdi-play</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
Check now — fetch posts published since the last check
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon size="small" variant="text"
|
||||
:color="running ? 'warning' : undefined"
|
||||
@click.stop="emit('backfill', source)"
|
||||
>
|
||||
<v-icon>{{ running ? 'mdi-stop' : 'mdi-magnify-scan' }}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ running
|
||||
? (recovering ? 'Stop recovery' : 'Stop backfill')
|
||||
: 'Backfill — one-time walk of the FULL post history until complete' }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn icon size="small" variant="text" v-bind="menuProps" @click.stop>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
<v-tooltip activator="parent" location="top">More actions</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" min-width="260">
|
||||
<v-list-item
|
||||
v-if="isPatreon && !running"
|
||||
prepend-icon="mdi-backup-restore"
|
||||
@click="emit('recover', source)"
|
||||
>
|
||||
<v-list-item-title>Recover dropped near-duplicates</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
Re-fetch images previously dropped as near-dups and re-judge them
|
||||
under the current similarity threshold
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-divider v-if="isPatreon && !running" />
|
||||
<v-list-item
|
||||
base-color="error"
|
||||
prepend-icon="mdi-delete-outline"
|
||||
@click="emit('remove', source)"
|
||||
>
|
||||
<v-list-item-title>Remove source</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: Object, required: true },
|
||||
checking: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['check', 'backfill', 'recover', 'remove'])
|
||||
|
||||
const running = computed(() => props.source.backfill_state === 'running')
|
||||
const recovering = computed(() => !!props.source.backfill_bypass_seen)
|
||||
const isPatreon = computed(() => props.source.platform === 'patreon')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-src-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -12,10 +12,19 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="source.url" target="_blank" rel="noopener"
|
||||
class="fc-source-card__url" @click.stop
|
||||
>{{ source.url }}</a>
|
||||
<div class="fc-source-card__url-row">
|
||||
<a
|
||||
:href="source.url" target="_blank" rel="noopener"
|
||||
class="fc-source-card__url" @click.stop
|
||||
>{{ source.url }}</a>
|
||||
<v-btn
|
||||
icon="mdi-pencil" size="x-small" variant="text"
|
||||
@click.stop="$emit('edit', source)"
|
||||
>
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Edit source</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="fc-source-card__meta">
|
||||
<span>Last {{ formatRelative(source.last_checked_at) }}</span>
|
||||
@@ -23,45 +32,42 @@
|
||||
<v-chip
|
||||
v-if="(source.consecutive_failures || 0) > 0"
|
||||
size="x-small" color="error" variant="tonal" label
|
||||
>{{ source.consecutive_failures }} err</v-chip>
|
||||
>{{ source.consecutive_failures }} err
|
||||
<v-tooltip v-if="source.last_error" activator="parent" location="top" max-width="480">
|
||||
<span style="white-space: pre-wrap; word-break: break-word;">{{ source.last_error }}</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-else-if="(source.backfill_runs_remaining || 0) > 0"
|
||||
v-else-if="source.backfill_state === 'running'"
|
||||
size="x-small" color="info" variant="tonal" label
|
||||
>backfill ({{ source.backfill_runs_remaining }}×)</v-chip>
|
||||
>{{ source.backfill_bypass_seen ? 'Recovering' : 'Backfilling'
|
||||
}}{{ source.backfill_posts
|
||||
? ` · ${source.backfill_posts} posts`
|
||||
: (source.backfill_chunks ? ` (${source.backfill_chunks})` : '') }}</v-chip>
|
||||
<v-chip
|
||||
v-else-if="source.backfill_state === 'complete'"
|
||||
size="x-small" color="success" variant="tonal" label
|
||||
>Backfilled</v-chip>
|
||||
<v-chip
|
||||
v-else-if="source.backfill_state === 'stalled'"
|
||||
size="x-small" color="warning" variant="tonal" label
|
||||
>Stalled</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="fc-source-card__actions">
|
||||
<v-btn
|
||||
size="x-small" variant="text" :loading="checking"
|
||||
@click.stop="$emit('check', source)"
|
||||
>
|
||||
<v-icon>mdi-play</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small" variant="text"
|
||||
:disabled="(source.backfill_runs_remaining || 0) > 0"
|
||||
@click.stop="$emit('backfill', source)"
|
||||
>
|
||||
<v-icon>mdi-magnify-scan</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Deep scan</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click.stop="$emit('edit', source)">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small" variant="text" color="error"
|
||||
@click.stop="$emit('remove', source)"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Remove</v-tooltip>
|
||||
</v-btn>
|
||||
<SourceActions
|
||||
:source="source" :checking="checking"
|
||||
@check="$emit('check', $event)"
|
||||
@backfill="$emit('backfill', $event)"
|
||||
@recover="$emit('recover', $event)"
|
||||
@remove="$emit('remove', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SourceActions from './SourceActions.vue'
|
||||
import SourceHealthDot from './SourceHealthDot.vue'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
|
||||
@@ -73,7 +79,7 @@ const props = defineProps({
|
||||
checking: { type: Boolean, default: false },
|
||||
warningThreshold: { type: Number, default: 5 },
|
||||
})
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill'])
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover'])
|
||||
|
||||
function onToggleEnabled(value) {
|
||||
emit('toggle', { source: props.source, enabled: value })
|
||||
@@ -89,11 +95,15 @@ function onToggleEnabled(value) {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.fc-source-card__top { display: flex; align-items: center; gap: 8px; }
|
||||
.fc-source-card__url-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.fc-source-card__url {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
}
|
||||
.fc-source-card__url:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-source-card__meta {
|
||||
|
||||
@@ -7,10 +7,22 @@
|
||||
<v-chip size="x-small" variant="tonal" label>{{ source.platform }}</v-chip>
|
||||
</td>
|
||||
<td class="fc-source-row__url-cell">
|
||||
<a :href="source.url" target="_blank" rel="noopener" class="fc-source-row__url"
|
||||
@click.stop>
|
||||
{{ source.url }}
|
||||
</a>
|
||||
<div class="fc-source-row__url-wrap">
|
||||
<a :href="source.url" target="_blank" rel="noopener" class="fc-source-row__url"
|
||||
@click.stop>
|
||||
{{ source.url }}
|
||||
</a>
|
||||
<!-- Edit sits next to the source identity (operator-requested), not in
|
||||
the action cluster where it was easy to fat-finger Remove. -->
|
||||
<v-btn
|
||||
icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="fc-source-row__edit"
|
||||
@click.stop="$emit('edit', source)"
|
||||
>
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Edit source</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<v-switch
|
||||
@@ -30,53 +42,43 @@
|
||||
<v-chip
|
||||
v-if="(source.consecutive_failures || 0) > 0"
|
||||
size="x-small" color="error" variant="tonal" label
|
||||
>{{ source.consecutive_failures }}</v-chip>
|
||||
<v-chip
|
||||
v-else-if="(source.backfill_runs_remaining || 0) > 0"
|
||||
size="x-small" color="info" variant="tonal" label
|
||||
>
|
||||
backfill ({{ source.backfill_runs_remaining }}×)
|
||||
>{{ source.consecutive_failures }}
|
||||
<!-- #1: show the actual failure reason on hover instead of a bare count. -->
|
||||
<v-tooltip v-if="source.last_error" activator="parent" location="top" max-width="480">
|
||||
<span class="fc-source-row__err-text">{{ source.last_error }}</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-else-if="source.backfill_state === 'running'"
|
||||
size="x-small" color="info" variant="tonal" label
|
||||
>{{ source.backfill_bypass_seen ? 'Recovering' : 'Backfilling'
|
||||
}}{{ source.backfill_posts
|
||||
? ` · ${source.backfill_posts} posts`
|
||||
: (source.backfill_chunks ? ` (${source.backfill_chunks})` : '') }}</v-chip>
|
||||
<v-chip
|
||||
v-else-if="source.backfill_state === 'complete'"
|
||||
size="x-small" color="success" variant="tonal" label
|
||||
>Backfilled</v-chip>
|
||||
<v-chip
|
||||
v-else-if="source.backfill_state === 'stalled'"
|
||||
size="x-small" color="warning" variant="tonal" label
|
||||
>Stalled</v-chip>
|
||||
<span v-else class="fc-source-row__zero">0</span>
|
||||
</td>
|
||||
<td class="fc-source-row__actions">
|
||||
<v-btn
|
||||
icon="mdi-play" size="x-small" variant="text"
|
||||
:loading="checking"
|
||||
@click.stop="$emit('check', source)"
|
||||
>
|
||||
<v-icon>mdi-play</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-magnify-scan" size="x-small" variant="text"
|
||||
:disabled="(source.backfill_runs_remaining || 0) > 0"
|
||||
@click.stop="$emit('backfill', source)"
|
||||
>
|
||||
<v-icon>mdi-magnify-scan</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
Deep scan — walk full history for next few runs
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-pencil" size="x-small" variant="text"
|
||||
@click.stop="$emit('edit', source)"
|
||||
>
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-close" size="x-small" variant="text" color="error"
|
||||
@click.stop="$emit('remove', source)"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Remove</v-tooltip>
|
||||
</v-btn>
|
||||
<SourceActions
|
||||
:source="source" :checking="checking"
|
||||
@check="$emit('check', $event)"
|
||||
@backfill="$emit('backfill', $event)"
|
||||
@recover="$emit('recover', $event)"
|
||||
@remove="$emit('remove', $event)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SourceActions from './SourceActions.vue'
|
||||
import SourceHealthDot from './SourceHealthDot.vue'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
|
||||
@@ -85,7 +87,7 @@ const props = defineProps({
|
||||
checking: { type: Boolean, default: false },
|
||||
warningThreshold: { type: Number, default: 5 },
|
||||
})
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill'])
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover'])
|
||||
|
||||
function onToggleEnabled(value) {
|
||||
emit('toggle', { source: props.source, enabled: value })
|
||||
@@ -98,18 +100,27 @@ function onToggleEnabled(value) {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.fc-source-row__url-cell {
|
||||
max-width: 400px;
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-source-row__url-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.fc-source-row__url {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Keep the edit affordance subtle until the row is hovered. */
|
||||
.fc-source-row__edit { opacity: 0.5; flex: 0 0 auto; }
|
||||
.fc-source-row:hover .fc-source-row__edit { opacity: 1; }
|
||||
.fc-source-row__url:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-source-row__when {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
@@ -120,6 +131,10 @@ function onToggleEnabled(value) {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
opacity: 0.6;
|
||||
}
|
||||
.fc-source-row__err-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.fc-source-row__actions {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-toggle-switch-off-outline" @click="bulkSetEnabled(false)">
|
||||
Disable all
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-magnify-scan" @click="bulkBackfill">
|
||||
Backfill
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" color="error" prepend-icon="mdi-delete" @click="bulkDelete">
|
||||
Delete
|
||||
</v-btn>
|
||||
@@ -82,6 +85,7 @@
|
||||
item-value="key"
|
||||
v-model="selected"
|
||||
v-model:expanded="expanded"
|
||||
v-model:sort-by="sortBy"
|
||||
:items-per-page="50"
|
||||
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
|
||||
density="comfortable"
|
||||
@@ -89,7 +93,13 @@
|
||||
@click:row="onRowClick"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<span class="fc-subs__name">{{ item.artist.name }}</span>
|
||||
<div class="fc-subs__name">{{ item.artist.name }}</div>
|
||||
<!-- #2: single-source subscriptions show their URL inline (no expand). -->
|
||||
<a
|
||||
v-if="item.singleSource"
|
||||
:href="item.singleSource.url" target="_blank" rel="noopener"
|
||||
class="fc-subs__sub-url" @click.stop
|
||||
>{{ item.singleSource.url }}</a>
|
||||
</template>
|
||||
|
||||
<template #item.platforms="{ item }">
|
||||
@@ -121,32 +131,60 @@
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
icon size="small" variant="text"
|
||||
:loading="anyChecking(item.sources)"
|
||||
@click.stop="checkAll(item)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Check all sources</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn icon size="small" variant="text" @click.stop="openAddSource(item.artist)">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon size="small" variant="text"
|
||||
:to="`/posts?artist_id=${item.artist.id}`" @click.stop
|
||||
>
|
||||
<v-icon>mdi-rss</v-icon>
|
||||
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon size="small" variant="text"
|
||||
:to="`/artist/${item.artist.slug}`" @click.stop
|
||||
>
|
||||
<v-icon>mdi-account</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Open artist page</v-tooltip>
|
||||
</v-btn>
|
||||
<!-- #2: single source → its own actions inline (Check / Backfill / ⋮),
|
||||
plus Edit, so the row is actionable without expanding. -->
|
||||
<template v-if="item.singleSource">
|
||||
<SourceActions
|
||||
:source="item.singleSource"
|
||||
:checking="store.checkingIds.has(item.singleSource.id)"
|
||||
@check="onCheck" @backfill="onBackfill"
|
||||
@recover="onRecover" @remove="removeSource"
|
||||
/>
|
||||
<v-btn icon size="small" variant="text" @click.stop="openEditSource(item.singleSource)">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Edit source</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn icon size="small" variant="text" @click.stop="openAddSource(item.artist)">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Add another source</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon size="small" variant="text"
|
||||
:to="`/artist/${item.artist.slug}`" @click.stop
|
||||
>
|
||||
<v-icon>mdi-account</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Open artist page</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
<!-- Multi-source → artist-level actions; expand for per-source rows. -->
|
||||
<template v-else>
|
||||
<v-btn
|
||||
icon size="small" variant="text"
|
||||
:loading="anyChecking(item.sources)"
|
||||
@click.stop="checkAll(item)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Check all sources</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn icon size="small" variant="text" @click.stop="openAddSource(item.artist)">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon size="small" variant="text"
|
||||
:to="`/posts?artist_id=${item.artist.id}`" @click.stop
|
||||
>
|
||||
<v-icon>mdi-rss</v-icon>
|
||||
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon size="small" variant="text"
|
||||
:to="`/artist/${item.artist.slug}`" @click.stop
|
||||
>
|
||||
<v-icon>mdi-account</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Open artist page</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #expanded-row="{ columns, item }">
|
||||
@@ -175,6 +213,7 @@
|
||||
@toggle="toggleSourceEnabled"
|
||||
@check="onCheck"
|
||||
@backfill="onBackfill"
|
||||
@recover="onRecover"
|
||||
/>
|
||||
<tr v-if="item.sources.length === 0">
|
||||
<td colspan="8" class="fc-subs__sources-empty">
|
||||
@@ -251,6 +290,7 @@
|
||||
@toggle="toggleSourceEnabled"
|
||||
@check="onCheck"
|
||||
@backfill="onBackfill"
|
||||
@recover="onRecover"
|
||||
/>
|
||||
<div v-if="item.sources.length === 0" class="fc-subs__sources-empty">
|
||||
No sources yet. Tap + to add one.
|
||||
@@ -282,6 +322,7 @@ import SourceCard from './SourceCard.vue'
|
||||
import SourceHealthDot from './SourceHealthDot.vue'
|
||||
import SourceFormDialog from './SourceFormDialog.vue'
|
||||
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
||||
import SourceActions from './SourceActions.vue'
|
||||
import PlatformChip from './PlatformChip.vue'
|
||||
import SchedulerStatusBar from './SchedulerStatusBar.vue'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
@@ -315,6 +356,12 @@ const statusFilter = ref('all')
|
||||
const needsAttention = ref(false)
|
||||
const expanded = ref([])
|
||||
const selected = ref([])
|
||||
// #3: default to worst-health first, name as the tiebreak (the table header
|
||||
// stays clickable to re-sort by any column).
|
||||
const sortBy = ref([
|
||||
{ key: 'health', order: 'desc' },
|
||||
{ key: 'name', order: 'asc' },
|
||||
])
|
||||
|
||||
// Mobile card list drives the same `selected`/`expanded` key arrays the
|
||||
// desktop v-data-table binds, so selection + bulk actions work identically.
|
||||
@@ -370,7 +417,7 @@ const headers = [
|
||||
{ title: 'Subscription', key: 'name', sortable: true, align: 'start' },
|
||||
{ title: 'Platforms', key: 'platforms', sortable: false, align: 'start', width: 240 },
|
||||
{ title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 90 },
|
||||
{ title: 'Health', key: 'health', sortable: false, align: 'start', width: 80 },
|
||||
{ title: 'Health', key: 'health', sortable: true, align: 'start', width: 80 },
|
||||
{ title: 'Last activity',key: 'last_activity', sortable: true, align: 'start', width: 140 },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, align: 'end', width: 200 },
|
||||
]
|
||||
@@ -391,10 +438,26 @@ const groups = computed(() => {
|
||||
lastActivity,
|
||||
name: g.artist.name,
|
||||
last_activity: lastActivity ?? '',
|
||||
// #3: numeric health rank so the Health column is sortable worst-first.
|
||||
health: healthRank(worstSource, failureThreshold.value),
|
||||
// #2: when a subscription has exactly one source, surface it on the
|
||||
// artist row directly (URL + actions) so the common case needs no expand.
|
||||
singleSource: g.sources.length === 1 ? g.sources[0] : null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 0 never-checked · 1 ok · 2 warning · 3 failing — higher = worse, so a
|
||||
// descending sort floats the broken subscriptions to the top.
|
||||
function healthRank(source, threshold) {
|
||||
if (!source) return 0
|
||||
if (!source.last_checked_at) return 0
|
||||
const f = source.consecutive_failures || 0
|
||||
if (f === 0) return 1
|
||||
if (f < threshold) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
// A group "needs attention" if any of its sources has accumulated
|
||||
// failures OR has never been checked — the ones worth acting on.
|
||||
function groupNeedsAttention(g) {
|
||||
@@ -479,13 +542,26 @@ function openEditSource(source) {
|
||||
}
|
||||
|
||||
async function removeSource(source) {
|
||||
await store.remove(source.id, source.artist_id)
|
||||
await refresh()
|
||||
// Confirm — Remove moved into the overflow menu, but a destructive action
|
||||
// still deserves a guard (single-source removal had none before).
|
||||
if (!globalThis.window?.confirm(
|
||||
`Remove this ${source.platform} source?\n${source.url}\n\nThe artist and already-downloaded images stay.`,
|
||||
)) return
|
||||
try {
|
||||
// store._dropSource patches the cache in place — no full reload, so the
|
||||
// table + expansion don't reset (operator-flagged lock/reload, 2026-06-07).
|
||||
await store.remove(source.id, source.artist_id)
|
||||
} catch (e) {
|
||||
toast({ text: `Remove failed: ${e?.body?.detail || e?.message || e}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSourceEnabled({ source, enabled }) {
|
||||
await store.update(source.id, { enabled }, source.artist_id)
|
||||
await refresh()
|
||||
try {
|
||||
await store.update(source.id, { enabled }, source.artist_id)
|
||||
} catch (e) {
|
||||
toast({ text: `Update failed: ${e?.body?.detail || e?.message || e}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onSourceSaved() {
|
||||
@@ -532,31 +608,39 @@ async function onCheck(source) {
|
||||
}
|
||||
}
|
||||
|
||||
// Plan #544: arm a source for backfill mode (gallery-dl walks the full
|
||||
// post history) for the next N download runs. Default 3 — enough budget
|
||||
// to finish a deep creator without re-prompting the operator across
|
||||
// timeout boundaries. The chip on the row reflects the remaining count.
|
||||
// Plan #693: toggle a run-until-done backfill. Starting walks the full post
|
||||
// history in time-boxed chunks across ticks until it reaches the bottom (the
|
||||
// row badge tracks progress / completion); stopping cancels back to tick mode.
|
||||
async function onBackfill(source) {
|
||||
const raw = globalThis.window?.prompt(
|
||||
`Deep scan "${source.artist_name} (${source.platform})" — walk full history for the next how many download runs? (1–10, default 3)`,
|
||||
'3',
|
||||
)
|
||||
if (raw == null) return
|
||||
const runs = parseInt(raw, 10)
|
||||
if (!Number.isFinite(runs) || runs < 1 || runs > 10) {
|
||||
toast({ text: 'Deep scan: runs must be 1–10', type: 'error' })
|
||||
return
|
||||
}
|
||||
const running = source.backfill_state === 'running'
|
||||
try {
|
||||
await store.setBackfill(source.id, runs, source.artist_id)
|
||||
toast({
|
||||
text: `Deep scan armed for ${runs} run${runs === 1 ? '' : 's'}`,
|
||||
type: 'success',
|
||||
})
|
||||
await store.loadAll()
|
||||
if (running) {
|
||||
await store.stopBackfill(source.id, source.artist_id)
|
||||
toast({ text: `Backfill stopped for ${source.artist_name}`, type: 'success' })
|
||||
} else {
|
||||
await store.startBackfill(source.id, source.artist_id)
|
||||
toast({ text: `Backfill started for ${source.artist_name}`, type: 'success' })
|
||||
}
|
||||
// startBackfill/stopBackfill patch the source in place — no full reload.
|
||||
} catch (e) {
|
||||
toast({
|
||||
text: `Deep scan failed: ${e?.detail || e?.message || e}`,
|
||||
text: `Backfill ${running ? 'stop' : 'start'} failed: ${e?.body?.detail || e?.message || e}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Plan #697: arm a recovery walk (Patreon-only) — re-fetches dropped-and-deleted
|
||||
// near-dups and re-evaluates them under the current pHash threshold. Reuses the
|
||||
// backfill lifecycle/badge; stop via the same Stop control (onBackfill).
|
||||
async function onRecover(source) {
|
||||
try {
|
||||
await store.recoverSource(source.id, source.artist_id)
|
||||
toast({ text: `Recovery started for ${source.artist_name}`, type: 'success' })
|
||||
// recoverSource patches the source in place — no full reload.
|
||||
} catch (e) {
|
||||
toast({
|
||||
text: `Recovery start failed: ${e?.body?.detail || e?.message || e}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
@@ -617,6 +701,28 @@ async function bulkSetEnabled(enabled) {
|
||||
selected.value = []
|
||||
}
|
||||
|
||||
// #5: arm a run-until-done backfill on every enabled source in the selected
|
||||
// subscriptions (skips ones already backfilling). Patches each row in place.
|
||||
async function bulkBackfill() {
|
||||
const sel = resolveSelectedGroups()
|
||||
let armed = 0
|
||||
for (const g of sel) {
|
||||
for (const s of g.sources) {
|
||||
if (!s.enabled || s.backfill_state === 'running') continue
|
||||
try {
|
||||
await store.startBackfill(s.id, s.artist_id)
|
||||
armed += 1
|
||||
} catch { /* keep going */ }
|
||||
}
|
||||
}
|
||||
toast({
|
||||
text: armed ? `Backfill started on ${armed} source${armed === 1 ? '' : 's'}`
|
||||
: 'Nothing to backfill (already running or disabled)',
|
||||
type: armed ? 'success' : 'info',
|
||||
})
|
||||
selected.value = []
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
const groups = resolveSelectedGroups()
|
||||
const total = groups.reduce((n, g) => n + g.sources.length, 0)
|
||||
@@ -701,6 +807,17 @@ async function bulkDelete() {
|
||||
}
|
||||
.fc-subs__mactions { display: flex; gap: 2px; }
|
||||
.fc-subs__name { font-weight: 600; }
|
||||
.fc-subs__sub-url {
|
||||
display: block;
|
||||
max-width: 460px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.76rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
text-decoration: none;
|
||||
}
|
||||
.fc-subs__sub-url:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-subs__chips {
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -142,6 +142,22 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// #714: Title-Case the back-catalog + merge case/whitespace-variant tags.
|
||||
// dry-run returns a projection inline; live returns {task_id} (long op —
|
||||
// FK repoints) the caller polls via pollTaskUntilDone.
|
||||
async function normalizeTags({ dryRun = true } = {}) {
|
||||
lastError.value = null
|
||||
try {
|
||||
return await api.post(
|
||||
'/api/admin/tags/normalize',
|
||||
{ body: { dry_run: dryRun } },
|
||||
)
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task progress polling (taps FC-3i activity dashboard) --------
|
||||
|
||||
/**
|
||||
@@ -178,6 +194,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
pruneUnusedTags,
|
||||
purgeLegacyTags,
|
||||
resetContentTagging,
|
||||
normalizeTags,
|
||||
pollTaskUntilDone,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,6 +14,10 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
|
||||
const q = ref('')
|
||||
const platform = ref(null)
|
||||
let started = false
|
||||
// Has a load ATTEMPT completed yet? Gates the "No artists match" empty state
|
||||
// so it can't flash on the very first render (loading is still false before
|
||||
// onMounted fires the first loadMore) or between a query change and its fetch.
|
||||
const loaded = ref(false)
|
||||
// Typed "alice" then "alice bob" used to drop the second fetch
|
||||
// entirely (loading flag still true from the first), so the UI
|
||||
// showed alice results while the input said "alice bob". Inflight
|
||||
@@ -35,6 +39,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
|
||||
nextCursor.value = body.next_cursor
|
||||
started = true
|
||||
})
|
||||
if (t.isCurrent()) loaded.value = true
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
@@ -42,6 +47,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
|
||||
cards.value = []
|
||||
nextCursor.value = null
|
||||
started = false
|
||||
loaded.value = false
|
||||
await loadMore()
|
||||
}
|
||||
|
||||
@@ -50,7 +56,8 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
|
||||
|
||||
const hasMore = computed(() => !started || nextCursor.value !== null)
|
||||
const isEmpty = computed(
|
||||
() => !loading.value && cards.value.length === 0 && error.value === null
|
||||
() => loaded.value && !loading.value
|
||||
&& cards.value.length === 0 && error.value === null
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -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,11 @@ 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, stated_part, title, is_placeholder, stated_page_start/end, source_post, pages:[...]}]
|
||||
const gaps = ref([]) // missing-page gaps: [{after_chapter_id, start, end}]
|
||||
const partGaps = ref([]) // missing-Part gaps: [{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 +32,15 @@ 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 || []
|
||||
partGaps.value = body.part_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 +50,79 @@ 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
|
||||
}
|
||||
|
||||
function partGapAfter(chapterId) {
|
||||
return partGaps.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 setChapterPart(chapterId, part) {
|
||||
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
|
||||
body: { stated_part: part }
|
||||
})
|
||||
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 +140,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 +156,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 +164,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, partGaps, pageCount, targetChapterId,
|
||||
picker, pickerCursor, pickerSelection, loading,
|
||||
load, refresh, gapAfter, partGapAfter,
|
||||
createChapter, renameChapter, setChapterPart, 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
|
||||
}
|
||||
})
|
||||
@@ -32,6 +32,31 @@ export const useSourcesStore = defineStore('sources', () => {
|
||||
if (artistId != null) byArtist.value.delete(artistId)
|
||||
}
|
||||
|
||||
// Patch a single updated source into every cached array IN PLACE, rather than
|
||||
// invalidating + refetching the whole list. The full refetch was what locked
|
||||
// the Subscriptions UI and collapsed the expanded row after every inline
|
||||
// action (check / backfill / recover / toggle) — operator-flagged 2026-06-07.
|
||||
// Map.set on a Vue-reactive ref(Map) triggers re-render; we hand each cache a
|
||||
// NEW array so the table diffs just the one changed row.
|
||||
function _patchSource(updated) {
|
||||
if (!updated || updated.id == null) return
|
||||
for (const [key, arr] of byArtist.value) {
|
||||
const i = arr.findIndex((s) => s.id === updated.id)
|
||||
if (i === -1) continue
|
||||
const next = arr.slice()
|
||||
next[i] = { ...arr[i], ...updated }
|
||||
byArtist.value.set(key, next)
|
||||
}
|
||||
}
|
||||
|
||||
function _dropSource(id) {
|
||||
for (const [key, arr] of byArtist.value) {
|
||||
if (arr.some((s) => s.id === id)) {
|
||||
byArtist.value.set(key, arr.filter((s) => s.id !== id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
const body = await api.post('/api/sources', { body: payload })
|
||||
_invalidate(payload.artist_id)
|
||||
@@ -40,13 +65,13 @@ export const useSourcesStore = defineStore('sources', () => {
|
||||
|
||||
async function update(id, patch, artistIdHint = null) {
|
||||
const body = await api.patch(`/api/sources/${id}`, { body: patch })
|
||||
_invalidate(artistIdHint ?? body.artist_id)
|
||||
_patchSource(body)
|
||||
return body
|
||||
}
|
||||
|
||||
async function remove(id, artistIdHint = null) {
|
||||
await api.delete(`/api/sources/${id}`)
|
||||
_invalidate(artistIdHint)
|
||||
_dropSource(id)
|
||||
}
|
||||
|
||||
async function findOrCreateArtist(name) {
|
||||
@@ -85,14 +110,33 @@ export const useSourcesStore = defineStore('sources', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Plan #544: arm a source for backfill mode. The next `runs` download
|
||||
// runs (default 3) walk gallery-dl's full post history instead of
|
||||
// exiting early at the first contiguous archived block.
|
||||
async function setBackfill(id, runs = 3, artistIdHint = null) {
|
||||
const body = await api.post(`/api/sources/${id}/backfill`, { body: { runs } })
|
||||
_invalidate(artistIdHint ?? body.artist_id)
|
||||
// Plan #693: start/stop a run-until-done backfill. 'start' walks the full
|
||||
// post history in time-boxed chunks until it reaches the bottom (then the
|
||||
// source shows backfill_state 'complete'); 'stop' cancels back to tick mode.
|
||||
async function startBackfill(id, artistIdHint = null) {
|
||||
const body = await api.post(`/api/sources/${id}/backfill`, { body: { action: 'start' } })
|
||||
_patchSource(body)
|
||||
return body
|
||||
}
|
||||
async function stopBackfill(id, artistIdHint = null) {
|
||||
const body = await api.post(`/api/sources/${id}/backfill`, { body: { action: 'stop' } })
|
||||
_patchSource(body)
|
||||
return body
|
||||
}
|
||||
// Plan #697: arm a recovery walk — a backfill that bypasses the Patreon
|
||||
// seen-ledger to re-fetch dropped-and-deleted near-dups and re-evaluate them
|
||||
// under the current pHash threshold. Stop with stopBackfill (shared lifecycle).
|
||||
async function recoverSource(id, artistIdHint = null) {
|
||||
const body = await api.post(`/api/sources/${id}/backfill`, { body: { action: 'recover' } })
|
||||
_patchSource(body)
|
||||
return body
|
||||
}
|
||||
|
||||
// Plan #708 B4: dry-run preview — count what a backfill WOULD download
|
||||
// (native platforms), without downloading. Read-only, so no cache invalidate.
|
||||
async function previewSource(id) {
|
||||
return await api.post(`/api/sources/${id}/preview`)
|
||||
}
|
||||
|
||||
function sourcesByArtistGrouped() {
|
||||
// returns [{artist: {id,name,slug}, sources: [...]}, ...]
|
||||
@@ -120,7 +164,10 @@ export const useSourcesStore = defineStore('sources', () => {
|
||||
loadAll, loadForArtist,
|
||||
create, update, remove,
|
||||
checkNow,
|
||||
setBackfill,
|
||||
startBackfill,
|
||||
stopBackfill,
|
||||
recoverSource,
|
||||
previewSource,
|
||||
findOrCreateArtist, autocompleteArtist,
|
||||
loadScheduleStatus,
|
||||
sourcesByArtistGrouped,
|
||||
|
||||
@@ -34,10 +34,20 @@ export const useTagStore = defineStore('tags', () => {
|
||||
}
|
||||
|
||||
async function loadFandoms() {
|
||||
fandomCache.value = await api.get('/api/tags/autocomplete', {
|
||||
params: { q: ' ', kind: 'fandom', limit: 200 }
|
||||
})
|
||||
return fandomCache.value
|
||||
// List ALL fandoms via the cursor-paged tag directory. (#712: the old impl
|
||||
// abused /tags/autocomplete with q=' ', which the backend strips to empty
|
||||
// and returns [] — so the picker showed no existing fandoms.)
|
||||
const all = []
|
||||
let cursor = null
|
||||
do {
|
||||
const params = { kind: 'fandom', limit: 200 }
|
||||
if (cursor) params.cursor = cursor
|
||||
const body = await api.get('/api/tags/directory', { params })
|
||||
all.push(...(body.cards || []))
|
||||
cursor = body.next_cursor
|
||||
} while (cursor)
|
||||
fandomCache.value = all
|
||||
return all
|
||||
}
|
||||
|
||||
async function createFandom(name) {
|
||||
|
||||
@@ -76,9 +76,11 @@ onMounted(async () => {
|
||||
}
|
||||
.fc-artists__grid {
|
||||
display: grid;
|
||||
/* min(440px, 100%) so a card never exceeds the viewport — on phones the
|
||||
min-track collapses to 100% (single column) instead of overflowing. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 1fr));
|
||||
/* min(360px, 100%) so a card never exceeds the viewport (phones collapse to
|
||||
one column). 360 keeps a LONE column under ~732px wide, so the 3/1 preview
|
||||
strip stays ≲244px tall and always fills the card width (no dead space),
|
||||
while giving 2+ columns on a normal desktop. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.fc-artists__sentinel {
|
||||
|
||||
@@ -1,83 +1,311 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<v-container fluid class="pt-2 pb-10 fc-series">
|
||||
<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 }} part(s) · {{ store.pageCount }} page(s)
|
||||
</span>
|
||||
<v-spacer />
|
||||
<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"
|
||||
@click="$router.push({ name: 'series-read', params: { tagId: store.tagId } })"
|
||||
>Read</v-btn>
|
||||
</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)" />
|
||||
<p class="fc-series__hint">
|
||||
Each part is one installment — drag pages to set their order, and set the
|
||||
<strong>Part #</strong> to mark where it falls in the story. Use
|
||||
<strong>Add pages</strong> to pull images from the gallery into the part
|
||||
you're working on.
|
||||
</p>
|
||||
|
||||
<!-- Parts, full width -->
|
||||
<div class="fc-parts">
|
||||
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
|
||||
<section class="fc-part">
|
||||
<header class="fc-part__head">
|
||||
<label class="fc-part__partfield" :title="'Part number for this installment'">
|
||||
<span class="fc-part__partlabel">Part</span>
|
||||
<input
|
||||
class="fc-part__partnum" type="number" min="1"
|
||||
:value="partDraft[ch.id] ?? ''"
|
||||
:placeholder="String(ch.chapter_number)"
|
||||
@input="partDraft[ch.id] = $event.target.value"
|
||||
@keydown.enter.prevent="commitPart(ch)"
|
||||
@blur="commitPart(ch)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<v-text-field
|
||||
v-model="titleDraft[ch.id]"
|
||||
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`"
|
||||
density="compact" variant="plain" hide-details
|
||||
class="fc-part__title"
|
||||
@keydown.enter="commitTitle(ch)"
|
||||
@blur="commitTitle(ch)"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="ch.source_post?.title" class="fc-part__src"
|
||||
:title="ch.source_post.title"
|
||||
>
|
||||
<v-icon size="x-small">mdi-link-variant</v-icon>
|
||||
{{ ch.source_post.title }}
|
||||
</span>
|
||||
|
||||
<span v-if="ch.is_placeholder" class="fc-part__badge">placeholder</span>
|
||||
<span v-else class="fc-part__pc">{{ ch.pages.length }} pg</span>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn
|
||||
v-if="!ch.is_placeholder"
|
||||
size="small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-image-plus"
|
||||
@click="openPicker(ch.id)"
|
||||
>Add pages</v-btn>
|
||||
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props" size="small" variant="text"
|
||||
icon="mdi-dots-vertical" title="Part actions"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-chevron-up" title="Move up"
|
||||
:disabled="ci === 0"
|
||||
@click="store.moveChapter(ch.id, -1)"
|
||||
/>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-chevron-down" title="Move down"
|
||||
:disabled="ci === store.chapters.length - 1"
|
||||
@click="store.moveChapter(ch.id, 1)"
|
||||
/>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
|
||||
:disabled="ci === 0"
|
||||
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
prepend-icon="mdi-delete-outline" title="Delete part"
|
||||
base-color="error"
|
||||
@click="confirmDelete(ch)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</header>
|
||||
|
||||
<div class="fc-part__statedrow">
|
||||
<span class="fc-part__statedlabel">Printed pages</span>
|
||||
<input
|
||||
class="fc-part__statedin" type="number" min="0"
|
||||
:value="ch.stated_page_start ?? ''" placeholder="start"
|
||||
@change="onStated(ch, 'start', $event)"
|
||||
>
|
||||
<span class="fc-part__dash">–</span>
|
||||
<input
|
||||
class="fc-part__statedin" type="number" min="0"
|
||||
:value="ch.stated_page_end ?? ''" placeholder="end"
|
||||
@change="onStated(ch, 'end', $event)"
|
||||
>
|
||||
<span class="fc-part__statedhelp">
|
||||
optional — the page numbers printed in this installment
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ch.is_placeholder" class="fc-part__reserved">
|
||||
Reserved slot — a part you don't have yet.
|
||||
</div>
|
||||
<div v-else-if="ch.pages.length === 0" class="fc-part__empty">
|
||||
No pages yet.
|
||||
<v-btn
|
||||
size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-image-plus" @click="openPicker(ch.id)"
|
||||
>Add pages</v-btn>
|
||||
</div>
|
||||
<div v-else class="fc-part__pages">
|
||||
<div
|
||||
v-for="(p, pi) in ch.pages" :key="p.image_id"
|
||||
class="fc-page" draggable="true"
|
||||
:class="{ 'fc-page--dragging': drag && drag.chapterId === ch.id && drag.idx === pi }"
|
||||
@dragstart="drag = { chapterId: ch.id, idx: pi }"
|
||||
@dragend="drag = null"
|
||||
@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="flat" icon="mdi-image-frame"
|
||||
title="Make series cover" @click="store.setCover(p.image_id)" />
|
||||
<v-btn size="x-small" variant="flat" icon="mdi-close"
|
||||
title="Remove from series" @click="store.remove(p.image_id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="store.partGapAfter(ch.id)" class="fc-gap">
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon>
|
||||
Missing
|
||||
<template v-if="store.partGapAfter(ch.id).start === store.partGapAfter(ch.id).end">
|
||||
Part {{ store.partGapAfter(ch.id).start }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Parts {{ store.partGapAfter(ch.id).start }}–{{ store.partGapAfter(ch.id).end }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="store.pages.length === 0" class="fc-series__empty">
|
||||
No pages yet — add images from the right.
|
||||
<div v-if="store.gapAfter(ch.id)" class="fc-gap">
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon>
|
||||
Gap: printed 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 parts yet — add one, then add pages from the gallery.
|
||||
</div>
|
||||
|
||||
<div class="fc-series__picker">
|
||||
<div class="fc-series__pickerhead">
|
||||
<div class="fc-parts__add">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
|
||||
@click="store.createChapter()">Add part</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 slide-over -->
|
||||
<v-navigation-drawer
|
||||
v-model="pickerOpen" location="right" temporary width="420"
|
||||
class="fc-picker"
|
||||
>
|
||||
<div class="fc-picker__head">
|
||||
<div class="fc-picker__title">
|
||||
<span>Add pages</span>
|
||||
<v-btn size="x-small" variant="text" icon="mdi-close"
|
||||
@click="pickerOpen = false" />
|
||||
</div>
|
||||
<v-select
|
||||
v-model="store.targetChapterId"
|
||||
:items="chapterItems" item-title="label" item-value="id"
|
||||
density="compact" variant="outlined" hide-details
|
||||
label="Add to part" class="mt-2"
|
||||
/>
|
||||
<div class="fc-picker__bar">
|
||||
<span>{{ store.pickerSelection.length }} selected</span>
|
||||
<v-btn
|
||||
size="small" color="accent" variant="flat"
|
||||
:disabled="store.pickerSelection.length === 0"
|
||||
@click="store.addSelected()"
|
||||
>Add to series</v-btn>
|
||||
>Add to part</v-btn>
|
||||
</div>
|
||||
<div class="fc-series__pickergrid">
|
||||
<div
|
||||
v-for="img in store.picker" :key="img.id"
|
||||
class="fc-series__pick"
|
||||
:class="{ on: store.pickerSelection.includes(img.id) }"
|
||||
@click="store.togglePick(img.id)"
|
||||
>
|
||||
<img :src="img.thumbnail_url" alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
<div ref="sentinel" class="fc-series__sentinel" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-picker__grid">
|
||||
<div
|
||||
v-for="img in store.picker" :key="img.id"
|
||||
class="fc-picker__pick"
|
||||
:class="{ on: store.pickerSelection.includes(img.id) }"
|
||||
@click="store.togglePick(img.id)"
|
||||
>
|
||||
<img :src="img.thumbnail_url" alt="" loading="lazy" />
|
||||
<v-icon
|
||||
v-if="store.pickerSelection.includes(img.id)"
|
||||
class="fc-picker__check" color="accent" size="small"
|
||||
>mdi-check-circle</v-icon>
|
||||
</div>
|
||||
<div ref="sentinel" class="fc-picker__sentinel" />
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
</v-container>
|
||||
</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 title
|
||||
const partDraft = reactive({}) // chapterId -> draft stated_part (string)
|
||||
const pickerOpen = ref(false)
|
||||
|
||||
function onDrop(toIdx) {
|
||||
if (dragFrom.value === null || dragFrom.value === toIdx) return
|
||||
// Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter,
|
||||
// which refreshes and resets the draft to the saved value.
|
||||
watch(() => store.chapters, (chs) => {
|
||||
for (const k of Object.keys(titleDraft)) delete titleDraft[k]
|
||||
for (const k of Object.keys(partDraft)) delete partDraft[k]
|
||||
for (const c of chs) {
|
||||
titleDraft[c.id] = c.title || ''
|
||||
partDraft[c.id] = c.stated_part == null ? '' : String(c.stated_part)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const chapterItems = computed(() =>
|
||||
store.chapters.map(c => ({
|
||||
id: c.id,
|
||||
label: `Part ${c.stated_part ?? c.chapter_number}` +
|
||||
(c.title ? ` — ${c.title}` : '') +
|
||||
(c.is_placeholder ? ' (placeholder)' : ` · ${c.pages.length} pg`),
|
||||
}))
|
||||
)
|
||||
|
||||
function commitTitle(ch) {
|
||||
const v = (titleDraft[ch.id] || '').trim()
|
||||
if (v === (ch.title || '')) return
|
||||
store.renameChapter(ch.id, v || null)
|
||||
}
|
||||
|
||||
function commitPart(ch) {
|
||||
const raw = (partDraft[ch.id] ?? '').trim()
|
||||
const next = raw === '' ? null : parseInt(raw, 10)
|
||||
if (raw !== '' && (Number.isNaN(next) || next < 1)) {
|
||||
partDraft[ch.id] = ch.stated_part == null ? '' : String(ch.stated_part)
|
||||
return
|
||||
}
|
||||
if (next === (ch.stated_part ?? null)) return
|
||||
store.setChapterPart(ch.id, next)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function confirmDelete(ch) {
|
||||
const label = `Part ${ch.stated_part ?? ch.chapter_number}`
|
||||
const n = ch.pages.length
|
||||
const msg = n
|
||||
? `Delete ${label} and remove its ${n} page(s) from the series?`
|
||||
: `Delete ${label}?`
|
||||
if (window.confirm(msg)) store.deleteChapter(ch.id)
|
||||
}
|
||||
|
||||
function openPicker(chapterId) {
|
||||
store.targetChapterId = chapterId
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
useInfiniteScroll(sentinel, () => store.loadPicker())
|
||||
@@ -90,51 +318,146 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.fc-series__head {
|
||||
display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.fc-series__name {
|
||||
font-family: 'Fraunces', Georgia, serif; font-size: 22px;
|
||||
display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;
|
||||
}
|
||||
.fc-series__name { font-family: 'Fraunces', Georgia, serif; font-size: 24px; }
|
||||
.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__hint {
|
||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-bottom: 18px; max-width: 760px;
|
||||
}
|
||||
.fc-series__page img {
|
||||
width: 64px; height: 64px; object-fit: cover; border-radius: 4px;
|
||||
|
||||
/* Parts — full width, stacked */
|
||||
.fc-parts { display: flex; flex-direction: column; gap: 14px; max-width: 1100px; }
|
||||
.fc-part {
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.fc-series__pn {
|
||||
width: 28px; text-align: center;
|
||||
.fc-part__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
.fc-part__partfield {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: rgb(var(--v-theme-accent), 0.12);
|
||||
border: 1px solid rgb(var(--v-theme-accent), 0.35);
|
||||
border-radius: 8px; padding: 4px 8px;
|
||||
}
|
||||
.fc-part__partlabel {
|
||||
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-part__partnum {
|
||||
width: 46px; text-align: center; font-size: 18px; font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: transparent; border: none; color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-part__partnum:focus { outline: none; }
|
||||
.fc-part__partnum::placeholder { color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6; }
|
||||
|
||||
.fc-part__title { flex: 1 1 180px; min-width: 120px; font-size: 15px; }
|
||||
.fc-part__src {
|
||||
display: inline-flex; align-items: center; gap: 4px; max-width: 240px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-part__badge {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-part__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-part__statedrow {
|
||||
display: flex; align-items: center; gap: 6px; margin: 8px 0 2px;
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-part__statedlabel { text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
|
||||
.fc-part__statedin {
|
||||
width: 56px; 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-part__statedin:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-part__dash { opacity: 0.6; }
|
||||
.fc-part__statedhelp { font-size: 11px; opacity: 0.7; }
|
||||
|
||||
.fc-part__reserved, .fc-part__empty {
|
||||
padding: 18px; text-align: center; font-size: 13px;
|
||||
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;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fc-series__pickergrid {
|
||||
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 img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-series__pick.on { outline-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-series__sentinel { height: 40px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fc-series__body { flex-direction: column; }
|
||||
/* Big page grid */
|
||||
.fc-part__pages {
|
||||
display: grid; gap: 10px; margin-top: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
.fc-page {
|
||||
position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-page--dragging { opacity: 0.4; }
|
||||
.fc-page img {
|
||||
width: 100%; aspect-ratio: 3 / 4; object-fit: contain;
|
||||
display: block; background: rgb(var(--v-theme-background));
|
||||
}
|
||||
.fc-page__pn {
|
||||
position: absolute; top: 6px; left: 6px; z-index: 2;
|
||||
min-width: 24px; height: 24px; padding: 0 6px; border-radius: 12px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||
background: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-on-accent, 0 0 0));
|
||||
}
|
||||
.fc-page__actions {
|
||||
position: absolute; top: 4px; right: 4px; z-index: 2;
|
||||
display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s;
|
||||
}
|
||||
.fc-page:hover .fc-page__actions, .fc-page:focus-within .fc-page__actions { opacity: 1; }
|
||||
.fc-page__actions :deep(.v-btn) {
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff;
|
||||
}
|
||||
|
||||
.fc-gap {
|
||||
display: flex; align-items: center; gap: 6px; padding: 2px 10px;
|
||||
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
|
||||
}
|
||||
.fc-parts__add { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.fc-series__empty {
|
||||
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
/* Picker slide-over */
|
||||
.fc-picker__head {
|
||||
position: sticky; top: 0; z-index: 1; padding: 12px 14px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-picker__title {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-family: 'Fraunces', Georgia, serif; font-size: 18px;
|
||||
}
|
||||
.fc-picker__bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-top: 10px; font-size: 13px;
|
||||
}
|
||||
.fc-picker__grid {
|
||||
display: grid; gap: 6px; padding: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
}
|
||||
.fc-picker__pick {
|
||||
position: relative; cursor: pointer; aspect-ratio: 1; overflow: hidden;
|
||||
border-radius: 6px; outline: 2px solid transparent;
|
||||
}
|
||||
.fc-picker__pick img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-picker__pick.on { outline-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-picker__check {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: rgba(0, 0, 0, 0.5); border-radius: 50%;
|
||||
}
|
||||
.fc-picker__sentinel { grid-column: 1 / -1; height: 40px; }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -87,8 +87,12 @@
|
||||
@confirm="onDeleteTagConfirm"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="fandomDialogOpen" max-width="460">
|
||||
<v-dialog
|
||||
v-model="fandomDialogOpen" max-width="460"
|
||||
@after-enter="fandomSetRef?.focusSearch?.()"
|
||||
>
|
||||
<FandomSetDialog
|
||||
ref="fandomSetRef"
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
|
||||
/>
|
||||
@@ -152,6 +156,7 @@ function openTag(tagId) {
|
||||
// Character fandom editing (dots-menu → FandomSetDialog).
|
||||
const fandomDialogOpen = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
const fandomSetRef = ref(null)
|
||||
function onSetFandom(card) {
|
||||
fandomTarget.value = card
|
||||
fandomDialogOpen.value = true
|
||||
|
||||
@@ -40,6 +40,30 @@ describe('PostCard', () => {
|
||||
expect(full.text()).not.toContain('Show more')
|
||||
})
|
||||
|
||||
const thumbs = (n) =>
|
||||
Array.from({ length: n }, (_, i) => ({ image_id: 100 + i, thumbnail_url: `/t${i}` }))
|
||||
|
||||
it('fills the rail to full width (5 cells, no overflow) for a 6-image post', () => {
|
||||
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 0 }
|
||||
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
||||
// hero + 5 rail thumbnails, no "+N" tile.
|
||||
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(5)
|
||||
expect(w.find('.fc-post-card__rail-more').exists()).toBe(false)
|
||||
// The grid spans the hero width via a column count == cells shown.
|
||||
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
|
||||
})
|
||||
|
||||
it('reserves the last cell for a "+N" overflow tile when there are more images', () => {
|
||||
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 4 }
|
||||
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
||||
// 4 thumbnails + 1 overflow tile == 5 columns; tile counts every hidden image.
|
||||
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(4)
|
||||
const more = w.find('.fc-post-card__rail-more')
|
||||
expect(more.exists()).toBe(true)
|
||||
expect(more.text()).toBe('+5')
|
||||
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
|
||||
})
|
||||
|
||||
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
|
||||
const pinia = freshPinia()
|
||||
const modal = useModalStore()
|
||||
|
||||
@@ -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,88 @@ 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('setChapterPart patches stated_part on the chapter', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
s.tagId = 7
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : null })
|
||||
if (init.method === 'PATCH') return { status: 200, body: { ok: true } }
|
||||
return { status: 200, body: SERIES_BODY }
|
||||
})
|
||||
await s.setChapterPart(1, 2)
|
||||
const p = calls.find(c => c.method === 'PATCH')
|
||||
expect(p.url).toContain('/api/series/7/chapters/1')
|
||||
expect(p.body).toEqual({ stated_part: 2 })
|
||||
})
|
||||
|
||||
it('load surfaces part_gaps and partGapAfter looks them up', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { ...SERIES_BODY, part_gaps: [{ after_chapter_id: 1, start: 2, end: 2 }] }
|
||||
}))
|
||||
await s.load(7)
|
||||
expect(s.partGaps).toHaveLength(1)
|
||||
expect(s.partGapAfter(1)).toEqual({ after_chapter_id: 1, start: 2, end: 2 })
|
||||
expect(s.partGapAfter(99)).toBeNull()
|
||||
})
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,6 +41,41 @@ describe('sources store', () => {
|
||||
expect(calls[0]).toContain('artist_id=7')
|
||||
})
|
||||
|
||||
it('update patches the source in place without dropping the cache', async () => {
|
||||
const s = useSourcesStore()
|
||||
s.byArtist.set(null, [
|
||||
{ id: 5, artist_id: 5, enabled: true, backfill_state: null },
|
||||
{ id: 6, artist_id: 5, enabled: true },
|
||||
])
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { id: 5, artist_id: 5, enabled: false, backfill_state: null },
|
||||
}))
|
||||
await s.update(5, { enabled: false }, 5)
|
||||
// Cache survives (no full reload) and only source 5 changed.
|
||||
expect(s.byArtist.has(null)).toBe(true)
|
||||
const arr = s.byArtist.get(null)
|
||||
expect(arr.find(r => r.id === 5).enabled).toBe(false)
|
||||
expect(arr.find(r => r.id === 6).enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('startBackfill patches backfill_state in place', async () => {
|
||||
const s = useSourcesStore()
|
||||
s.byArtist.set(null, [{ id: 5, artist_id: 5, backfill_state: null }])
|
||||
stubFetch(() => ({ status: 200, body: { id: 5, backfill_state: 'running' } }))
|
||||
await s.startBackfill(5, 5)
|
||||
expect(s.byArtist.get(null).find(r => r.id === 5).backfill_state).toBe('running')
|
||||
})
|
||||
|
||||
it('remove drops the source from the cache in place', async () => {
|
||||
const s = useSourcesStore()
|
||||
s.byArtist.set(null, [{ id: 5, artist_id: 5 }, { id: 6, artist_id: 5 }])
|
||||
stubFetch(() => ({ status: 204, body: null }))
|
||||
await s.remove(5, 5)
|
||||
expect(s.byArtist.has(null)).toBe(true)
|
||||
expect(s.byArtist.get(null).map(r => r.id)).toEqual([6])
|
||||
})
|
||||
|
||||
it('create invalidates the all-cache and the artist-cache', async () => {
|
||||
const s = useSourcesStore()
|
||||
s.byArtist.set(null, [])
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "1001",
|
||||
"type": "post",
|
||||
"attributes": {
|
||||
"title": "Image gallery post",
|
||||
"published_at": "2026-05-01T12:00:00.000+00:00",
|
||||
"post_type": "image_file",
|
||||
"content": "<p>just a gallery</p>",
|
||||
"url": "https://www.patreon.com/posts/1001",
|
||||
"image": {
|
||||
"large_url": "https://c10.patreonusercontent.com/4/large/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/gallery-one.jpg",
|
||||
"url": "https://c10.patreonusercontent.com/4/orig/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/gallery-one.jpg"
|
||||
}
|
||||
},
|
||||
"relationships": {
|
||||
"images": {
|
||||
"data": [
|
||||
{"id": "9001", "type": "media"},
|
||||
{"id": "9002", "type": "media"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1002",
|
||||
"type": "post",
|
||||
"attributes": {
|
||||
"title": "Attachment post",
|
||||
"published_at": "2026-04-15T09:30:00.000+00:00",
|
||||
"post_type": "text_only",
|
||||
"content": "<p>here is a zip</p>",
|
||||
"url": "https://www.patreon.com/posts/1002",
|
||||
"post_file": {
|
||||
"name": "bonus-pack.zip",
|
||||
"url": "https://www.patreon.com/file?h=cccccccccccccccccccccccccccccccc&i=1002"
|
||||
}
|
||||
},
|
||||
"relationships": {
|
||||
"attachments_media": {
|
||||
"data": [
|
||||
{"id": "9003", "type": "media"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1003",
|
||||
"type": "post",
|
||||
"attributes": {
|
||||
"title": "Inline content post",
|
||||
"published_at": "2026-04-01T08:00:00.000+00:00",
|
||||
"post_type": "text_only",
|
||||
"content": "<p>look</p><figure><img src=\"https://c10.patreonusercontent.com/4/orig/dddddddddddddddddddddddddddddddd/inline.png?token=x&v=2\" alt=\"inline\"></figure>",
|
||||
"url": "https://www.patreon.com/posts/1003"
|
||||
},
|
||||
"relationships": {}
|
||||
},
|
||||
{
|
||||
"id": "1004",
|
||||
"type": "post",
|
||||
"attributes": {
|
||||
"title": "Video post",
|
||||
"published_at": "2026-03-20T18:45:00.000+00:00",
|
||||
"post_type": "video_external_file",
|
||||
"content": "<p>watch</p>",
|
||||
"url": "https://www.patreon.com/posts/1004",
|
||||
"post_file": {
|
||||
"name": "clip.m3u8",
|
||||
"url": "https://stream.mux.com/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.m3u8?token=jwt"
|
||||
}
|
||||
},
|
||||
"relationships": {}
|
||||
}
|
||||
],
|
||||
"included": [
|
||||
{
|
||||
"id": "9001",
|
||||
"type": "media",
|
||||
"attributes": {
|
||||
"file_name": "gallery-one.jpg",
|
||||
"download_url": "https://c10.patreonusercontent.com/4/orig/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/gallery-one.jpg",
|
||||
"image_urls": {
|
||||
"original": "https://c10.patreonusercontent.com/4/orig/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/gallery-one.jpg",
|
||||
"default": "https://c10.patreonusercontent.com/4/default/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/gallery-one.jpg"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "9002",
|
||||
"type": "media",
|
||||
"attributes": {
|
||||
"file_name": "gallery-two.jpg",
|
||||
"download_url": "https://c10.patreonusercontent.com/4/orig/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/gallery-two.jpg",
|
||||
"image_urls": {
|
||||
"original": "https://c10.patreonusercontent.com/4/orig/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/gallery-two.jpg"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "9003",
|
||||
"type": "media",
|
||||
"attributes": {
|
||||
"file_name": "bonus-pack.zip",
|
||||
"download_url": "https://www.patreon.com/file/download?h=cccccccccccccccccccccccccccccccc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "5555",
|
||||
"type": "campaign",
|
||||
"attributes": {
|
||||
"name": "Example Creator",
|
||||
"url": "https://www.patreon.com/example"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"next": "https://www.patreon.com/api/posts?filter%5Bcampaign_id%5D=5555&page%5Bcursor%5D=NEXTCURSOR123&sort=-published_at"
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,8 @@ async def test_verify_no_enabled_source_is_untestable(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypatch):
|
||||
"""A gallery-dl platform routes through GalleryDLService.verify; success
|
||||
stamps last_verified (platform-agnostic endpoint behavior)."""
|
||||
from backend.app.models import Artist, Source
|
||||
from backend.app.services import gallery_dl as gdl_mod
|
||||
|
||||
@@ -163,6 +165,45 @@ async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypa
|
||||
return (True, "Credentials valid — the feed authenticated.")
|
||||
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
|
||||
|
||||
await client.post("/api/credentials", json={
|
||||
"platform": "subscribestar", "credential_type": "cookies", "data": _NETSCAPE,
|
||||
})
|
||||
artist = Artist(name="Maewix", slug="maewix")
|
||||
db.add(artist)
|
||||
await db.flush()
|
||||
db.add(Source(
|
||||
artist_id=artist.id, platform="subscribestar",
|
||||
url="https://www.subscribestar.com/maewix", enabled=True, config_overrides={},
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post("/api/credentials/subscribestar/verify")
|
||||
body = await resp.get_json()
|
||||
assert body["valid"] is True
|
||||
assert body["last_verified"] is not None
|
||||
|
||||
# The stamp is persisted on the credential record.
|
||||
rec = await (await client.get("/api/credentials/subscribestar")).get_json()
|
||||
assert rec["last_verified"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_patreon_uses_native_ingester_not_gallery_dl(client, db, monkeypatch):
|
||||
"""plan #697 cutover: Patreon credential verify routes through the native
|
||||
ingester (verify_patreon_credential), NOT gallery-dl --simulate."""
|
||||
from backend.app.models import Artist, Source
|
||||
from backend.app.services import gallery_dl as gdl_mod
|
||||
from backend.app.services import patreon_ingester as pi_mod
|
||||
|
||||
async def _native_verify(url, cookies_path, overrides):
|
||||
return (True, "Credentials valid — the Patreon feed authenticated.")
|
||||
monkeypatch.setattr(pi_mod, "verify_patreon_credential", _native_verify)
|
||||
|
||||
# gallery-dl must NOT be consulted for Patreon.
|
||||
async def _boom(self, *args, **kwargs):
|
||||
raise AssertionError("gallery-dl verify must not run for patreon")
|
||||
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _boom)
|
||||
|
||||
await client.post("/api/credentials", json={
|
||||
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
|
||||
})
|
||||
@@ -180,10 +221,6 @@ async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypa
|
||||
assert body["valid"] is True
|
||||
assert body["last_verified"] is not None
|
||||
|
||||
# The stamp is persisted on the credential record.
|
||||
rec = await (await client.get("/api/credentials/patreon")).get_json()
|
||||
assert rec["last_verified"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_reports_auth_failure(client, db, monkeypatch):
|
||||
|
||||
+174
-14
@@ -5,6 +5,20 @@ from backend.app.models import Artist, Source
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_preflight_verify(monkeypatch):
|
||||
"""Backfill/recover arms run a pre-flight credential verify (plan #703 #2)
|
||||
which for Patreon would hit the network. Default it to 'valid' so the
|
||||
endpoint tests stay network-free; the dedicated pre-flight tests override
|
||||
this with a rejection/inconclusive verdict."""
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _ok(**kwargs):
|
||||
return (True, "Credentials valid")
|
||||
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _ok)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def artist(db):
|
||||
a = Artist(name="Alice", slug="alice")
|
||||
@@ -75,6 +89,39 @@ async def test_create_list_get_delete(client, artist):
|
||||
assert (await client.get(f"/api/sources/{sid}")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_kicks_off_backfill_for_enabled_source(client, artist, monkeypatch):
|
||||
"""A new enabled source dispatches its first walk immediately (no waiting
|
||||
for the next scheduler tick), unless its platform is in cooldown."""
|
||||
delays: list = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.download.download_source.delay",
|
||||
lambda *a, **k: delays.append(a),
|
||||
)
|
||||
resp = await client.post("/api/sources", json={
|
||||
"artist_id": artist.id, "platform": "patreon",
|
||||
"url": "https://patreon.com/kickoff",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
sid = (await resp.get_json())["id"]
|
||||
assert delays == [(sid,)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_disabled_source_does_not_kick_off(client, artist, monkeypatch):
|
||||
delays: list = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.download.download_source.delay",
|
||||
lambda *a, **k: delays.append(a),
|
||||
)
|
||||
resp = await client.post("/api/sources", json={
|
||||
"artist_id": artist.id, "platform": "patreon",
|
||||
"url": "https://patreon.com/disabled", "enabled": False,
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert delays == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rejects_unknown_platform(client, artist):
|
||||
resp = await client.post("/api/sources", json={
|
||||
@@ -199,7 +246,7 @@ async def test_list_derives_next_check_at_when_last_checked_set(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_endpoint_arms_source(client, artist, db):
|
||||
async def test_backfill_endpoint_start_and_stop(client, artist, db):
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-backfill", enabled=True,
|
||||
@@ -208,18 +255,21 @@ async def test_backfill_endpoint_arms_source(client, artist, db):
|
||||
await db.commit()
|
||||
sid = src.id
|
||||
|
||||
resp = await client.post(f"/api/sources/{sid}/backfill", json={"runs": 5})
|
||||
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "start"})
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["backfill_runs_remaining"] == 5
|
||||
assert body["backfill_state"] == "running"
|
||||
|
||||
# GET reflects the new state.
|
||||
# GET reflects the running state.
|
||||
one = await client.get(f"/api/sources/{sid}")
|
||||
assert (await one.get_json())["backfill_runs_remaining"] == 5
|
||||
assert (await one.get_json())["backfill_state"] == "running"
|
||||
|
||||
stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
|
||||
assert (await stopped.get_json())["backfill_state"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_endpoint_defaults_to_three(client, artist, db):
|
||||
async def test_backfill_endpoint_defaults_to_start(client, artist, db):
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-backfill-default", enabled=True,
|
||||
@@ -228,26 +278,136 @@ async def test_backfill_endpoint_defaults_to_three(client, artist, db):
|
||||
await db.commit()
|
||||
resp = await client.post(f"/api/sources/{src.id}/backfill", json={})
|
||||
body = await resp.get_json()
|
||||
assert body["backfill_runs_remaining"] == 3
|
||||
assert body["backfill_state"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_endpoint_rejects_out_of_range(client, artist, db):
|
||||
async def test_backfill_endpoint_rejects_bad_action(client, artist, db):
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-backfill-bad", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
bad = await client.post(f"/api/sources/{src.id}/backfill", json={"runs": 0})
|
||||
bad = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "nope"})
|
||||
assert bad.status_code == 400
|
||||
too_big = await client.post(
|
||||
f"/api/sources/{src.id}/backfill", json={"runs": 99}
|
||||
)
|
||||
assert too_big.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_endpoint_404_when_source_missing(client):
|
||||
resp = await client.post("/api/sources/999999/backfill", json={"runs": 3})
|
||||
resp = await client.post("/api/sources/999999/backfill", json={"action": "start"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_endpoint_recover_arms_bypass(client, artist, db):
|
||||
"""Plan #697: action='recover' arms a backfill that bypasses the Patreon
|
||||
seen-ledger; the response exposes backfill_bypass_seen=True so the UI badge
|
||||
can label it 'Recovering'. Stop clears it."""
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-recover", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
sid = src.id
|
||||
|
||||
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recover"})
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["backfill_state"] == "running"
|
||||
assert body["backfill_bypass_seen"] is True
|
||||
|
||||
stopped = await client.post(f"/api/sources/{sid}/backfill", json={"action": "stop"})
|
||||
stopped_body = await stopped.get_json()
|
||||
assert stopped_body["backfill_state"] is None
|
||||
assert stopped_body["backfill_bypass_seen"] is False
|
||||
|
||||
|
||||
# --- Plan #703 #2: pre-flight credential verify on backfill/recover arm -----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arm_blocked_when_credential_rejected(client, artist, db, monkeypatch):
|
||||
"""A definitively-rejected credential refuses the arm (409 + reason) instead
|
||||
of starting a doomed walk; the source is NOT armed."""
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _reject(**kwargs):
|
||||
return (False, "Patreon rejected the credential — cookies expired")
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _reject)
|
||||
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-preflight", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
sid = src.id
|
||||
|
||||
resp = await client.post(f"/api/sources/{sid}/backfill", json={"action": "recover"})
|
||||
assert resp.status_code == 409
|
||||
assert "expired" in ((await resp.get_json()).get("detail") or "")
|
||||
|
||||
# Not armed.
|
||||
one = await (await client.get(f"/api/sources/{sid}")).get_json()
|
||||
assert one["backfill_state"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arm_proceeds_when_credential_inconclusive(client, artist, db, monkeypatch):
|
||||
"""An inconclusive verify (network blip / drift) must NOT block the arm."""
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _inconclusive(**kwargs):
|
||||
return (None, "couldn't verify (network)")
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _inconclusive)
|
||||
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-incon", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "start"})
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["backfill_state"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_never_pre_flights(client, artist, db, monkeypatch):
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _boom(**kwargs):
|
||||
raise AssertionError("stop must not pre-flight verify")
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _boom)
|
||||
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://patreon.com/alice-stop", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "stop"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_dl_platform_arm_skips_pre_flight(client, artist, db, monkeypatch):
|
||||
"""Pre-flight is gated to native-ingester platforms (cheap verify); a
|
||||
gallery-dl source arms without the slow --simulate probe."""
|
||||
from backend.app.services import download_backends as db_mod
|
||||
|
||||
async def _boom(**kwargs):
|
||||
raise AssertionError("gallery-dl arm must not pre-flight verify")
|
||||
monkeypatch.setattr(db_mod, "verify_source_credential", _boom)
|
||||
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="subscribestar",
|
||||
url="https://www.subscribestar.com/x", enabled=True,
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
resp = await client.post(f"/api/sources/{src.id}/backfill", json={"action": "start"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -52,11 +52,12 @@ async def test_create_tag_missing_name_400(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag_name_only_defaults_to_general(client):
|
||||
"""IR-style: name without kind and without `kind:` prefix → general."""
|
||||
"""IR-style: name without kind and without `kind:` prefix → general.
|
||||
#701: operator-entered names are Title-Cased at the create endpoint."""
|
||||
resp = await client.post("/api/tags", json={"name": "sunset"})
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "sunset"
|
||||
assert body["name"] == "Sunset"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
|
||||
@@ -73,21 +74,23 @@ async def test_create_tag_with_kind_prefix(client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_kind_overrides_prefix_parsing(client):
|
||||
"""If caller passes explicit kind, don't re-parse the name —
|
||||
colon and prefix stay literal."""
|
||||
colon and prefix stay literal. #701: first letter capitalized, tail
|
||||
preserved (acronym-safe), so the 'S' in Saber survives."""
|
||||
resp = await client.post(
|
||||
"/api/tags", json={"name": "character:Saber", "kind": "general"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "character:Saber"
|
||||
assert body["name"] == "Character:Saber"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_prefix_kept_literal(client):
|
||||
"""`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name."""
|
||||
"""`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name.
|
||||
#701: Title-Cased as one word (no internal whitespace to split on)."""
|
||||
resp = await client.post("/api/tags", json={"name": "http://example.com"})
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "http://example.com"
|
||||
assert body["name"] == "Http://example.com"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
@@ -39,6 +39,24 @@ def test_cbz_alias(tmp_path):
|
||||
assert [n for n, _ in members] == ["p1.jpg"]
|
||||
|
||||
|
||||
def test_misnamed_zip_detected_and_extracted(tmp_path):
|
||||
"""A real zip whose filename has no usable extension (Patreon attachment
|
||||
URL-blob name) is detected by magic bytes and its members extracted —
|
||||
previously it was filed as an opaque attachment and never opened."""
|
||||
z = tmp_path / "01_https___www.patreon.com_media-u_v3_131083093"
|
||||
_zip(z, {"a.jpg": b"img", "b.png": b"img2"})
|
||||
assert is_archive(z)
|
||||
with extract_archive(z) as members:
|
||||
assert sorted(n for n, _ in members) == ["a.jpg", "b.png"]
|
||||
|
||||
|
||||
def test_non_archive_with_dotted_name_is_not_archive(tmp_path):
|
||||
"""A non-archive file with a dotted/extension-less name isn't misdetected."""
|
||||
f = tmp_path / "01_https___www.patreon.com_media-u_v3_999"
|
||||
f.write_bytes(b"\x89PNG\r\n\x1a\n not really but not a zip")
|
||||
assert not is_archive(f)
|
||||
|
||||
|
||||
def test_corrupt_archive_yields_nothing(tmp_path):
|
||||
bad = tmp_path / "broken.zip"
|
||||
bad.write_bytes(b"not a zip at all")
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user