feat(fc2a): schema migration 0002 — tag kinds + fandom hierarchy + import lifecycle

Adds Tag.kind enum (artist/character/fandom/general/series/archive/post/meta/rating),
Tag.fandom_id FK with CHECK constraint (only valid for kind='character'), and a
kind-aware uniqueness index so the same name can exist across kinds and the same
character name can exist in different fandoms.

Adds ImportBatch + ImportTask state-machine tables for scan tracking, plus a
single-row ImportSettings table (CHECK id=1) holding the importer's filter knobs.

Adds image_record.integrity_status column (defaults to 'unknown'); FC-2e
populates this via the integrity verifier.

Drops the unused tag.namespace column from FC-1 — superseded by kind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:03:41 -04:00
parent ff122c55eb
commit 9eb1614fbf
8 changed files with 425 additions and 8 deletions
+8 -1
View File
@@ -6,9 +6,12 @@ from .credential import Credential
from .download_event import DownloadEvent
from .image_provenance import ImageProvenance
from .image_record import ImageRecord
from .import_batch import ImportBatch
from .import_settings import ImportSettings
from .import_task import ImportTask
from .post import Post
from .source import Source
from .tag import Tag, image_tag
from .tag import Tag, TagKind, image_tag
__all__ = [
"Base",
@@ -19,6 +22,10 @@ __all__ = [
"ImageRecord",
"ImageProvenance",
"Tag",
"TagKind",
"image_tag",
"DownloadEvent",
"ImportBatch",
"ImportTask",
"ImportSettings",
]
+6
View File
@@ -40,6 +40,12 @@ class ImageRecord(Base):
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
integrity_status: Mapped[str] = mapped_column(
String(24), nullable=False, default="unknown", index=True
)
# Thumbnail (populated by FC-2)
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True)
+32
View File
@@ -0,0 +1,32 @@
"""ImportBatch — one scan run, aggregating many ImportTasks."""
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
class ImportBatch(Base):
__tablename__ = "import_batch"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
triggered_by: Mapped[str] = mapped_column(String(32), nullable=False)
source_path: Mapped[str] = mapped_column(Text, nullable=False)
scan_mode: Mapped[str] = mapped_column(String(16), nullable=False) # quick|deep
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
total_files: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
# running | complete | cancelled
tasks = relationship("ImportTask", back_populates="batch", cascade="all, delete-orphan")
+28
View File
@@ -0,0 +1,28 @@
"""ImportSettings — single-row table holding the importer's tunable knobs.
Enforced as a single row via a CHECK (id = 1) constraint. The application
always SELECTs id=1 and never inserts/deletes after the initial migration.
"""
from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ImportSettings(Base):
__tablename__ = "import_settings"
__table_args__ = (CheckConstraint("id = 1", name="ck_import_settings_singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
import_scan_path: Mapped[str] = mapped_column(Text, nullable=False, default="/import")
min_width: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
min_height: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
skip_transparent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
transparency_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.9)
skip_single_color: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95)
single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
+41
View File
@@ -0,0 +1,41 @@
"""ImportTask — a single source file to import as part of a batch.
State machine: pending -> queued -> processing -> complete | skipped | failed.
Workers crashing mid-task leave rows in 'processing'; the recovery sweep
(tasks/maintenance.py::recover_interrupted_tasks) re-queues rows that have
been processing longer than the stuck-task threshold.
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
class ImportTask(Base):
__tablename__ = "import_task"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
batch_id: Mapped[int] = mapped_column(
ForeignKey("import_batch.id", ondelete="CASCADE"), nullable=False, index=True
)
source_path: Mapped[str] = mapped_column(Text, nullable=False)
task_type: Mapped[str] = mapped_column(String(16), nullable=False) # media|archive
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
result_image_id: Mapped[int | None] = mapped_column(
ForeignKey("image_record.id", ondelete="SET NULL"), nullable=True
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
batch = relationship("ImportBatch", back_populates="tasks")
+46 -7
View File
@@ -1,17 +1,42 @@
"""Tag + ImageTag association.
Tags use namespaced strings (``artist:Name``, ``character:Name``,
``rating:safe``, etc.). Artist tags are kept in sync with the Artist table —
the sync trigger lands in FC-2.
Tags carry a ``kind`` enum and (for kind='character') an optional
``fandom_id`` FK to another tag of kind='fandom'. The uniqueness key is
(name, kind, COALESCE(fandom_id, 0)) so the same name can exist across
different kinds and the same character name can exist in different fandoms.
"""
from datetime import datetime
from enum import Enum
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Table, func
from sqlalchemy import (
CheckConstraint,
Column,
DateTime,
Enum as SQLEnum,
ForeignKey,
Integer,
String,
Table,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
class TagKind(str, Enum):
artist = "artist"
character = "character"
fandom = "fandom"
general = "general"
series = "series"
archive = "archive"
post = "post"
meta = "meta"
rating = "rating"
image_tag = Table(
"image_tag",
Base.metadata,
@@ -21,20 +46,34 @@ image_tag = Table(
primary_key=True,
),
Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True),
Column("source", String(32), nullable=False, default="manual"), # manual|auto|ml
Column("source", String(32), nullable=False, default="manual"),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
class Tag(Base):
__tablename__ = "tag"
__table_args__ = (
CheckConstraint(
"(fandom_id IS NULL) OR (kind = 'character')",
name="ck_tag_fandom_requires_character",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
namespace: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
kind: Mapped[TagKind] = mapped_column(
SQLEnum(TagKind, name="tag_kind", values_callable=lambda e: [m.value for m in e]),
nullable=False,
default=TagKind.general,
)
fandom_id: Mapped[int | None] = mapped_column(
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
images = relationship("ImageRecord", secondary=image_tag, backref="tags")
fandom = relationship("Tag", remote_side="Tag.id", foreign_keys=[fandom_id])