diff --git a/.gitleaksignore b/.gitleaksignore
index 0d9bbeb4..8c90ce14 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -3,9 +3,10 @@ tests/api/test_comicvine_key.py:generic-api-key:253
tests/integration/test_security_endpoints.py:generic-api-key:206
tests/unit/test_secret_validation.py:generic-api-key:103
tests/fixtures/prowlarr_test_fixtures.json:square-access-token:206168
-tests/unit/test_import_story_arc_staging.py:generic-api-key:456
-tests/unit/test_import_story_arc_staging.py:generic-api-key:468
-tests/unit/test_import_story_arc_staging.py:generic-api-key:480
+# Synthetic folder cohort paths; exact locations shifted with the test's imports.
+tests/unit/test_import_story_arc_staging.py:generic-api-key:457
+tests/unit/test_import_story_arc_staging.py:generic-api-key:469
+tests/unit/test_import_story_arc_staging.py:generic-api-key:481
# Historical synthetic folder cohort IDs, not credentials. Keep commit/path/line scope.
ecd22c27eb7607e072ddb98d921738fb213896e4:tests/unit/test_import_scan_pipeline.py:generic-api-key:502
ecd22c27eb7607e072ddb98d921738fb213896e4:tests/unit/test_import_story_arc_staging.py:generic-api-key:443
diff --git a/.grype.yaml b/.grype.yaml
index 3285d719..411c7936 100644
--- a/.grype.yaml
+++ b/.grype.yaml
@@ -21,10 +21,13 @@ ignore:
# not imported comic XML. Residual upstream-library risk remains until DHI
# ships fixed packages; all other High findings continue to block.
#
- # Keep the existing libc review deadline of 2026-09-30 or the next base
- # refresh, whichever comes first. Keep the existing Expat review deadline of
- # 2026-10-07 or the next base refresh, whichever comes first. Remove these
- # entries as soon as fixed stable packages are available.
+ # Re-reviewed and renewed by Adam Hernandez on 2026-10-01 ONLY for the two
+ # libc6 2.41-12+deb13u4+dhi1 matches below: re-review by 2026-10-04 or the
+ # next base refresh. Debian 13 has no fixed stable packages, and no direct
+ # affected calls were found in Pullbox or its pinned MEGA SDK; dependency
+ # reachability remains unproven. Older libc exceptions are not renewed.
+ # Keep the existing Expat review deadline of 2026-10-07 or the next base
+ # refresh, whichever comes first. Remove entries when fixed packages ship.
# https://security-tracker.debian.org/tracker/CVE-2026-5435
# https://security-tracker.debian.org/tracker/CVE-2026-19499
# https://security-tracker.debian.org/tracker/CVE-2026-66046
@@ -33,14 +36,40 @@ ignore:
# https://security-tracker.debian.org/tracker/CVE-2026-93990
- vulnerability: CVE-2026-5435
package:
- name: libc6
+ name: '^libc6$'
version: 2.41-12+deb13u4+dhi1
type: deb
- vulnerability: CVE-2026-19499
package:
- name: libc6
+ name: '^libc6$'
version: 2.41-12+deb13u4+dhi1
type: deb
+
+ # Approved by Adam Hernandez on 2026-10-01 for ONLY these three exact package
+ # matches. Accepted risk, NOT a fix or a claim that all dependencies are safe.
+ # GCC's fix is in the PBDS binary-heap erase_if template header. The runtime
+ # contains no compiler; neither Pullbox's native bridge nor its pinned MEGA
+ # SDK includes PBDS or calls this erase_if path. Third-party reachability and
+ # DHI backport status remain unproven; Debian 13 has no fixed stable package.
+ # Re-review by 2026-10-04 or at the next DHI refresh, whichever comes first.
+ # All other High findings and other package revisions remain blocking.
+ # https://security-tracker.debian.org/tracker/CVE-2026-102010
+ # https://github.com/gcc-mirror/gcc/commit/aaa8351f4d2e636f9680a1f0a8ebc2f0a60611e6
+ - vulnerability: CVE-2026-102010
+ package:
+ name: '^gcc-14-base$'
+ version: 14.2.0-19+dhi3
+ type: deb
+ - vulnerability: CVE-2026-102010
+ package:
+ name: '^libgcc-s1$'
+ version: 14.2.0-19+dhi3
+ type: deb
+ - vulnerability: CVE-2026-102010
+ package:
+ name: '^libstdc\+\+6$'
+ version: 14.2.0-19+dhi3
+ type: deb
- vulnerability: CVE-2026-66046
package:
name: libexpat1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4390a1b9..c7bf5b5e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Added opt-in Metron API and read-only GCD Local series search, provider linking,
+ and reviewed metadata refresh alongside the existing ComicVine sources.
+- Added explicit existing-CBZ metadata review and coordinated ComicInfo.xml and
+ MetronInfo.xml output, including source choices for conflicting descriptive
+ values. Identity conflicts, reference-only files, and unsafe archives remain blocked.
+- Added a Series Details preview and approved `series.json` write using the saved
+ reconciled metadata, verified provider links, and field provenance. Safe custom
+ JSON fields are preserved; shared, reference-only, and read-only folders stay
+ unchanged. Split managed folders are reviewed individually.
+
+Automatic import metadata writing, GCD API v2, and LOCG enrichment are not enabled
+by this development preview. This is not the completed v2.0 general release.
+
## [1.3.0] - 2026-09-15
Minor release adding Story Arc management, an optional local Comic Vine catalog,
diff --git a/alembic/versions/a8u9v0w1x234_settle_stopped_archive_publications.py b/alembic/versions/a8u9v0w1x234_settle_stopped_archive_publications.py
new file mode 100644
index 00000000..ef5f9274
--- /dev/null
+++ b/alembic/versions/a8u9v0w1x234_settle_stopped_archive_publications.py
@@ -0,0 +1,39 @@
+"""Retain stopped-owner publication evidence independently of canonical adoption."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "a8u9v0w1x234"
+down_revision = "z7t8u9v0w123"
+branch_labels = None
+depends_on = None
+
+
+def _constraints(*, settled: bool) -> None:
+ terminal = "'abandoned','finalized'" + (",'settled'" if settled else "")
+ states = "'intended','published','abandoned','review','finalized'" + (
+ ",'settled'" if settled else ""
+ )
+ with op.batch_alter_table("archive_metadata_publications") as batch:
+ batch.drop_constraint("archive_publication_state", type_="check")
+ batch.drop_constraint("ck_archive_publication_reservation", type_="check")
+ batch.create_check_constraint("archive_publication_state", f"state IN ({states})")
+ batch.create_check_constraint(
+ "ck_archive_publication_reservation",
+ f"(state IN ({terminal}) AND active_file_id IS NULL AND active_path_key IS NULL) OR "
+ f"(state NOT IN ({terminal}) AND active_path_key IS NOT NULL)",
+ )
+
+
+def upgrade() -> None:
+ _constraints(settled=True)
+
+
+def downgrade() -> None:
+ table = sa.table("archive_metadata_publications", sa.column("state"))
+ if op.get_bind().scalar(
+ sa.select(sa.func.count()).select_from(table).where(table.c.state == "settled")
+ ):
+ raise RuntimeError("Cannot downgrade settled archive publication evidence.")
+ _constraints(settled=False)
diff --git a/alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py b/alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py
new file mode 100644
index 00000000..4776f302
--- /dev/null
+++ b/alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py
@@ -0,0 +1,47 @@
+"""Retain original, output and backup evidence for Library conversion recovery."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "b9v0w1x2y345"
+down_revision = "a8u9v0w1x234"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "library_conversions",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("operation_id", sa.String(36), nullable=False, unique=True),
+ sa.Column(
+ "library_file_id", sa.Integer(), sa.ForeignKey("library_files.id", ondelete="SET NULL")
+ ),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.Column("state", sa.String(10), nullable=False),
+ sa.Column("plan_json", sa.Text(), nullable=False),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.CheckConstraint(
+ "state IN ('intended','registered','complete','abandoned','review')",
+ name="ck_library_conversion_state",
+ ),
+ sa.CheckConstraint(
+ "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_conversion_plan_size"
+ ),
+ )
+ op.create_index("ix_library_conversions_active", "library_conversions", ["active"])
+
+
+def downgrade() -> None:
+ table = sa.table("library_conversions", sa.column("state"))
+ if op.get_bind().scalar(
+ sa.select(sa.func.count()).select_from(table).where(table.c.state != "abandoned")
+ ):
+ raise RuntimeError("Resolve retained library conversion evidence before downgrading.")
+ op.drop_table("library_conversions")
diff --git a/alembic/versions/c0w1x2y3z456_add_library_removal_journal.py b/alembic/versions/c0w1x2y3z456_add_library_removal_journal.py
new file mode 100644
index 00000000..2423066c
--- /dev/null
+++ b/alembic/versions/c0w1x2y3z456_add_library_removal_journal.py
@@ -0,0 +1,44 @@
+"""Retain authorization and private staging evidence for library removal."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "c0w1x2y3z456"
+down_revision = "b9v0w1x2y345"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "library_removals",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("operation_id", sa.String(36), nullable=False, unique=True),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.Column("state", sa.String(10), nullable=False),
+ sa.Column("plan_json", sa.Text(), nullable=False),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.CheckConstraint(
+ "state IN ('intended','detached','complete','abandoned','review')",
+ name="ck_library_removal_state",
+ ),
+ sa.CheckConstraint(
+ "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_removal_plan_size"
+ ),
+ )
+ op.create_index("ix_library_removals_active", "library_removals", ["active"])
+
+
+def downgrade() -> None:
+ table = sa.table("library_removals", sa.column("state"))
+ if op.get_bind().scalar(
+ sa.select(sa.func.count()).select_from(table).where(table.c.state != "abandoned")
+ ):
+ raise RuntimeError("Resolve retained library removal evidence before downgrading.")
+ op.drop_table("library_removals")
diff --git a/alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py b/alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py
new file mode 100644
index 00000000..2fc0ecc2
--- /dev/null
+++ b/alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py
@@ -0,0 +1,36 @@
+"""Retain bounded backup and cleanup evidence after removal detachment."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "d1x2y3z4a567"
+down_revision = "c0w1x2y3z456"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "library_removals",
+ sa.Column(
+ "cleanup_json",
+ sa.Text(),
+ sa.CheckConstraint(
+ "cleanup_json IS NULL OR length(cleanup_json) BETWEEN 2 AND 4096",
+ name="ck_library_removal_cleanup_size",
+ ),
+ nullable=True,
+ ),
+ )
+
+
+def downgrade() -> None:
+ table = sa.table("library_removals", sa.column("cleanup_json"))
+ if op.get_bind().scalar(
+ sa.select(sa.func.count()).select_from(table).where(table.c.cleanup_json.is_not(None))
+ ):
+ raise RuntimeError("Resolve retained removal cleanup evidence before downgrading.")
+ with op.batch_alter_table("library_removals") as batch:
+ batch.drop_constraint("ck_library_removal_cleanup_size", type_="check")
+ batch.drop_column("cleanup_json")
diff --git a/alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py b/alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py
new file mode 100644
index 00000000..e2599c98
--- /dev/null
+++ b/alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py
@@ -0,0 +1,57 @@
+"""Index retained removal trash receipts for bounded retention lookups."""
+
+import hashlib
+import json
+import os
+from pathlib import Path
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "e2y3z4a5b678"
+down_revision = "d1x2y3z4a567"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("library_removals", sa.Column("trash_path_key", sa.String(64), nullable=True))
+ table = sa.table(
+ "library_removals", sa.column("id"), sa.column("plan_json"), sa.column("trash_path_key")
+ )
+ connection = op.get_bind()
+ last_id = 0
+ while rows := connection.execute(
+ sa.select(table.c.id, table.c.plan_json)
+ .where(table.c.id > last_id)
+ .order_by(table.c.id)
+ .limit(8)
+ ).all():
+ for row_id, encoded in rows:
+ last_id = row_id
+ if not isinstance(encoded, str) or len(encoded.encode("utf-8")) > 65536:
+ raise RuntimeError("Invalid retained removal evidence; repair before upgrading.")
+ plan = json.loads(encoded)
+ if not isinstance(plan, dict):
+ raise RuntimeError("Invalid retained removal plan; repair before upgrading.")
+ value = plan.get("trash_path")
+ if value is None:
+ continue
+ if (
+ not isinstance(value, str)
+ or not Path(value).is_absolute()
+ or ".." in Path(value).parts
+ ):
+ raise RuntimeError("Invalid retained trash path; repair before upgrading.")
+ connection.execute(
+ table.update()
+ .where(table.c.id == row_id)
+ .values(trash_path_key=hashlib.sha256(os.fsencode(value)).hexdigest())
+ )
+ op.create_index("ix_library_removals_trash_path_key", "library_removals", ["trash_path_key"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_library_removals_trash_path_key", table_name="library_removals")
+ op.drop_column("library_removals", "trash_path_key")
diff --git a/alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py b/alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py
new file mode 100644
index 00000000..e4f3d24f
--- /dev/null
+++ b/alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py
@@ -0,0 +1,27 @@
+"""Track terminal removal workspace cleanup without discarding journal evidence."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "f3z4a5b6c789"
+down_revision = "e2y3z4a5b678"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "library_removals",
+ sa.Column("workspace_cleaned", sa.Boolean(), nullable=False, server_default=sa.false()),
+ )
+ op.create_index(
+ "ix_library_removal_workspace_pending",
+ "library_removals",
+ ["workspace_cleaned", "active", "id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_library_removal_workspace_pending", table_name="library_removals")
+ op.drop_column("library_removals", "workspace_cleaned")
diff --git a/alembic/versions/g4a5b6c7d890_add_series_catalog_exclusions.py b/alembic/versions/g4a5b6c7d890_add_series_catalog_exclusions.py
new file mode 100644
index 00000000..c478d702
--- /dev/null
+++ b/alembic/versions/g4a5b6c7d890_add_series_catalog_exclusions.py
@@ -0,0 +1,34 @@
+"""Retain explicit GCD Add exclusions independently of issue ownership."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "g4a5b6c7d890"
+down_revision = "f3z4a5b6c789"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "series", sa.Column("catalog_exclusions", sa.JSON(), nullable=False, server_default="[]")
+ )
+
+
+def downgrade() -> None:
+ table = sa.table("series", sa.column("catalog_exclusions", sa.JSON()))
+ if (
+ op.get_bind()
+ .execute(
+ sa.select(table.c.catalog_exclusions)
+ .where(sa.cast(table.c.catalog_exclusions, sa.String()) != "[]")
+ .limit(1)
+ )
+ .first()
+ is not None
+ ):
+ raise RuntimeError(
+ "Retained catalog exclusions prevent downgrade; preserve the review first."
+ )
+ op.drop_column("series", "catalog_exclusions")
diff --git a/alembic/versions/h5b6c7d8e901_add_file_metadata_job.py b/alembic/versions/h5b6c7d8e901_add_file_metadata_job.py
new file mode 100644
index 00000000..4895faa1
--- /dev/null
+++ b/alembic/versions/h5b6c7d8e901_add_file_metadata_job.py
@@ -0,0 +1,44 @@
+"""Allow paired metadata jobs without changing utility history or lifecycle."""
+
+from sqlalchemy import text
+
+from alembic import op
+
+revision = "h5b6c7d8e901"
+down_revision = "g4a5b6c7d890"
+branch_labels = None
+depends_on = None
+
+_OLD = (
+ "file_convert",
+ "mass_convert_pipeline",
+ "mass_rename",
+ "db_check_cleanup",
+ "series_rescan",
+ "export_library",
+ "integrity_check",
+ "library_permissions",
+ "rollback",
+)
+
+
+def _replace(values: tuple[str, ...]) -> None:
+ with op.batch_alter_table("utility_jobs") as batch:
+ batch.drop_constraint("ck_utility_jobs_job_type", type_="check")
+ batch.create_check_constraint(
+ "ck_utility_jobs_job_type", "job_type IN (" + ", ".join(repr(v) for v in values) + ")"
+ )
+
+
+def upgrade() -> None:
+ op.create_index("ix_import_files_library_file_id", "import_files", ["library_file_id"])
+ _replace((*_OLD, "file_metadata"))
+
+
+def downgrade() -> None:
+ if op.get_bind().scalar(
+ text("SELECT count(*) FROM utility_jobs WHERE job_type = 'file_metadata'")
+ ):
+ raise RuntimeError("Remove saved file metadata jobs before downgrading.")
+ _replace(_OLD)
+ op.drop_index("ix_import_files_library_file_id", table_name="import_files")
diff --git a/alembic/versions/p7j8k9l0m123_add_metadata_identity_storage.py b/alembic/versions/p7j8k9l0m123_add_metadata_identity_storage.py
new file mode 100644
index 00000000..2f4e7b9d
--- /dev/null
+++ b/alembic/versions/p7j8k9l0m123_add_metadata_identity_storage.py
@@ -0,0 +1,130 @@
+"""Add provider identity ownership and retained evidence history."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "p7j8k9l0m123"
+down_revision = "o6i7j8k9l012"
+branch_labels = None
+depends_on = None
+
+_KINDS = (("series", "series"), ("issue", "issues"), ("story_arc", "story_arcs"))
+_NAMESPACES = ("comicvine", "metron", "gcd", "locg")
+_STATES = ("observed", "verified", "conflicted", "stale", "rejected")
+_EVIDENCE = (
+ "legacy_backfill",
+ "mylar_database",
+ "series_json",
+ "comicinfo_xml",
+ "metroninfo_xml",
+ "provider_result",
+ "provider_crosswalk",
+ "user_selection",
+ "migration",
+)
+
+
+def _enum(name: str, values: tuple[str, ...]) -> sa.Enum:
+ return sa.Enum(*values, name=name, native_enum=False, create_constraint=True)
+
+
+def _decimal_id_check() -> str:
+ remaining = "external_id"
+ for digit in "0123456789":
+ remaining = f"replace({remaining}, '{digit}', '')"
+ return (
+ "length(external_id) BETWEEN 1 AND 255 AND "
+ "substr(external_id, 1, 1) IN ('1','2','3','4','5','6','7','8','9') AND "
+ f"{remaining} = ''"
+ )
+
+
+def _claim_columns(kind: str, parent: str) -> list[sa.Column]:
+ return [
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column(
+ f"{kind}_id",
+ sa.Integer,
+ sa.ForeignKey(f"{parent}.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("identity_namespace", _enum("identitynamespace", _NAMESPACES), nullable=False),
+ sa.Column("external_id", sa.String(255), nullable=False),
+ sa.Column(
+ "verification_state", _enum("identityverificationstate", _STATES), nullable=False
+ ),
+ sa.Column("evidence_kind", _enum("identityevidencekind", _EVIDENCE), nullable=False),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ ]
+
+
+def upgrade() -> None:
+ existing = set(sa.inspect(op.get_bind()).get_table_names())
+ for kind, parent in _KINDS:
+ name = f"{kind}_external_identities"
+ if kind != "story_arc" and name not in existing:
+ op.create_table(
+ name,
+ *_claim_columns(kind, parent),
+ sa.Column("resource_url", sa.String(1000)),
+ sa.Column("evidence_locator", sa.Text),
+ sa.Column("verified_at", sa.DateTime(timezone=True)),
+ sa.Column("last_seen_at", sa.DateTime(timezone=True)),
+ sa.Column("revision", sa.Integer, server_default="1", nullable=False),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ sa.UniqueConstraint("identity_namespace", "external_id", name=f"uq_{name}_owner"),
+ sa.UniqueConstraint(
+ f"{kind}_id", "identity_namespace", name=f"uq_{name}_namespace"
+ ),
+ sa.CheckConstraint(
+ "verification_state IN ('verified', 'stale', 'conflicted')",
+ name=f"ck_{name}_active_state",
+ ),
+ sa.CheckConstraint(_decimal_id_check(), name=f"ck_{name}_decimal_id"),
+ sa.CheckConstraint("revision > 0", name=f"ck_{name}_revision"),
+ )
+ name = f"{kind}_identity_events"
+ if name not in existing:
+ op.create_table(
+ name,
+ *_claim_columns(kind, parent),
+ sa.Column("event_key", sa.String(64), nullable=False),
+ sa.Column("request_fingerprint", sa.String(64), nullable=False),
+ sa.Column("request_json", sa.Text, nullable=False),
+ sa.UniqueConstraint(f"{kind}_id", "event_key", name=f"uq_{name}_retry"),
+ sa.CheckConstraint(_decimal_id_check(), name=f"ck_{name}_decimal_id"),
+ sa.CheckConstraint("length(event_key) = 64", name=f"ck_{name}_event_key"),
+ sa.CheckConstraint(
+ "length(request_fingerprint) = 64", name=f"ck_{name}_fingerprint"
+ ),
+ sa.CheckConstraint(
+ "length(request_json) BETWEEN 2 AND 8192", name=f"ck_{name}_request"
+ ),
+ )
+ # A SQLite DDL interruption can leave the table without its index.
+ indexes = {index["name"] for index in sa.inspect(op.get_bind()).get_indexes(name)}
+ if f"ix_{name}_claim" not in indexes:
+ op.create_index(
+ f"ix_{name}_claim", name, [f"{kind}_id", "identity_namespace", "external_id", "id"]
+ )
+
+
+def downgrade() -> None:
+ names = [f"{kind}_identity_events" for kind, _ in _KINDS]
+ names += ["issue_external_identities", "series_external_identities"]
+ for name in names:
+ table = sa.table(name, sa.column("id"))
+ if op.get_bind().execute(sa.select(table.c.id).limit(1)).first() is not None:
+ raise RuntimeError(
+ "Cannot remove metadata identity storage while retained identity data exists."
+ )
+ for name in names:
+ op.drop_table(name)
diff --git a/alembic/versions/q8k9l0m1n234_backfill_comicvine_identities.py b/alembic/versions/q8k9l0m1n234_backfill_comicvine_identities.py
new file mode 100644
index 00000000..f87a6680
--- /dev/null
+++ b/alembic/versions/q8k9l0m1n234_backfill_comicvine_identities.py
@@ -0,0 +1,192 @@
+"""Preserve existing canonical ComicVine ownership in generic identity storage."""
+
+import hashlib
+import json
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "q8k9l0m1n234"
+down_revision = "p7j8k9l0m123"
+branch_labels = None
+depends_on = None
+
+_PAGE_SIZE = 400
+_OPERATION_ID = "b69082d3-830e-4a72-9ad7-818c9d169381"
+_KINDS = (("series", "series"), ("issue", "issues"))
+
+
+def _json(value: dict) -> str:
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
+
+
+def _digest(value: str) -> str:
+ return hashlib.sha256(value.encode("ascii")).hexdigest()
+
+
+def _expected(kind: str, local_id: int, external_id: str) -> tuple[dict, dict]:
+ """Frozen v1 event envelope; migrations must not import evolving runtime policy."""
+ origin = {"record_kind": kind, "record_id": local_id}
+ revision_digest = _digest(f"legacy_comicvine:{kind}:{local_id}:{external_id}")
+ slot = {
+ "version": 1,
+ "operation_id": _OPERATION_ID,
+ "entity_kind": kind,
+ "local_id": local_id,
+ "identity_namespace": "comicvine",
+ "action": "verify",
+ "origin": origin,
+ "evidence_revision": revision_digest,
+ }
+ payload = {
+ "version": 1,
+ "operation_id": _OPERATION_ID,
+ "local_id": local_id,
+ "identity": {"namespace": "comicvine", "entity_kind": kind, "external_id": external_id},
+ "action": "verify",
+ "evidence_kind": "legacy_backfill",
+ "evidence_revision": revision_digest,
+ "origin": origin,
+ "actor": "migration",
+ "actor_user_id": None,
+ "review_revision": None,
+ }
+ request_json = _json(payload)
+ common = {
+ f"{kind}_id": local_id,
+ "identity_namespace": "comicvine",
+ "external_id": external_id,
+ "verification_state": "verified",
+ "evidence_kind": "legacy_backfill",
+ }
+ return (
+ {
+ **common,
+ "resource_url": None,
+ "evidence_locator": _json(origin),
+ "verified_at": None,
+ "last_seen_at": None,
+ "revision": 1,
+ },
+ {
+ **common,
+ "event_key": _digest(_json(slot)),
+ "request_fingerprint": _digest(request_json),
+ "request_json": request_json,
+ },
+ )
+
+
+def _tables(kind: str, parent: str) -> tuple[sa.Table, sa.Table, sa.Table]:
+ metadata = sa.MetaData()
+ connection = op.get_bind()
+ return tuple(
+ sa.Table(name, metadata, autoload_with=connection)
+ for name in (
+ parent,
+ f"{kind}_external_identities",
+ f"{kind}_identity_events",
+ )
+ )
+
+
+def _valid_id(value: object) -> bool:
+ return type(value) is int and 0 < value < 2**63
+
+
+def _check_equal(row: sa.RowMapping, expected: dict) -> None:
+ if any(row[key] != value for key, value in expected.items()):
+ raise RuntimeError(
+ "Metadata identity data differs from its legacy backfill; "
+ "review before retry or downgrade."
+ )
+
+
+def upgrade() -> None:
+ connection = op.get_bind()
+ for kind, parent in _KINDS:
+ legacy, active, events = _tables(kind, parent)
+ target_key = f"{kind}_id"
+ cursor = 0
+ while rows := connection.execute(
+ sa.select(legacy.c.id, legacy.c.comicvine_id)
+ .where(legacy.c.id > cursor)
+ .order_by(legacy.c.id)
+ .limit(_PAGE_SIZE)
+ ).all():
+ cursor = rows[-1].id
+ valid = [row for row in rows if _valid_id(row.comicvine_id)]
+ if not valid:
+ continue
+ target_ids = [row.id for row in valid]
+ current = {
+ row[target_key]: row
+ for row in connection.execute(
+ sa.select(active).where(
+ active.c[target_key].in_(target_ids),
+ active.c.identity_namespace == "comicvine",
+ )
+ ).mappings()
+ }
+ expected = [_expected(kind, row.id, str(row.comicvine_id)) for row in valid]
+ prior_events = {
+ row.event_key: row
+ for row in connection.execute(
+ sa.select(events).where(
+ events.c.event_key.in_([event["event_key"] for _, event in expected]),
+ events.c[target_key].in_(target_ids),
+ )
+ ).mappings()
+ }
+ new_owners, new_events = [], []
+ for owner, event in expected:
+ if owner[target_key] in current:
+ _check_equal(current[owner[target_key]], owner)
+ else:
+ new_owners.append(owner)
+ if event["event_key"] in prior_events:
+ _check_equal(prior_events[event["event_key"]], event)
+ else:
+ new_events.append(event)
+ if new_owners:
+ connection.execute(sa.insert(active), new_owners)
+ if new_events:
+ connection.execute(sa.insert(events), new_events)
+
+
+def downgrade() -> None:
+ connection = op.get_bind()
+ arc_events = sa.table("story_arc_identity_events", sa.column("id"))
+ if connection.execute(sa.select(arc_events.c.id).limit(1)).first() is not None:
+ raise RuntimeError("Cannot discard retained Story Arc identity history during downgrade.")
+ tables = []
+ # Preflight every row before deleting anything, including on SQLite.
+ for kind, parent in _KINDS:
+ legacy, active, events = _tables(kind, parent)
+ for table, expected_index in ((active, 0), (events, 1)):
+ tables.append(table)
+ cursor = 0
+ while (
+ rows := connection.execute(
+ sa.select(table, legacy.c.comicvine_id)
+ .select_from(table.outerjoin(legacy, table.c[f"{kind}_id"] == legacy.c.id))
+ .where(table.c.id > cursor)
+ .order_by(table.c.id)
+ .limit(_PAGE_SIZE)
+ )
+ .mappings()
+ .all()
+ ):
+ cursor = rows[-1]["id"]
+ for row in rows:
+ if not _valid_id(row["comicvine_id"]):
+ raise RuntimeError(
+ "Cannot discard metadata identity data without an equivalent legacy ID."
+ )
+ expected = _expected(kind, row[f"{kind}_id"], str(row["comicvine_id"]))[
+ expected_index
+ ]
+ _check_equal(row, expected)
+ for table in reversed(tables):
+ connection.execute(sa.delete(table))
diff --git a/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py b/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py
new file mode 100644
index 00000000..a41297cd
--- /dev/null
+++ b/alembic/versions/r9l0m1n2o345_story_arc_identity_lifecycle.py
@@ -0,0 +1,465 @@
+"""Add canonical Story Arc identity lifecycle without changing import scopes."""
+
+import hashlib
+import json
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "r9l0m1n2o345"
+down_revision = "q8k9l0m1n234"
+branch_labels = None
+depends_on = None
+
+_NAME = "story_arc_external_identities"
+_PAGE = 200
+_OPERATION = "593a9848-6071-40d7-9cd5-fffece1dc7f4"
+_NORMALIZATION = "_pullbox_identity_normalization"
+_PROVIDERS = ("comicvine", "metron", "gcd", "locg")
+_STATES = ("observed", "verified", "conflicted", "stale", "rejected")
+_EVIDENCE = (
+ "legacy_backfill",
+ "mylar_database",
+ "series_json",
+ "comicinfo_xml",
+ "metroninfo_xml",
+ "provider_result",
+ "provider_crosswalk",
+ "user_selection",
+ "migration",
+)
+_FIELDS = (
+ "verification_state",
+ "evidence_kind",
+ "evidence_locator",
+ "verified_at",
+ "last_seen_at",
+ "revision",
+)
+_CHECKS = {
+ "identityverificationstate": "verification_state IN " + str(_STATES),
+ "identityevidencekind": "evidence_kind IN " + str(_EVIDENCE),
+ "ck_story_arc_identity_active_state": "verification_state IN ('verified','stale','conflicted')",
+ "ck_story_arc_identity_revision": "revision > 0",
+}
+
+
+def _json(value: dict) -> str:
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
+
+
+def _digest(value: str) -> str:
+ return hashlib.sha256(value.encode("ascii")).hexdigest()
+
+
+def _tables() -> tuple[sa.Table, sa.Table, sa.Table]:
+ metadata = sa.MetaData()
+ return tuple(
+ sa.Table(name, metadata, autoload_with=op.get_bind(), resolve_fks=False)
+ for name in (
+ "story_arcs",
+ _NAME,
+ "story_arc_identity_events",
+ )
+ )
+
+
+def _canonical(active: sa.Table):
+ return sa.and_(active.c.namespace == "story_arc", active.c.source.in_(_PROVIDERS))
+
+
+def _pages(table: sa.Table, condition=None):
+ cursor = 0
+ while (
+ rows := op.get_bind()
+ .execute(
+ sa.select(table)
+ .where(table.c.id > cursor, condition if condition is not None else sa.true())
+ .order_by(table.c.id)
+ .limit(_PAGE)
+ )
+ .mappings()
+ .all()
+ ):
+ cursor = rows[-1].id
+ yield rows
+
+
+def _fail() -> None:
+ raise RuntimeError(
+ "Story Arc identity evidence needs review before migration or downgrade; "
+ "no ownership may be discarded."
+ )
+
+
+def _valid(value: str) -> bool:
+ return bool(
+ value and len(value) <= 255 and value.isascii() and value.isdecimal() and value[0] != "0"
+ )
+
+
+def _canonical_id(source: str, value: str) -> str:
+ # Frozen compatibility parser: ComicVine's 4045 resource is a Story Arc.
+ if source == "comicvine" and len(value) <= 255:
+ text = value.strip(" ").removeprefix("4045-")
+ if text.isascii() and text.isdecimal():
+ normalized = text.lstrip("0")
+ if normalized and int(normalized) < 2**63:
+ return normalized
+ elif source != "comicvine" and _valid(value):
+ return value
+ _fail()
+
+
+def _untouched(row) -> bool:
+ return all(row[field] is None for field in _FIELDS if field != "revision") and row.revision == 1
+
+
+def _update_rows(active: sa.Table, rows: list[dict]) -> None:
+ if rows:
+ op.get_bind().execute(
+ sa.update(active)
+ .where(active.c.id == sa.bindparam("_row_id"))
+ .values(**{field: sa.bindparam(field) for field in rows[0] if field != "_row_id"}),
+ rows,
+ )
+
+
+def _normalization_original(row) -> str | None:
+ if not isinstance(row.evidence, dict):
+ _fail()
+ if _NORMALIZATION not in row.evidence:
+ return None
+ marker = row.evidence[_NORMALIZATION]
+ if (
+ row.source != "comicvine"
+ or not isinstance(marker, dict)
+ or set(marker) != {"schema_version", "external_id"}
+ or type(marker["schema_version"]) is not int
+ or marker["schema_version"] != 1
+ or not isinstance(marker["external_id"], str)
+ or marker["external_id"] == row.external_id
+ or _canonical_id(row.source, marker["external_id"]) != row.external_id
+ or not set(_FIELDS) <= row.keys()
+ or not (
+ _untouched(row)
+ or _equal(row, _expected(row.story_arc_id, row.source, row.external_id)[0])
+ )
+ ):
+ _fail()
+ return marker["external_id"]
+
+
+def _normalized_key(active: sa.Table):
+ trimmed = sa.func.trim(active.c.external_id, " ")
+ numeric = sa.case(
+ (sa.func.substr(trimmed, 1, 5) == "4045-", sa.func.substr(trimmed, 6)),
+ else_=trimmed,
+ )
+ return sa.case(
+ (active.c.source == "comicvine", sa.func.ltrim(numeric, "0")),
+ else_=active.c.external_id,
+ )
+
+
+def _preflight(arcs: sa.Table, active: sa.Table) -> None:
+ conn = op.get_bind()
+ if conn.execute(
+ sa.select(active.c.story_arc_id)
+ .where(_canonical(active))
+ .group_by(active.c.story_arc_id, active.c.source)
+ .having(sa.func.count() > 1)
+ .limit(1)
+ ).first():
+ _fail()
+ # Compare aliases across the entire table, not just the current page.
+ if conn.execute(
+ sa.select(active.c.source)
+ .where(_canonical(active))
+ .group_by(active.c.source, _normalized_key(active))
+ .having(sa.func.count() > 1)
+ .limit(1)
+ ).first():
+ _fail()
+ for rows in _pages(active, _canonical(active)):
+ canonical = {}
+ for row in rows:
+ canonical[row.id] = _canonical_id(row.source, row.external_id)
+ if (
+ set(_FIELDS) <= row.keys()
+ and not _untouched(row)
+ and not (
+ canonical[row.id] == row.external_id
+ and _equal(row, _expected(row.story_arc_id, row.source, row.external_id)[0])
+ )
+ ):
+ _fail()
+ _normalization_original(row)
+ parents = dict(
+ conn.execute(
+ sa.select(arcs.c.id, arcs.c.comicvine_id).where(
+ arcs.c.id.in_([row.story_arc_id for row in rows])
+ )
+ ).all()
+ )
+ cv_rows = [row for row in rows if row.source == "comicvine"]
+ owners = {
+ value: local_id
+ for local_id, value in conn.execute(
+ sa.select(arcs.c.id, arcs.c.comicvine_id).where(
+ sa.cast(arcs.c.comicvine_id, sa.BigInteger).in_(
+ [int(canonical[row.id]) for row in cv_rows]
+ )
+ )
+ )
+ }
+ for row in rows:
+ if row.story_arc_id not in parents:
+ _fail()
+ if row.source == "comicvine" and (
+ parents[row.story_arc_id] not in (None, int(canonical[row.id]))
+ or owners.get(int(canonical[row.id]), row.story_arc_id) != row.story_arc_id
+ ):
+ _fail()
+
+
+def _expected(
+ local_id: int, namespace: str, external_id: str, legacy_external_id: str | None = None
+) -> tuple[dict, dict]:
+ # Frozen v1 event envelope; do not import mutable runtime event policy.
+ origin = {"record_kind": "story_arc", "record_id": local_id}
+ revision_basis = f"legacy_arc:{namespace}:{local_id}:{external_id}"
+ if legacy_external_id is not None:
+ revision_basis += f":{legacy_external_id}"
+ evidence_revision = _digest(revision_basis)
+ slot = {
+ "version": 1,
+ "operation_id": _OPERATION,
+ "entity_kind": "story_arc",
+ "local_id": local_id,
+ "identity_namespace": namespace,
+ "action": "verify",
+ "origin": origin,
+ "evidence_revision": evidence_revision,
+ }
+ payload = {
+ "version": 1,
+ "operation_id": _OPERATION,
+ "local_id": local_id,
+ "identity": {
+ "namespace": namespace,
+ "entity_kind": "story_arc",
+ "external_id": external_id,
+ },
+ "action": "verify",
+ "evidence_kind": "legacy_backfill",
+ "evidence_revision": evidence_revision,
+ "origin": origin,
+ "actor": "migration",
+ "actor_user_id": None,
+ "review_revision": None,
+ }
+ if legacy_external_id is not None:
+ payload["legacy_external_id"] = legacy_external_id
+ serialized = _json(payload)
+ owner = {
+ "verification_state": "verified",
+ "evidence_kind": "legacy_backfill",
+ "evidence_locator": _json(origin),
+ "verified_at": None,
+ "last_seen_at": None,
+ "revision": 1,
+ }
+ event = {
+ "story_arc_id": local_id,
+ "identity_namespace": namespace,
+ "external_id": external_id,
+ "verification_state": "verified",
+ "evidence_kind": "legacy_backfill",
+ "event_key": _digest(_json(slot)),
+ "request_fingerprint": _digest(serialized),
+ "request_json": serialized,
+ }
+ return owner, event
+
+
+def _equal(row, expected: dict) -> bool:
+ return all(row[key] == value for key, value in expected.items())
+
+
+def _validate_events(active: sa.Table, events: sa.Table) -> None:
+ for rows in _pages(events):
+ keys = [(row.story_arc_id, row.identity_namespace, row.external_id) for row in rows]
+ owners = {
+ (row.story_arc_id, row.source, row.external_id): row
+ for row in op.get_bind()
+ .execute(
+ sa.select(active).where(
+ _canonical(active),
+ sa.tuple_(active.c.story_arc_id, active.c.source, active.c.external_id).in_(
+ keys
+ ),
+ )
+ )
+ .mappings()
+ }
+ for row in rows:
+ owner = owners.get((row.story_arc_id, row.identity_namespace, row.external_id))
+ if owner is None or not _equal(
+ row,
+ _expected(
+ row.story_arc_id,
+ row.identity_namespace,
+ row.external_id,
+ _normalization_original(owner),
+ )[1],
+ ):
+ _fail()
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ arcs, active, events = _tables()
+ _preflight(arcs, active)
+ # Refuse prior decisions before SQLite performs non-transactional DDL.
+ _validate_events(active, events)
+ # Only after every collision/history check passes may source keys change.
+ for rows in _pages(active, _canonical(active)):
+ updates = []
+ for row in rows:
+ canonical = _canonical_id(row.source, row.external_id)
+ if canonical != row.external_id:
+ updates.append(
+ {
+ "_row_id": row.id,
+ "external_id": canonical,
+ "evidence": {
+ **row.evidence,
+ _NORMALIZATION: {"schema_version": 1, "external_id": row.external_id},
+ },
+ }
+ )
+ _update_rows(active, updates)
+ existing = {col["name"] for col in sa.inspect(conn).get_columns(_NAME)}
+ columns = (
+ sa.Column("verification_state", sa.String(max(map(len, _STATES)))),
+ sa.Column("evidence_kind", sa.String(max(map(len, _EVIDENCE)))),
+ sa.Column("evidence_locator", sa.Text),
+ sa.Column("verified_at", sa.DateTime(timezone=True)),
+ sa.Column("last_seen_at", sa.DateTime(timezone=True)),
+ sa.Column("revision", sa.Integer, nullable=False, server_default="1"),
+ )
+ for column in columns:
+ if column.name not in existing:
+ op.add_column(_NAME, column)
+ arcs, active, events = _tables()
+ # Preserve column-only ComicVine arcs as canonical relations too.
+ for rows in _pages(arcs, arcs.c.comicvine_id > 0):
+ present = set(
+ conn.execute(
+ sa.select(active.c.story_arc_id).where(
+ active.c.story_arc_id.in_([row.id for row in rows]),
+ active.c.source == "comicvine",
+ active.c.namespace == "story_arc",
+ )
+ ).scalars()
+ )
+ values = [
+ {
+ "story_arc_id": row.id,
+ "source": "comicvine",
+ "namespace": "story_arc",
+ "external_id": str(row.comicvine_id),
+ "source_url": row.comicvine_url,
+ "evidence": {},
+ }
+ for row in rows
+ if row.id not in present
+ ]
+ if values:
+ conn.execute(sa.insert(active), values)
+ for rows in _pages(active, _canonical(active)):
+ expected = [
+ (
+ row,
+ *_expected(
+ row.story_arc_id, row.source, row.external_id, _normalization_original(row)
+ ),
+ )
+ for row in rows
+ ]
+ prior = {
+ row.event_key: row
+ for row in conn.execute(
+ sa.select(events).where(
+ events.c.event_key.in_([event["event_key"] for _, _, event in expected])
+ )
+ ).mappings()
+ }
+ creates = []
+ updates = []
+ for row, owner, event in expected:
+ untouched = _untouched(row)
+ if not untouched and not _equal(row, owner):
+ _fail()
+ if untouched:
+ updates.append({"_row_id": row.id, **owner})
+ if event["event_key"] in prior:
+ if not _equal(prior[event["event_key"]], event):
+ _fail()
+ else:
+ creates.append(event)
+ _update_rows(active, updates)
+ if creates:
+ conn.execute(sa.insert(events), creates)
+ checks = {row["name"] for row in sa.inspect(conn).get_check_constraints(_NAME)}
+ if missing := _CHECKS.keys() - checks:
+ with op.batch_alter_table(_NAME) as batch:
+ for name in sorted(missing):
+ batch.create_check_constraint(name, _CHECKS[name])
+ indexes = {row["name"] for row in sa.inspect(conn).get_indexes(_NAME)}
+ if "uq_story_arc_canonical_provider" not in indexes:
+ predicate = sa.text(
+ "namespace = 'story_arc' AND source IN ('comicvine','metron','gcd','locg')"
+ )
+ op.create_index(
+ "uq_story_arc_canonical_provider",
+ _NAME,
+ ["story_arc_id", "source"],
+ unique=True,
+ sqlite_where=predicate,
+ postgresql_where=predicate,
+ )
+
+
+def downgrade() -> None:
+ arcs, active, events = _tables()
+ _preflight(arcs, active)
+ _validate_events(active, events)
+ for rows in _pages(active):
+ for row in rows:
+ expected = (
+ _expected(row.story_arc_id, row.source, row.external_id)[0]
+ if (row.namespace == "story_arc" and row.source in _PROVIDERS)
+ else {field: 1 if field == "revision" else None for field in _FIELDS}
+ )
+ if not _equal(row, expected):
+ _fail()
+ op.get_bind().execute(sa.delete(events))
+ for rows in _pages(active, _canonical(active)):
+ updates = []
+ for row in rows:
+ original = _normalization_original(row)
+ if original is not None:
+ evidence = {
+ key: value for key, value in row.evidence.items() if key != _NORMALIZATION
+ }
+ updates.append({"_row_id": row.id, "external_id": original, "evidence": evidence})
+ _update_rows(active, updates)
+ op.drop_index("uq_story_arc_canonical_provider", table_name=_NAME)
+ with op.batch_alter_table(_NAME) as batch:
+ for name in _CHECKS:
+ batch.drop_constraint(name, type_="check")
+ for field in reversed(_FIELDS):
+ batch.drop_column(field)
diff --git a/alembic/versions/s0m1n2o3p456_add_metadata_source_configuration.py b/alembic/versions/s0m1n2o3p456_add_metadata_source_configuration.py
new file mode 100644
index 00000000..32815b98
--- /dev/null
+++ b/alembic/versions/s0m1n2o3p456_add_metadata_source_configuration.py
@@ -0,0 +1,65 @@
+"""Add independent metadata source configuration and priority policy."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "s0m1n2o3p456"
+down_revision = "r9l0m1n2o345"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ table = op.create_table(
+ "metadata_source_configs",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("source", sa.String(30), unique=True, nullable=False),
+ sa.Column("enabled", sa.Boolean(), nullable=False),
+ sa.Column("priority", sa.Integer(), nullable=False),
+ sa.Column("domain_priorities", sa.JSON(), nullable=False),
+ sa.Column("settings", sa.JSON(), nullable=False),
+ sa.Column("credential_secret", sa.Text()),
+ sa.Column("revision", sa.Integer(), nullable=False),
+ sa.Column("last_tested_at", sa.DateTime(timezone=True)),
+ sa.Column("last_success_at", sa.DateTime(timezone=True)),
+ sa.Column("last_status", sa.String(40)),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.CheckConstraint(
+ "source IN ('comicvine_local','comicvine_api','metron_api','gcd_local','gcd_api_v2')",
+ name="ck_metadata_source_slug",
+ ),
+ sa.CheckConstraint("priority BETWEEN 0 AND 1000", name="ck_metadata_source_priority"),
+ sa.CheckConstraint("revision > 0", name="ck_metadata_source_revision"),
+ sa.CheckConstraint(
+ "credential_secret IS NULL OR source IN ('metron_api','gcd_api_v2')",
+ name="ck_metadata_source_credential_owner",
+ ),
+ )
+ op.bulk_insert(
+ table,
+ [
+ {
+ "source": source,
+ "enabled": source.startswith("comicvine_"),
+ "priority": (index + 1) * 10,
+ "domain_priorities": {},
+ "settings": {},
+ "revision": 1,
+ }
+ for index, source in enumerate(
+ ("comicvine_local", "comicvine_api", "metron_api", "gcd_local", "gcd_api_v2")
+ )
+ ],
+ )
+
+
+def downgrade() -> None:
+ # Explicit schema downgrade removes source policy/credentials, not identities
+ # or the existing ComicVine credential in system_config.
+ op.drop_table("metadata_source_configs")
diff --git a/alembic/versions/t1n2o3p4q567_add_metadata_baselines.py b/alembic/versions/t1n2o3p4q567_add_metadata_baselines.py
new file mode 100644
index 00000000..0fc008db
--- /dev/null
+++ b/alembic/versions/t1n2o3p4q567_add_metadata_baselines.py
@@ -0,0 +1,50 @@
+"""Persist canonical metadata baselines without inventing legacy provenance."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "t1n2o3p4q567"
+down_revision = "s0m1n2o3p456"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ for kind, parent in (("series", "series"), ("issue", "issues"), ("story_arc", "story_arcs")):
+ op.create_table(
+ f"{kind}_metadata_baselines",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column(
+ f"{kind}_id",
+ sa.Integer(),
+ sa.ForeignKey(f"{parent}.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("revision", sa.Integer(), nullable=False),
+ sa.Column("snapshot_json", sa.Text(), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ sa.UniqueConstraint(f"{kind}_id", name=f"uq_{kind}_metadata_baseline_target"),
+ sa.CheckConstraint("revision > 0", name=f"ck_{kind}_metadata_baseline_revision"),
+ sa.CheckConstraint(
+ "length(snapshot_json) BETWEEN 2 AND 1048576",
+ name=f"ck_{kind}_metadata_baseline_size",
+ ),
+ )
+
+
+def downgrade() -> None:
+ # Downgrade discards provenance, never entities, ownership, or source files.
+ for kind in ("story_arc", "issue", "series"):
+ op.drop_table(f"{kind}_metadata_baselines")
diff --git a/alembic/versions/u2o3p4q5r678_add_catalog_checkpoints.py b/alembic/versions/u2o3p4q5r678_add_catalog_checkpoints.py
new file mode 100644
index 00000000..45bac92b
--- /dev/null
+++ b/alembic/versions/u2o3p4q5r678_add_catalog_checkpoints.py
@@ -0,0 +1,69 @@
+"""Keep full-catalog checkpoints bound to their source and verified identity."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "u2o3p4q5r678"
+down_revision = "t1n2o3p4q567"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Existing global timestamps do not prove which provider supplied a catalog.
+ op.create_table(
+ "series_catalog_checkpoints",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column(
+ "series_id",
+ sa.Integer(),
+ sa.ForeignKey("series.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "source",
+ sa.String(30),
+ sa.ForeignKey("metadata_source_configs.source", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("source_revision", sa.Integer(), nullable=False),
+ sa.Column(
+ "identity_id",
+ sa.Integer(),
+ sa.ForeignKey("series_external_identities.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("identity_revision", sa.Integer(), nullable=False),
+ sa.Column("external_id", sa.String(255), nullable=False),
+ sa.Column("revision", sa.Integer(), nullable=False),
+ sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("full_synced_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("source_updated_at", sa.DateTime(timezone=True)),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.UniqueConstraint("series_id", "source", name="uq_catalog_checkpoint_series_source"),
+ sa.CheckConstraint(
+ "revision > 0 AND source_revision > 0 AND identity_revision > 0",
+ name="ck_catalog_checkpoint_revisions",
+ ),
+ sa.CheckConstraint(
+ "length(external_id) BETWEEN 1 AND 255", name="ck_catalog_checkpoint_id"
+ ),
+ sa.CheckConstraint("full_synced_at <= checked_at", name="ck_catalog_checkpoint_time"),
+ sa.CheckConstraint(
+ "source NOT IN ('comicvine_local', 'gcd_local') OR source_updated_at IS NOT NULL",
+ name="ck_catalog_checkpoint_generation",
+ ),
+ )
+ op.create_index("ix_catalog_checkpoint_source", "series_catalog_checkpoints", ["source"])
+ op.create_index("ix_catalog_checkpoint_identity", "series_catalog_checkpoints", ["identity_id"])
+
+
+def downgrade() -> None:
+ # Discard only sync progress, never canonical metadata, files or ownership.
+ op.drop_table("series_catalog_checkpoints")
diff --git a/alembic/versions/v3p4q5r6s789_add_metadata_series_retries.py b/alembic/versions/v3p4q5r6s789_add_metadata_series_retries.py
new file mode 100644
index 00000000..abd1eedc
--- /dev/null
+++ b/alembic/versions/v3p4q5r6s789_add_metadata_series_retries.py
@@ -0,0 +1,61 @@
+"""Separate deferred metadata source work from the sweep cursor."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "v3p4q5r6s789"
+down_revision = "u2o3p4q5r678"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "metadata_series_retries",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("task_id", sa.String(30), nullable=False),
+ sa.Column(
+ "series_id",
+ sa.Integer(),
+ sa.ForeignKey("series.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("source", sa.String(30), nullable=False),
+ sa.Column("config_key", sa.String(64), nullable=False),
+ sa.Column("status", sa.String(30), nullable=False),
+ sa.Column("retry_at", sa.DateTime(timezone=True)),
+ sa.Column("revision", sa.Integer(), nullable=False),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.UniqueConstraint("task_id", "series_id", "source", name="uq_metadata_series_retry"),
+ sa.CheckConstraint(
+ "task_id IN ('sync_new_issues','refresh_metadata')", name="ck_metadata_retry_task"
+ ),
+ sa.CheckConstraint(
+ "source IN ('*','comicvine_local','comicvine_api','metron_api',"
+ "'gcd_local','gcd_api_v2')",
+ name="ck_metadata_retry_source",
+ ),
+ sa.CheckConstraint("revision > 0", name="ck_metadata_retry_revision"),
+ sa.CheckConstraint(
+ "status IN ('rate_limited','timeout','unavailable','authentication_failed')",
+ name="ck_metadata_retry_status",
+ ),
+ sa.CheckConstraint(
+ "(status = 'authentication_failed' AND retry_at IS NULL) OR "
+ "(status != 'authentication_failed' AND retry_at IS NOT NULL)",
+ name="ck_metadata_retry_deadline",
+ ),
+ )
+ op.create_index(
+ "ix_metadata_retry_due", "metadata_series_retries", ["task_id", "retry_at", "series_id"]
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("metadata_series_retries")
diff --git a/alembic/versions/w4q5r6s7t890_add_metadata_source_accounts.py b/alembic/versions/w4q5r6s7t890_add_metadata_source_accounts.py
new file mode 100644
index 00000000..f9e55433
--- /dev/null
+++ b/alembic/versions/w4q5r6s7t890_add_metadata_source_accounts.py
@@ -0,0 +1,46 @@
+"""Persist provider account cooldowns independently of individual series retries."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "w4q5r6s7t890"
+down_revision = "v3p4q5r6s789"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "metadata_source_accounts",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("source", sa.String(30), nullable=False, unique=True),
+ sa.Column("account_key", sa.String(64), nullable=False),
+ sa.Column("revision", sa.Integer(), nullable=False),
+ sa.Column("status", sa.String(30)),
+ sa.Column("retry_at", sa.DateTime(timezone=True)),
+ sa.Column("lease_until", sa.DateTime(timezone=True)),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.CheckConstraint(
+ "source IN ('comicvine_api','metron_api','gcd_api_v2')",
+ name="ck_metadata_account_source",
+ ),
+ sa.CheckConstraint("revision > 0", name="ck_metadata_account_revision"),
+ sa.CheckConstraint(
+ "(status IS NULL AND retry_at IS NULL AND lease_until IS NULL) OR "
+ "(status IS NOT NULL AND status = 'authentication_failed' "
+ "AND retry_at IS NULL AND lease_until IS NULL) OR "
+ "(status IS NOT NULL AND status IN ('rate_limited','timeout','unavailable') "
+ "AND retry_at IS NOT NULL)",
+ name="ck_metadata_account_state",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("metadata_source_accounts")
diff --git a/alembic/versions/x5r6s7t8u901_allow_metadata_authentication_probes.py b/alembic/versions/x5r6s7t8u901_allow_metadata_authentication_probes.py
new file mode 100644
index 00000000..c66d0730
--- /dev/null
+++ b/alembic/versions/x5r6s7t8u901_allow_metadata_authentication_probes.py
@@ -0,0 +1,44 @@
+"""Allow a bounded deliberate probe without discarding an authentication hold."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "x5r6s7t8u901"
+down_revision = "w4q5r6s7t890"
+branch_labels = None
+depends_on = None
+
+
+def _constraint(*, allow_probe: bool) -> None:
+ auth_lease = "" if allow_probe else " AND lease_until IS NULL"
+ with op.batch_alter_table("metadata_source_accounts") as batch:
+ batch.drop_constraint("ck_metadata_account_state", type_="check")
+ batch.create_check_constraint(
+ "ck_metadata_account_state",
+ "(status IS NULL AND retry_at IS NULL AND lease_until IS NULL) OR "
+ "(status IS NOT NULL AND status = 'authentication_failed' "
+ f"AND retry_at IS NULL{auth_lease}) OR "
+ "(status IS NOT NULL AND status IN ('rate_limited','timeout','unavailable') "
+ "AND retry_at IS NOT NULL)",
+ )
+
+
+def upgrade() -> None:
+ _constraint(allow_probe=True)
+
+
+def downgrade() -> None:
+ # Retain the authentication hold; only the unsupported active probe is dropped.
+ account = sa.table(
+ "metadata_source_accounts",
+ sa.column("status"),
+ sa.column("lease_until"),
+ sa.column("revision"),
+ )
+ op.execute(
+ account.update()
+ .where(account.c.status == "authentication_failed", account.c.lease_until.isnot(None))
+ .values(lease_until=None, revision=account.c.revision + 1)
+ )
+ _constraint(allow_probe=False)
diff --git a/alembic/versions/y6s7t8u9v012_add_archive_metadata_publications.py b/alembic/versions/y6s7t8u9v012_add_archive_metadata_publications.py
new file mode 100644
index 00000000..3ad8648e
--- /dev/null
+++ b/alembic/versions/y6s7t8u9v012_add_archive_metadata_publications.py
@@ -0,0 +1,59 @@
+"""Retain paired archive publication intent across process and DB failures."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "y6s7t8u9v012"
+down_revision = "x5r6s7t8u901"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "archive_metadata_publications",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("operation_id", sa.String(36), nullable=False, unique=True),
+ sa.Column(
+ "library_file_id", sa.Integer(), sa.ForeignKey("library_files.id", ondelete="SET NULL")
+ ),
+ sa.Column(
+ "active_file_id",
+ sa.Integer(),
+ sa.ForeignKey("library_files.id", ondelete="SET NULL"),
+ unique=True,
+ ),
+ sa.Column("active_path_key", sa.String(64), unique=True),
+ sa.Column("revision", sa.Integer(), nullable=False),
+ sa.Column("state", sa.String(9), nullable=False),
+ sa.Column("plan_json", sa.Text(), nullable=False),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.CheckConstraint("revision > 0", name="ck_archive_publication_revision"),
+ sa.CheckConstraint(
+ "length(plan_json) BETWEEN 2 AND 4194304", name="ck_archive_publication_size"
+ ),
+ sa.CheckConstraint(
+ "state IN ('intended','published','abandoned','review')",
+ name="archive_publication_state",
+ ),
+ sa.CheckConstraint(
+ "(state = 'abandoned' AND active_file_id IS NULL AND active_path_key IS NULL) OR "
+ "(state <> 'abandoned' AND active_path_key IS NOT NULL)",
+ name="ck_archive_publication_reservation",
+ ),
+ )
+
+
+def downgrade() -> None:
+ table = sa.table("archive_metadata_publications", sa.column("state"))
+ if op.get_bind().scalar(
+ sa.select(sa.func.count()).select_from(table).where(table.c.state != "abandoned")
+ ):
+ raise RuntimeError("Resolve retained archive publication work before downgrading.")
+ op.drop_table("archive_metadata_publications")
diff --git a/alembic/versions/z7t8u9v0w123_finalize_archive_publications.py b/alembic/versions/z7t8u9v0w123_finalize_archive_publications.py
new file mode 100644
index 00000000..7c5222c7
--- /dev/null
+++ b/alembic/versions/z7t8u9v0w123_finalize_archive_publications.py
@@ -0,0 +1,37 @@
+"""Retain completed canonical publication receipts without active reservations."""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "z7t8u9v0w123"
+down_revision = "y6s7t8u9v012"
+branch_labels = None
+depends_on = None
+
+
+def _constraints(*, finalized: bool) -> None:
+ terminal = "'abandoned','finalized'" if finalized else "'abandoned'"
+ states = "'intended','published','abandoned','review'" + (",'finalized'" if finalized else "")
+ with op.batch_alter_table("archive_metadata_publications") as batch:
+ batch.drop_constraint("archive_publication_state", type_="check")
+ batch.drop_constraint("ck_archive_publication_reservation", type_="check")
+ batch.create_check_constraint("archive_publication_state", f"state IN ({states})")
+ batch.create_check_constraint(
+ "ck_archive_publication_reservation",
+ f"(state IN ({terminal}) AND active_file_id IS NULL AND active_path_key IS NULL) OR "
+ f"(state NOT IN ({terminal}) AND active_path_key IS NOT NULL)",
+ )
+
+
+def upgrade() -> None:
+ _constraints(finalized=True)
+
+
+def downgrade() -> None:
+ table = sa.table("archive_metadata_publications", sa.column("state"))
+ if op.get_bind().scalar(
+ sa.select(sa.func.count()).select_from(table).where(table.c.state == "finalized")
+ ):
+ raise RuntimeError("Cannot downgrade completed archive publication evidence.")
+ _constraints(finalized=False)
diff --git a/docs/development/ARCHITECTURE_OVERVIEW.md b/docs/development/ARCHITECTURE_OVERVIEW.md
index e2e9fc06..7c63d822 100644
--- a/docs/development/ARCHITECTURE_OVERVIEW.md
+++ b/docs/development/ARCHITECTURE_OVERVIEW.md
@@ -303,6 +303,393 @@ constructed, and provider instances are registered for service use.
AirDC++ uses `src/pullbox/composition/airdcpp.py` because its authenticated REST
session and WebSocket lifecycle are supervised per exact configured client.
+The v2 metadata platform has a separate source-aware registry in
+`services/metadata_discovery.py`. Its ComicVine local/API adapters share the
+ComicVine identity namespace but retain separate priorities, capabilities,
+pagination, and typed outcomes. Interactive discovery fans out with bounded
+concurrency; automatic discovery cascades and stops only when its caller's
+explicit satisfaction predicate succeeds. Search results do not attach identities.
+
+Series Details exposes existing provider links and an operator-only search,
+comparison, and confirmation flow. The link preview fetches the exact provider
+record outside a database transaction and saves an observation, not ownership.
+Confirmation reuses identity review with source-policy, local summary, and
+other-provider claim freshness checks. Conflicting provider crosswalks stop the
+preview. Linking never adds series/issues, rematches issues, or modifies files.
+The existing Refresh metadata action uses the verified links for enrichment;
+catalog refresh only considers namespaces already owning issues when a catalog
+exists. A newly linked Metron source can supply representative artwork from one
+bounded first issue page without adopting that page as issue membership.
+
+`/api/v1/metadata/sources` exposes operator-only configuration and connection
+checks; `/api/v1/metadata/search` requires authentication. Session writes use
+the existing CSRF contract. Database reads end before provider I/O, clients close
+after each operation, and one provider failure does not hide other results.
+Read-only local catalog work retains at most two owned in-flight operations per
+event loop. Cancelled requests can return without abandoning disk cleanup or
+creating an unbounded background queue.
+The registry also exposes exact series/issue details and bounded issue pages for
+ComicVine local/API and Metron. `/api/v1/metadata/series/preview` returns a profile
+and the first 100-row issue page with separate outcomes. Subsequent
+`/api/v1/metadata/series/issues` requests carry the observed source-policy revision;
+configuration changes reject stale results. Both POST endpoints require
+authentication and the existing session CSRF protection. Source namespace,
+entity identity, issue parent, page completeness and continuation are checked
+before returning data. Preview is read-only and is not an adoption proof.
+
+The separate `recent_issues` capability returns at most 100 issues and never
+claims complete membership. ComicVine local/API return newest-publication slices;
+Metron returns issues modified after an explicit UTC checkpoint. Query totals
+and truncation describe only that slice. A truncated modification window needs
+a full/bounded reconciliation before advancing its checkpoint. Empty local
+results still carry their immutable catalog generation. These reads do not use
+the full-page response cache, follow continuation links or attach identities.
+The daily issue sweep uses these windows with source-specific checkpoints and
+retains the normal continuing/ended-series cadence and full-refresh deadline.
+
+Complete source-aware Add and refresh now persist source-bound catalog
+checkpoints with the same atomic library write. The checkpoint retains the
+source-policy and verified-identity revisions, exact parent and local generation.
+Only proven live catalog reads establish the request-start boundary. Cached
+bundles of unknown age and Add of an existing series do not advance progress.
+Refresh revalidates captured checkpoints along with metadata and policies, so
+an in-flight refresh cannot overwrite newer sync progress. Incremental advancement
+preserves the full-sync date and commits with the corresponding issue writes.
+
+`services/metadata_issue_catalog.py` applies either complete membership or an
+issue-only batch to the caller's locked, revalidated read set. Full refresh uses
+this writer; partial batches do not imply removal, completeness or a new full-sync
+timestamp. Both modes preserve issue ownership, local edits, private reading state
+and registered artifacts while sharing native identity, crosswalk observation and
+canonical-baseline writes. The writer returns exact created issue IDs. Scheduled
+refresh uses that receipt, not an ID range that could include another operation's
+new issues, when deciding whether to search after commit. Daily synchronization
+uses the same writer, overlaps modification cursors by two minutes, and falls back
+to full reconciliation for truncated modification windows, changed local catalog
+generations, or publication slices that cannot account for known membership.
+
+`services/metadata_series_adoption.py` separates a complete server-side catalog
+fetch from transactional adoption. Traversal rejects partial pages, repeated
+identities, changed counts and mixed local catalog generations. The writer locks
+and rechecks source configuration, uses shared identity attachment and caller-owned
+transactions, and preserves existing exact owners rather than treating Add as
+Refresh. Native issues require exact parent evidence; foreign issue crosswalks
+remain observations until independently verified. Unsupported designations and
+conflicting ownership fail without partial library rows. The Add Series API/UI
+uses this command with caller-owned transactions, managed-folder rollback and
+post-commit events. Source-aware Story Arc search, complete command snapshots,
+Add/refresh UI and scheduled membership refresh use the same registry and
+existing catalog ownership, placement and reading-order services.
+
+Normalized remote detail and issue-page reads reuse `metadata_provider_cache`
+through `services/metadata_read_cache.py`. Keys bind source, policy revision,
+operation, external ID and page; credentials are never cache key material.
+Responses are fresh for five minutes (not-found for 15 seconds), with validators
+retained for one day. Add and refresh commands always revalidate. Complete series
+catalog reads that establish sync checkpoints bypass this cache and its shared
+flights; descriptive reads and other consumers retain the existing cache. A not-modified
+response requires an exact saved response; outages never become cached success.
+The cache is limited to 128 entries of at most 256 KiB each, separate from legacy
+ComicVine import entries. It owns short database sessions outside provider I/O,
+coalesces at most eight concurrent reads per database, and drains cancelled work.
+Availability checks precede cache reads; local catalog reads stay generation-bound
+in their existing reader rather than entering this remote-response cache.
+
+`schemas/metadata_snapshot.py` and `services/metadata_assembly.py` define a
+versioned descriptive snapshot with per-field source, domain, observation time
+and override markers. New source-aware series adoption uses that assembler for
+series/issue descriptive fields, after complete-catalog and identity checks.
+Snapshots distinguish verified identities from observed crosswalks. Exact-ID or
+issue-parent disagreement stops assembly; priority cannot resolve it. Local edits,
+including intentional clears, are preserved. Background assembly fills gaps;
+explicit refresh can replace tracked provider-managed values under domain priority.
+
+`services/metadata_refresh_snapshot.py` supplies the bounded read phase for
+series, issue and Story Arc descriptive refresh. It requests only known exact
+identities, revalidates cached responses, cascades by requested domain, stops
+unnecessary lower-priority reads and retains typed partial outcomes. It does not
+write metadata, attach identities or change catalog membership. The caller must
+revalidate ownership and all captured source-policy revisions before applying it.
+`services/metadata_baselines.py` persists versioned canonical snapshots in separate
+series, issue and Story Arc tables with real cascading foreign keys. Writes are
+bounded batches, use caller-owned transactions, recheck verified ownership and
+require the expected baseline revision. They acquire the same parent-first locks
+as identity writers; the baseline never establishes identity ownership itself.
+Corrupt or incompatible snapshots fail explicitly rather than silently removing
+override protection. No legacy backfill invents provenance for existing values.
+
+Source-aware Add Series saves series/issue baselines in its catalog transaction,
+including final lifecycle/format inference. Derived values have a distinct origin,
+not provider attribution or user-override markers. Explicit refresh may replace
+those values; background enrichment still fills gaps only. Existing-owner Add
+does not replace its baseline or local edits. Manual and scheduled Series refresh
+use the revision-checked canonical/catalog writer. Source-bound Story Arc Add also
+persists arc provenance and baselines for newly seeded parents/issues in its
+existing atomic graph transaction. Reused members retain their metadata and history.
+Arc refresh reads current descriptive values under the claimed arc revision,
+preserves edits and intentional clears, and saves the new baseline atomically with
+membership changes. Scheduled enrichment fills gaps; explicit refresh may replace
+provider-managed values. Managed placements keep the arc name fixed without
+blocking other safe changes; the baseline records that restriction separately
+from user overrides. Existing order, skipped and removed members, partial parent
+catalogs and pending-placement review remain unchanged. Legacy snapshots without
+normalized source evidence are not silently assigned canonical provenance.
+Source-bound manual and scheduled Arc refresh use the shared descriptive cascade
+over verified attached identities. Fresh catalog evidence is reused without a
+second read of that transport; higher-priority sources can supply descriptive
+fields without taking over membership. The read set captures values, baseline,
+identity revisions/history, arc state, placement restrictions and source policies.
+Provider I/O runs after releasing the reader. Policy-first/arc locks and read-set
+revalidation precede the atomic catalog write; concurrent changes reject the whole
+refresh. Partial source outcomes are retained as bounded diagnostics. Legacy Arc
+browser URLs now delegate to the same source-bound search, preview, Add and refresh
+commands. Pre-upgrade forms without a source revision must be previewed again;
+existing ComicVine arcs retain their identity and reviewed membership. New Add
+adopts the selected source, while refresh uses the descriptive cascade. Provider
+cleanup failure cannot supply a successful read for a write; primary cancellation
+and rate-limit outcomes remain intact.
+
+Normalized issue reads and canonical snapshots retain bounded descriptive creator
+credits. Credit lists follow core-domain authority as a whole; lower-priority
+lists do not merge over an existing list. User edits and intentional clears remain
+overrides. Source-aware Add, issue catalog writes and newly seeded Story Arc
+members persist credits through the existing Creator/IssueCreator relations in
+the same transaction as their baseline. Refresh captures current relations in its
+read set before provider I/O and rejects in-flight changes. Reads/writes batch at
+200 issues; descriptive names never establish a foreign creator identity. No
+additional provider detail calls or archive writes are introduced. Remaining
+rich metadata/membership fields, legacy consumers and coordinated XML/sidecar
+writers are pending.
+This does not complete the full metadata feature.
+
+The bounded MetronInfo reader preserves descriptive fields, creator/role resources,
+localized aliases and schema-specific resource IDs alongside exact issue/series
+evidence. Resource IDs remain opaque document data, not foreign identity claims.
+Missing credits differ from an explicit empty list; malformed or over-limit
+credits cannot produce a partial list. Unsupported elements and attributes still
+raise preservation diagnostics. The paired archive probe reads both XML documents
+in one open pass without changing reference files. This is read support, not
+offline XSD validation or permission to rewrite either document; coordinated
+canonical output remains pending.
+
+`services/archive_metadata_reconciliation.py` compares both embedded documents
+into canonical-shaped series/issue values without I/O or persistence. It retains
+each document and its raw payload, exposes descriptive disagreements separately
+from exact-ID conflicts, and leaves disputed shared fields unset. Missing and
+explicitly empty credits remain distinct. Partial ComicInfo publication dates
+never become invented dates or series start years. Scoped ComicVine markers and
+validated resource URLs yield unverified evidence, not attachments or crosswalks.
+Both XML paths share the same bounded envelope; duplicate fields and archive
+probe diagnostics remain reviewable. The shared canonical assembler accepts this
+comparison only for an independently resolved file/target and parent binding;
+it does not perform that matching. Adopted fields retain bounded document-name
+provenance, never fabricated API attribution. Local values and explicit empty
+credits survive later provider refreshes; user edits/clears remain authoritative.
+Unchanged XML that agrees with an existing provider-managed baseline retains its
+normal explicit-refresh authority; external XML edits instead require review.
+Exact target/parent conflicts and differing issue designations abort assembly.
+Descriptive disagreements and unsupported XML remain durable review/preservation
+diagnostics rather than becoming provider-filled gaps. Previously observed IDs
+survive refreshes without acquiring verified ownership. Existing baseline storage
+retains this additive provenance with the same revision/transaction checks.
+This comparison and adoption are not write authorization: production file-binding
+integration and coordinated atomic writers remain required before the existing
+import/writer consumers switch to them.
+
+`core/metroninfo_schema.py` provides the offline output-schema gate. It validates
+a bounded, defused document with the bundled, pinned MetronInfo 1.1 XSD through
+`XMLSchema11`, including its primary-ID/URL assertions. Schema location hints
+cannot trigger network or filesystem access. Validation errors expose fixed
+codes, not XML payloads. The schema, MIT license and provenance ship in the
+Python package. This gate checks representation, not identity or write authority;
+archive integration and ownership authorization are still required.
+
+`services/archive_metadata_rendering.py` renders both XML documents together from
+independently bound canonical series/issue snapshots. It preserves supported
+schema-specific metadata and refuses unassembled local changes, conflicting exact
+IDs, unverified identity promotion, ambiguous primary sources, and resource-ID
+reinterpretation. Explicit edits may replace descriptive values; provider refresh
+requires an unchanged managed baseline. Shared credit comparisons project only
+ComicInfo-supported roles while retaining richer Metron roles. Partial dates are
+not expanded into invented days. Existing arc names/order are coordinated without
+creating provider identities or canonical membership. Unknown extensions and
+unrepresentable values stop for review instead of disappearing. Output passes the
+offline XSD and a shared-value cross-check before either document is returned.
+This pure renderer makes no provider/database calls or archive-file mutations;
+the offline validator loads only its bundled schema resource. Production
+archive rewrite/conversion integration, canonical rich-field/membership expansion,
+production consumer migration and managed-root authorization remain required;
+legacy writers have not switched to this boundary yet.
+
+`services/archive_metadata_writing.py` provides the coordinated CBZ publication
+primitive for independently authorized callers. It reads both XML members from
+one source archive session, renders the pair, streams approved entries into one
+same-directory temporary archive, verifies its manifest and payload CRCs, and
+publishes without overwriting an existing destination. Explicit same-path
+refresh uses atomic replacement after closing the source handle. Source/staging
+identity checks reject detected changes; invoking workflows still own per-file
+serialization, binding, managed-root authorization, rollback journals and
+interruptible process cleanup. Cooperative cancellation is checked through
+streaming, verification and immediately before publication, never after a
+successful commit point. Reference-only/keep-in-place callers are not authorized
+by this primitive. `core/metadata_archive_source.py` also accepts RAR, 7z and TAR
+sources without an intermediate CBZ. RAR/TAR members are streamed; solid 7z is
+decompressed once into bounded, private numbered spools under the owner's staging
+directory, never archive-supplied filesystem paths. Special/encrypted/split
+members and unsafe or oversized payloads fail closed. Cancellation checks cover
+extraction, copying and verification. PDF input uses bounded, private page spools
+under the same owner workspace. Poppler inspects the page count, encryption and
+dimensions before rendering, then renders one page at a time at the requested
+quality without silently downscaling. Output bytes, page pixels and helper
+deadlines are bounded; cancellation kills/reaps the helper before cleanup.
+Both XML documents are still written during the single CBZ construction.
+Production conversion callers have not yet switched their metadata content
+policy to this paired boundary.
+
+`utilities/executors/archive_metadata_staging.py` hands paired CBZ work to the
+interruptible archive worker without giving it a final library destination.
+Canonical snapshots use a bounded private request file, not process arguments.
+The worker produces one verified private archive; its fingerprint and the
+original source fingerprint must still agree when the parent hands it off.
+The async context removes its own workspace on failure, cancellation or exit,
+without following a replaced workspace or deleting an artifact published out
+of it. The owner must recheck current binding/ownership/policy, journal intent,
+and revalidate the supplied fingerprints before publication. This context is
+not a durable journal, permission to mutate reference files or a filesystem lock.
+The shared supervisor reaps workers after task cancellation, including during
+spawn and repeated cancellation, and shields pipe draining during kill escalation.
+Paired workers on POSIX run in isolated process groups, unwind helper cleanup on
+SIGTERM, and terminate remaining helpers after cancellation or abnormal exit.
+Legacy operations still publish inside their workers; they have not migrated to
+the staged owner-publication contract. Production binding, journal recovery and
+consumer migration remain required.
+
+`services/archive_metadata_binding.py` captures one registered managed CBZ's
+issue/parent, verified identities and revisions, current values/credits, canonical
+baselines, source policies and root policy. It reuses the series refresh mapper
+with a bounded exact issue selection rather than loading the whole catalog.
+Legacy ComicVine columns must agree with active ownership; stale or conflicted
+claims do not disappear from the check. Local XML is assembled only after this
+independent binding and cannot acquire identity ownership. Database reads do not
+commit or flush pending caller edits. Filesystem probes run separately, off the
+event loop, and capture a writable regular file plus its directory identities
+inside the recorded root. Final revalidation checks both database state and
+canonical-path reference ownership; the owner separately rechecks filesystem
+evidence immediately before publication. These read sets are not locks or a
+durable journal. Production migration still requires a serialized recoverable
+publication boundary, output baseline persistence and crash reconciliation.
+
+`services/archive_metadata_publication.py` now retains bounded publication intent
+before replacement. Independent file/path reservations do not expire, and deleting
+a library row does not erase recovery evidence. Preparation validates the staged
+XML against its canonical snapshots and hashes source/output outside DB sessions.
+Publication rechecks binding under policy/parent/issue/root/file locks and holds
+the journal lock through the short offloaded stat/rename/directory-sync boundary.
+Cancellation joins file work before releasing the lock. Recovery hashes outside
+the transaction, then rechecks file evidence and journal revision under that same
+lock. It recognizes the verified staged inode/content after a rename/DB-commit
+failure, abandons only a proven untouched original, and retains unknown outcomes
+for review. Recovery never republishes or deletes a file. The published state is
+filesystem evidence only, not completed canonical DB adoption; its reservation
+stays active. Production callers remain unchanged pending finalization, rollback,
+shared mutation coordination and restart orchestration. Existing legacy writers
+and external filesystem programs are not fenced by this new journal.
+
+`services/archive_metadata_finalization.py` completes a proven publication in a
+caller-owned transaction. It revalidates identity, policy, user values, baseline
+revisions and output evidence before applying one canonical snapshot to entity
+values, credits, baselines and file state. The shared descriptive projection is
+also used by catalog refresh; finalization never fabricates a catalog refresh or
+changes import naming/source evidence. Completed receipts release reservations,
+survive file deletion and replay without changing later edits. Failure or
+cancellation retains recoverable publication state. Known writer format labels
+normalize through a shared reversible mapping when read back; unknown formats and
+real disagreements remain intact. File timestamps retain the same stat conversion
+used by registration. ComicInfo Count participates in bounded canonical issue-count
+comparison, including zero when MetronInfo omits it; malformed, repeated and
+disagreeing counts remain reviewable rather than silently falling back.
+Import-owner binding now connects publication/finalization to the exact pending
+import and placement action. Finalization acknowledges that owner and records a
+successor reference without replacing original ownership evidence. Import rollback
+accepts only a finalized, action-bound successor whose registered owner and actual
+bytes still match. Active publications and later reassignment remain protected.
+Startup/enrichment and rollback orchestration now recover retained import-owned
+publications before using the legacy writer or removing artifacts. Stopped owners
+receive a separate settled receipt and file accounting, not a stale canonical
+update; unresolved reservations block legacy enrichment. Recovery never repeats
+a filesystem replacement. Shared mutation coordination across other writers,
+paired enrichment-worker activation and no-op archive-write avoidance remain required.
+
+Short Library browser and series-folder renames now share a database admission
+mutex with new publication intent. They reject retained source/stage reservations,
+including folder descendants and orphaned file records, instead of relying only
+on a reference-file preflight. Library browser rename cancellation joins workers
+and commits before releasing coordination; compensation is identity-checked and
+never overwrites another destination. Folder path updates treat `%` and `_` as
+literal filename characters. Conversion, deletion, bulk utility writers and
+other mutation routes have not yet joined this contract. This does not authorize
+paired-writer activation or claim crash recovery for arbitrary legacy renames.
+
+Library browser conversion now prepares output and an independent original backup
+in private, interruptible workers, records durable intent, publishes exclusively,
+and updates the existing registration before deleting the original. Recovery runs
+before startup file writers and never guesses ownership from a filename or removes
+unproven output after an ambiguous commit. Same-root and cross-filesystem trash use
+the same staged protocol. This retains the existing conversion content behavior;
+the paired renderer is not activated by this lifecycle change. Bulk conversion,
+deletion and other writers still need to join coordination before paired background
+metadata writing can be enabled.
+
+`library_removal.py` adds durable reservation and same-filesystem private staging
+for deletion owners. Startup restores uncommitted staging before conversion
+recovery. `library_removal_cleanup.py` finishes explicitly authorized, committed
+detachment without holding the database writer across copying, hashing or recursive
+cleanup. Retained receipts and an OS file lock fence overlapping cleanup workers;
+trash publication never overwrites an existing destination. Same-filesystem trash
+renames rather than copying large collections. Cancellation leaves owned partial
+copies/deletions retryable, and cleanup never follows the old public source path.
+The browser, series and bulk deletion commands still need to adopt this lifecycle;
+the primitives alone do not enable paired production writers or change public
+deletion semantics. Non-POSIX removal locking remains open.
+
+`library_trash_cleanup.py` owns startup retention and interactive trash cleanup.
+It checks retained publication/conversion/removal reservations under shared
+admission before each short unlink, keeps private preparation directories, and
+refuses registered files or library roots inside trash. Directory traversal runs
+off-thread in bounded pages without a database transaction. Completed removal
+receipts protect a newly trashed tree for its full retention window even when
+child files have old modification times. The settings surface reports retained
+entries instead of claiming that protected trash was emptied. Public deletion
+owner integration remains separate work.
+
+`library_removal_workspaces.py` prunes only proven empty workspaces after a
+committed completion or restored abandonment. It keeps original journal receipts,
+checks directory identity through non-following descriptors and refuses live
+cleanup locks, unknown contents or replaced directories. Startup retries terminal
+workspace cleanup in bounded pages, but never resumes payload deletion. An indexed
+completion marker prevents repeated filesystem inspection of cleaned workspaces.
+Cancellation joins the short worker before releasing mutation admission. No public
+series/browser/bulk deletion behavior is activated by this housekeeping boundary.
+
+The existing import/cache consumers have not yet migrated to this registry.
+Metron has token settings, bounded transport and
+series/issue/arc adapter operations. GCD Local validates and reads an official
+SQLite dump in query-only mode for bounded series/issue discovery and refresh;
+GCD API v2 execution remains disabled by the release feature flag. Capability
+lists describe implemented adapter operations, not planned features.
+
+`series_sidecar.py` exposes an explicit Series Details preview/write boundary for
+`series.json`. It assembles the saved canonical series snapshot without provider
+calls or issue-catalog reads, retains verified links and provenance, and preserves
+safe existing Mylar/custom JSON fields. Approval binds metadata, root policy,
+folder ownership, and existing file evidence. Reference-only, shared, ambiguous,
+linked, and read-only locations remain untouched. Split series receive equivalent
+compiled metadata only in their individually eligible managed folders. Staging
+runs off-thread outside write admission; short atomic publication revalidates
+metadata, folder evidence, active imports, and existing publication/removal guards.
+Cancellation drains the current short item and its cleanup before stopping. This
+does not activate automatic import writers, rewrite archives, or change deletion.
+
**Required standard**
- Keep provider quirks inside provider modules.
@@ -562,10 +949,49 @@ Nightly issue and metadata sweeps checkpoint their last completed series and
initial upper bound in `SystemConfig`. Each batch handles at most 25 series and
checks a two-minute budget between series; an individual series has a separate
15-minute timeout. Pending batches resume through hidden continuations, including
-after restart. Provider throttling pauses the sweep at its saved position instead
-of repeatedly failing every remaining series. Series metadata writes are committed
-before subsequent cover or issue-provider waits.
-Removing the ComicVine key stops the active sweep and clears its continuation.
+after restart. Deferred provider work lives in `metadata_series_retries`, keyed
+by task, series and source, rather than holding the shared sweep cursor. Batches
+reserve space for fresh work and due retries. Provider reads happen before the
+atomic library/progress write; optional cover work follows its commit.
+The scheduled metadata refresh selects verified identities backed by executable,
+configured sources, including native Metron and local ComicVine without an API
+key. It uses the same revision-checked writer as manual refresh but fills gaps
+only, preserving existing descriptive values and user overrides. Its metadata
+write and sweep checkpoint commit together before search scheduling or optional
+cover-cache work. Structured source failures preserve durable retry deadlines;
+disabling the last eligible source clears the continuation.
+The daily issue sweep uses the same configured-source eligibility, with partial
+windows where a valid source checkpoint permits them. Native and local sources
+do not require a ComicVine API key. Failed sources retain their checkpoints,
+including when a fallback succeeds. Retry attempts restrict reads to deferred
+sources and commit retry settlement with library/cursor changes. Authentication
+failures have no timed retry. Changed entity configuration can make an entity
+retry eligible, but the account guard still blocks known-failing credentials;
+priority edits cannot bypass an authentication hold. Only one-way configuration
+scope keys are stored, never credentials.
+Cancellation, failed commits and stale attempts cannot discard or resurrect
+settled retry work. Infrastructure lock failures retain a sweep-level pause.
+Account-wide cooldown admission is separate from entity retries. The source
+registry uses `metadata_source_accounts` for remote-source authentication holds,
+rate limits and transient outages across tasks, series and process restarts.
+Credential scope, not source priority, identifies the account. Local catalogs
+and fresh response-cache hits do not depend on account admission. Each provider
+read obtains its request slot before claiming a bounded recovery probe; one
+probe runs after a cooldown expires and an abandoned probe expires after 45
+seconds. Cancellation releases its lease without clearing the previous failure.
+Revision-checked outcome writes cannot clear a newer hold. Account transactions
+are short and independent of library transactions; provider I/O starts only
+after the admission transaction ends. Existing client request pacing remains
+in force. Settings shows current credential-scoped holds and a bounded, paginated
+list of deferred series work without provider calls or automatic polling. An
+authenticated, CSRF-protected Test connection explicitly permits one bounded
+authentication probe, never bypassing a timed rate limit. Success makes matching
+held retries due in the same transaction as account recovery; failed/cancelled or
+stale probes cannot clear newer holds. Older held retries without an account row
+are handled by the same manual check. Late authentication results are reconciled
+against recovered account state rather than recreating an indefinite hold.
+Successful recovery schedules existing continuations after commit, and startup
+also restores eligible retries for previously inactive sweeps.
Post-restore aftercare keeps its recovery marker while continuation batches remain
active; it observes completion without retaining a database transaction between checks.
Cancellation leaves the marker and sweep checkpoint available for the next startup.
diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md
index ca5091e2..e02579be 100644
--- a/docs/development/DATABASE_STANDARDS.md
+++ b/docs/development/DATABASE_STANDARDS.md
@@ -370,6 +370,301 @@ PostgreSQL where the code already supports it.
## 4. Schema Conventions
+### Metadata Identity Review
+
+Canonical series, issue, and Story Arc provider identities retain append-only
+evidence separately from active ownership. Automatic writers use the shared
+attachment service; non-verifying observations cannot acquire ownership or clear
+a rejected/conflicted decision. Stale and conflicted owners retain uniqueness.
+
+The interactive `/api/v1/metadata-identities/{kind}/{local_id}` endpoints list
+bounded saved claims, preview one saved event, and confirm/reject that event.
+Writes require operator authentication, shared CSRF protection, and a current
+review fingerprint/revision. The server supplies the actor; clients cannot
+supply a provider identity or actor ID in a decision body. A replay receipt
+proves historical completion, not current ownership. Replacing an assignment
+requires explicit rejection first, and dependent issue identities must be
+reviewed before detaching a series. Files and archive metadata are not changed
+by identity review.
+
+Review, observation, and attachment share parent lock ordering and caller-owned
+transactions. Compatibility ComicVine columns and retained history change
+atomically with ownership. Do not bypass these services with ad hoc ID updates.
+
+### Metadata Source Configuration
+
+`metadata_source_configs` stores one revision-checked policy per executable source,
+using the normal integer identity and timestamp mixins plus a unique source slug.
+It has no cascading relationship to provider identities. Reads do not create rows;
+the migration seeds default ComicVine local/API policies and disabled other sources.
+The canonical ComicVine credential remains in its existing configuration key.
+Other API tokens use encrypted columns, never the ordinary settings JSON or read DTOs.
+
+Services leave transaction ownership to callers. Settings writes use revision
+checks and row locks (plus a SQLite write transaction before the savepoint).
+Health checks perform no provider I/O inside a database transaction. Their short
+result write applies only to the tested revision and cannot replace a newer probe.
+Cached health is diagnostic, not proof that a source is safe or available now.
+
+### Metadata Account Admission
+
+`metadata_source_accounts` keeps at most one current credential-scope record per
+remote source, separate from series retries, response caches and library metadata.
+It stores a one-way account scope key, bounded status, UTC retry/probe deadlines
+and a positive revision, not credentials or remote error payloads. Source policy
+ordering changes cannot bypass a known account failure. Local catalogs have no
+account row. Downgrade removes transport admission state only.
+
+The account guard is a transport lifecycle boundary with engine-bound sessions
+of its own, like the source response cache. It never commits the calling library
+session. Admission commits before provider I/O; outcomes use conditional updates
+against the captured credential scope and revision. An expired cooldown admits
+one 45-second recovery probe, acquired after local request capacity is available.
+Cancellation releases the probe while retaining the prior failure; a crashed
+process recovers through lease expiry. Reads fail closed if admission storage
+cannot be checked, instead of repeatedly submitting known-failing requests.
+
+An explicit Test connection may lease an authentication-held account while its
+retry deadline remains null. Only success clears that hold; cancellation retains
+it. Account recovery and matching credential/configuration-scoped entity retry
+deadlines commit atomically, with revision increments invalidating stale work.
+Scheduler wakeups happen after commit. Downgrading the authentication-probe
+migration clears active authentication leases and advances their revisions without
+discarding the hold, library or entity retries. Operator read DTOs expose status,
+deadlines and bounded task counts, never account/configuration keys or credentials.
+
+### Canonical Metadata Baselines
+
+Series, issue and Story Arc metadata baselines use separate real foreign keys,
+one row per local target, positive revisions and bounded versioned snapshot JSON.
+They are retained provenance, not a provider-response cache. Entity deletion
+cascades the corresponding baseline; source configuration deletion does not.
+Migration does not backfill provider ownership of existing descriptive values.
+
+Baseline writes run through the shared service in caller-owned transactions.
+They lock arcs, series parents, then issues in ID order, verify current exact
+ownership and compare the expected baseline revision before updating. Batched
+creation avoids per-issue reads. SQLite obtains its write transaction before the
+savepoint; PostgreSQL uses row locks. A stale batch rolls back even when its caller
+catches the conflict. Future refresh writers must additionally revalidate their
+captured source-policy and ownership revisions and current user values before
+applying network results; the baseline service is not a substitute for that check.
+
+Source-bound Story Arc commands save their canonical arc baseline inside the
+existing revision-claimed catalog savepoint. Newly seeded parent/issue baselines
+share that transaction; pre-existing rows are not retrospectively attributed to
+the incoming source. Issue baseline writes are batched at 200. Refresh reloads
+current mapped fields after claiming the arc, retaining user changes and clears.
+Corrupt provenance aborts the entire refresh, including membership additions.
+Source-bound multi-source refresh captures an immutable read set before provider
+I/O and releases its read transaction. It locks source policies before the arc,
+then compares mapped fields, baseline revision/payload, identity claims/history,
+arc revision/lifecycle/monitoring and managed-placement presence before saving.
+A stale read aborts the complete membership and metadata write, including when a
+caller catches the domain error. Secondary sources never acquire membership or
+identity ownership through descriptive enrichment.
+
+Issue snapshots can retain up to 128 descriptive creator credits. Each credit
+has a bounded name and normalized role list compatible with the existing
+Creator/IssueCreator columns. Relations and baseline values commit together;
+provider absence does not clear local credits. Refresh includes current creator
+names/roles in its read set to detect edits, clears and concurrent changes.
+Credit reads and writes use 200-issue batches, not per-issue queries. Writers
+retain existing linked creator IDs when possible and never rename a shared
+creator or claim another provider's creator ID from a name match. The legacy
+ComicVine writer likewise resolves known IDs exactly; name-only reuse is limited
+to unclaimed descriptive rows. No credit schema migration or provenance backfill
+is needed for this additive snapshot field.
+
+Canonical field origins may retain the fixed embedded document names ComicInfo.xml
+and MetronInfo.xml. This local provenance cannot also claim a provider source,
+provider freshness, derivation or explicit user override. Older version-one
+snapshot JSON without the field remains valid. Archive-derived empty credits are
+not provider-enrichment gaps; subsequent edits and clears become user overrides.
+Observed archive identities stay unverified and cannot pass the baseline writer's
+ownership check as verified identities. Archive review/preservation diagnostics
+persist across refreshes until a new archive comparison updates them. The caller
+still owns independent file/target matching and atomic application to entity rows;
+baseline persistence alone neither establishes that match nor permits file writes.
+
+### Archive Publication Intent
+
+`archive_metadata_publications` stores versioned, bounded evidence before a paired
+archive replacement. A unique operation ID supports exact replay; active file and
+canonical-path reservations reject concurrent attempts. Nullable `SET NULL` file
+links preserve the immutable intent when the library row is deleted; the path
+reservation and evidence survive. There is no age-based lease takeover.
+
+Services use caller-owned transactions and the shared SQLite write/savepoint
+boundary. Recording new intent first acquires the shared file-mutation admission
+mutex, before any owner/entity locks. Import-owned publication locks its job, imported file and action;
+publication then locks source policies, series, issue, root, file, then the
+journal row. Generic filesystem classification only locks the journal row;
+import-owner recovery uses the full owner/binding lock order. Large file hashing, rendering
+and staging stay outside write transactions; only the short, offloaded atomic
+publication and directory-sync boundary retains the lock. An intent must commit
+before publication. A failed post-rename transaction leaves evidence for later
+inspection instead of authorizing blind replacement. Published records remain
+reserved pending coherent canonical/file-state finalization. Downgrade refuses
+to discard any intended, published or review record; only abandoned-only history
+may be removed. No existing archive is backfilled or mutated by the migration.
+
+Finalization uses the same policy/parent/issue/root/file/journal lock order and
+requires a published receipt, independently inspected output and an unchanged
+database binding. Canonical entity values, descriptive credits, baselines and
+LibraryFile size/timestamp/hash commit together with the finalized marker and
+reservation release. Unchanged snapshots do not advance their baselines. File
+checks run off-thread before and after DB writes; finalization does not rewrite
+archives. Naming/source ownership evidence and catalog lifecycle timestamps are
+not repurposed. A completed replay reports historical completion without
+reapplying values or claiming present ownership. Finalized receipts survive file
+deletion and block downgrade to the old state contract. Stale, interrupted or
+failed finalization retains the published reservation; caught errors still roll
+back the nested write. Production workflow rollback and restart integration remain
+required before existing writers switch to this boundary.
+
+An optional versioned-plan import owner binds deferred enrichment to its exact
+completed import record and managed placement action. Bounded digests retain the
+original action and pending-work snapshot; legacy journal JSON without an owner
+remains readable. Owner cancellation, rollback, deletion or changed evidence
+cannot authorize record, publication or finalization. Completed replay remains
+historical even after the owner is deleted.
+
+Finalization stores the successor publication reference on the import action and
+acknowledges deferred enrichment in the same transaction as canonical/file state.
+It never replaces the original destination signature or source/naming evidence.
+Import rollback rejects active publications, unverified successor pointers and
+reassigned registrations. A successor must be finalized for that exact unchanged
+action, and its verified fingerprint/digest must still match the actual archive.
+The original source remains subject to the existing rollback policy.
+
+A stopped owner may receive a `settled` receipt for a proven published successor.
+It updates only registered file accounting and the import's successor reference,
+marking enrichment cancelled without applying canonical snapshots or baselines.
+The immutable original placement proof and exact current registration must still
+match. Settled receipts release reservations and survive rollback; downgrade
+refuses to discard them. A proven untouched intent can instead be abandoned.
+Changed or unproven files retain reservations and cannot fall through to legacy
+enrichment. Neither recovery path writes an archive.
+
+The recovery task owns a clean session and commits bounded keyset reads before
+inspection/hashing, then commits each recovered receipt independently. It rejects
+pending caller edits and nested transactions. Startup/enrichment and rollback
+orchestration invoke it under the existing process-local import lane. Shared
+mutation fencing across all other writers and paired enrichment-worker activation
+remain required; the process-local lane is not a cross-process filesystem lock.
+
+### Short File Mutation Coordination
+
+`services/library_mutation_coordination.py` coordinates Library browser renames
+and series-folder renames with new archive-publication admission. SQLite acquires
+its writer transaction with a no-op ORM update; PostgreSQL uses the fixed
+transaction-scoped advisory key pair `0x50554C4C / 0x46494C45`. Acquire this mutex
+before entity locks and retain it through the caller's commit or rollback. No
+provider calls, archive rewriting or hashing may run under it. Existing bulk
+series-folder rename retains its caller-owned transaction; it is not a concurrent
+or background archive-conversion lane.
+
+Under the mutex, renames inspect bounded pages of active publication evidence,
+including source and private stage paths and directory descendants. Orphaned
+reservations survive deleted LibraryFile rows. Invalid or inconsistent plans fail
+closed. A terminal released publication does not block later renames. Publication
+work between commits remains protected by its durable reservation, not by a
+long-running advisory lock. This is cooperative application coordination, not a
+lock against external programs or a durable rename-recovery journal.
+
+Library browser renames join file work, commit and compensation on cancellation.
+Compensation reacquires admission, rechecks reservations/reference protection and
+moves back only the owned inode into an unclaimed destination. A completed commit
+is not undone when the request is cancelled. Conversion, deletion, bulk utility
+writers, import placement and other mutation paths still need the shared contract
+before paired background writing can be enabled.
+
+### Library Conversion Journal
+
+`library_conversions` retains bounded original, staged output and independent trash
+backup evidence. Preparation uses interruptible child processes outside database
+write transactions. The Library browser owns the session lifecycle: it commits
+intent before exclusive publication, then commits the existing LibraryFile's new
+path before removing the unchanged original. Reader state and issue identity keep
+their existing registration. Copying the backup into private staging on the trash
+filesystem supports cross-filesystem trash without a long SQLite write lock.
+
+Conversion reservations share admission with archive metadata publication and
+short renames. They cover original, output, backup and both stage paths, survive
+LibraryFile deletion, and do not expire. Startup recovery examines bounded batches,
+hashes outside write transactions, and rechecks the files under admission before
+registration or cleanup. It never republishes private stages. Proven untouched
+intent is abandoned; proven output/backup can finish registration and cleanup;
+changed or unproven evidence remains reserved for review. A lost commit response
+never permits deleting a registered output. Downgrade refuses retained completed
+or unresolved conversion evidence. Bulk utilities and other mutation entry points
+still require coordinated lifecycles before paired background writers activate.
+
+### Library Removal Journal
+
+`library_removals` retains bounded immutable source, root, private-stage and
+optional trash evidence independently of LibraryFile/series lifetimes. Admission
+reserves overlapping paths against archive publication, conversion and rename.
+The owner commits intent before a short exclusive staging rename, then commits
+the journal's detached state together with its selected database removals.
+Startup restores only uncommitted staging; it never resumes destructive cleanup.
+
+An explicit delete/trash disposition is required for cleanup. Historical plans
+default to retain-only. `cleanup_json` retains bounded copy/publication/deletion
+receipts; downgrade refuses to discard them. Cleanup owns clean-session
+transactions and holds a non-expiring OS file lock across its lifecycle, not a
+database write transaction across slow work. Same-filesystem trash uses exclusive
+rename; cross-filesystem trash copies privately, verifies content, and publishes
+without replacing an existing destination before removing its private source.
+Cancellation joins workers and leaves retryable proof. Unknown replacements are
+preserved. The old public source path is never used for cleanup.
+
+`workspace_cleaned` records proven terminal staging-directory cleanup, separately
+from payload disposition and retained cleanup evidence. Its composite index serves
+bounded startup recovery; completed markers avoid repeated filesystem scans.
+Upgrade defaults existing records to unverified without touching files. Downgrade
+drops only the marker/index and preserves all authorization and cleanup receipts.
+Only inactive complete/abandoned records may be pruned. Descriptor-relative empty
+directory removal and lock checks run under short admission; payloads, replacements
+and unknown contents are never recursively removed by terminal housekeeping.
+
+These are owner-integration primitives, not yet the public delete implementation.
+Series/browser/bulk deletion must adopt the complete lifecycle before paired
+background writing is enabled. POSIX file locking is implemented;
+other platforms currently fail closed at private cleanup admission.
+
+Trash maintenance now owns an idle session and commits each short, identity-checked
+unlink separately. The startup and utility route callers finish configuration
+reads before entering that lifecycle; the service rejects an existing transaction
+instead of committing unrelated changes. Shared admission protects recovery
+dependencies until deletion finishes, including cancellation. Traversal and
+bounded directory-page construction occur outside transactions. The indexed
+`library_removals.trash_path_key` is a derived literal-path lookup, not identity
+authority: the bounded original plan and cleanup receipt are revalidated before
+using their publication time to protect a whole trash tree. Upgrade backfills this
+index in bounded batches without filesystem access; downgrade discards only the
+derived key, never the retained plan/receipt. Existing legacy trash files keep
+mtime-based retention until their writers adopt journaled ownership.
+
+### Source Catalog Checkpoints
+
+`series_catalog_checkpoints` records complete issue-catalog progress separately
+for each series/source pair. Real cascading foreign keys bind it to the series,
+source configuration and active identity row. The saved source revision, identity
+revision and exact external ID must still match before a cursor is usable. A
+local-source checkpoint also retains the catalog generation; incremental readers
+must compare that generation before applying a partial window.
+
+Full Add/refresh writes persist the checkpoint in the same caller-owned
+transaction as catalog rows and canonical baselines. A captured checkpoint
+revision participates in refresh revalidation. Checkpoint timestamps record
+the live request start, never completion or an unvalidated cache hit. Explicit
+full revalidation bypasses response-cache coalescing for that catalog so a
+joined earlier fetch cannot be mistaken for a new modification boundary.
+Migration leaves existing global timestamps untouched and creates no synthetic
+provider checkpoints. Downgrade discards sync progress only.
+
### 4.1 Base Model Mixins
**Current Pullbox implementation**
diff --git a/docs/development/INFRASTRUCTURE.md b/docs/development/INFRASTRUCTURE.md
index 279a0fb1..587e1d3a 100644
--- a/docs/development/INFRASTRUCTURE.md
+++ b/docs/development/INFRASTRUCTURE.md
@@ -604,6 +604,7 @@ Production dependency categories:
| Auth/security | bcrypt, itsdangerous, cryptography |
| Logging/tasks | structlog, APScheduler |
| Archive/media | rarfile, py7zr, Pillow, pdf2image, tzlocal |
+| Embedded metadata | defusedxml for bounded parsing; xmlschema for offline MetronInfo XSD 1.1 validation |
Development dependency categories:
@@ -619,6 +620,9 @@ Development dependency categories:
- Keep production dependencies minimal.
- Every production dependency should justify its runtime value.
+- MetronInfo's bundled schema requires XSD 1.1 assertions. Keep its schema,
+ upstream license, and provenance in the built package; validation must not
+ download schemas or follow document-supplied resource locations.
- Prefer standard library tools when they are good enough.
- Use compatible-release pinning where practical.
- Avoid unnecessary dependency churn.
diff --git a/docs/development/SECURITY_STANDARDS.md b/docs/development/SECURITY_STANDARDS.md
index c72d1fbd..43b44052 100644
--- a/docs/development/SECURITY_STANDARDS.md
+++ b/docs/development/SECURITY_STANDARDS.md
@@ -533,7 +533,10 @@ that lock down important behavior.
- CBZ repack plus CBR/CB7 conversion reject unsafe archive member names before
extraction.
- Non-ZIP archive families do not receive equivalent decompressed-size
- inspection today.
+ inspection in legacy conversion paths. The paired metadata writer separately
+ enforces declared and streamed limits for RAR/7z/TAR, rejects links/special
+ members and uses private numbered 7z spools instead of extracted member paths.
+ Its production consumer migration is not yet enabled.
**Required standard**
diff --git a/pyproject.toml b/pyproject.toml
index a1f74e8f..5c5ebc7c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,7 @@ classifiers = [
dependencies = [
"fastapi~=0.135",
"uvicorn[standard]~=0.34",
- "sqlalchemy[asyncio]~=2.0",
+ "sqlalchemy[asyncio]~=2.0.0", # Keep the qualified 2.0 typing/runtime contract.
"alembic~=1.14",
"mako>=1.3.12", # transitive via alembic — floor for CVE-2026-44307
"aiosqlite~=0.20",
@@ -38,6 +38,7 @@ dependencies = [
"cryptography>=50.0.0", # floor for CVE-2026-69247
"msgpack>=1.2.1,<2.0", # transitive via py7zr — floor for GHSA-6v7p-g79w-8964
"defusedxml~=0.7",
+ "xmlschema~=4.3.2", # Offline MetronInfo XSD 1.1 validation, including assertions.
"apscheduler~=3.10",
"starlette>=1.3.1", # transitive via fastapi — floors for PYSEC-2026-161/CVE-2026-54282/CVE-2026-54283
"structlog>=25.5,<27.0",
@@ -92,6 +93,9 @@ where = ["src"]
[tool.setuptools.package-data]
pullbox = [
+ "core/metadata_schemas/*.xsd",
+ "core/metadata_schemas/*-LICENSE",
+ "core/metadata_schemas/README.md",
"ui/templates/*.html",
"ui/templates/components/*.html",
"ui/templates/pages/*.html",
diff --git a/scripts/benchmark_mylar3_import.py b/scripts/benchmark_mylar3_import.py
index 96c7cf1e..955e9ee5 100644
--- a/scripts/benchmark_mylar3_import.py
+++ b/scripts/benchmark_mylar3_import.py
@@ -36,11 +36,12 @@
ImportedFile,
ImportedFileStatus,
ImportedSeries,
- ImportJob,
ImportJobStatus,
ImportSourceType,
)
+from pullbox.models.library import LibraryRoot
from pullbox.performance.baseline import current_process_peak_rss_bytes
+from pullbox.schemas.import_job import ImportJobCreate
from pullbox.services.import_service import ImportService
@@ -122,12 +123,21 @@ def counting_read_zip(
try:
async with session_factory() as session:
- job = ImportJob(
- source_path=str(fixture.db_path),
- source_type=ImportSourceType.MYLAR3,
- status=ImportJobStatus.PENDING,
+ destination = workspace / "managed-library"
+ destination.mkdir()
+ root = LibraryRoot(name="Benchmark destination", path=str(destination), enabled=True)
+ session.add(root)
+ await session.flush()
+ # Use Step 1's revalidated identity mapping, not a synthetic scan bypass.
+ job = await service.create_job(
+ session,
+ ImportJobCreate(
+ source_path=str(fixture.db_path),
+ source_type=ImportSourceType.MYLAR3,
+ target_library_root_id=root.id,
+ mylar3_path_map_confirmed=True,
+ ),
)
- session.add(job)
await session.commit()
started_at = time.monotonic()
diff --git a/scripts/verify_container_security_runtime.py b/scripts/verify_container_security_runtime.py
index ed850a4b..c369bf71 100644
--- a/scripts/verify_container_security_runtime.py
+++ b/scripts/verify_container_security_runtime.py
@@ -2,10 +2,17 @@
from __future__ import annotations
+import asyncio
+import io
import pyexpat
+import tempfile
+import zipfile
from importlib.util import find_spec
+from pathlib import Path
from xml.etree import ElementTree
+from PIL import Image
+
from pullbox.core.xml_security import parse_untrusted_xml
MINIMUM_EXPAT_VERSION = (2, 8, 1)
@@ -32,6 +39,73 @@ def verify_utf16_xml_boundary() -> None:
raise SystemExit("Container must reject malformed UTF-16 XML before Expat parsing")
+def verify_paired_pdf_boundary() -> None:
+ """Require real bounded Poppler rendering and private paired staging in the image."""
+ from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+ )
+ from pullbox.core.metroninfo import parse_metroninfo
+ from pullbox.core.metroninfo_schema import validate_metroninfo_xml
+ from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues
+ from pullbox.utilities.executors.archive_metadata_staging import (
+ stage_cbz_metadata_interruptible,
+ )
+
+ series = MetadataSnapshot(
+ entity_kind=MetadataEntityKind.SERIES,
+ identities=(
+ ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.SERIES, "42"),
+ ),
+ values=MetadataValues(title="Runtime fixture"),
+ )
+ issue = MetadataSnapshot(
+ entity_kind=MetadataEntityKind.ISSUE,
+ identities=(
+ ExternalIdentityRef(IdentityNamespace.COMICVINE, MetadataEntityKind.ISSUE, "7"),
+ ),
+ values=MetadataValues(issue_number_text="50-x"),
+ )
+
+ async def probe(directory: Path) -> None:
+ source = directory / "fixture.pdf"
+ with (
+ Image.new("RGB", (72, 90), "red") as first,
+ Image.new("RGB", (72, 90), "blue") as second,
+ ):
+ first.save(source, format="PDF", save_all=True, append_images=[second])
+ original = source.read_bytes()
+ async with stage_cbz_metadata_interruptible(
+ source, directory, series, issue, max_uncompressed_bytes=2_000_000
+ ) as staged:
+ staged.check_unchanged()
+ with zipfile.ZipFile(staged.path) as archive:
+ assert archive.testzip() is None
+ assert archive.namelist() == [
+ "page_0000.jpg",
+ "page_0001.jpg",
+ "ComicInfo.xml",
+ "MetronInfo.xml",
+ ]
+ ci = parse_untrusted_xml(archive.read("ComicInfo.xml"))
+ mi_payload = archive.read("MetronInfo.xml")
+ validate_metroninfo_xml(mi_payload)
+ mi = parse_metroninfo(mi_payload)
+ assert ci.findtext("Series") == mi.series == "Runtime fixture"
+ assert ci.findtext("Number") == mi.number == "50-x"
+ for name, channel in (("page_0000.jpg", 0), ("page_0001.jpg", 2)):
+ with Image.open(io.BytesIO(archive.read(name))) as page:
+ assert page.size == (200, 250)
+ pixel = page.getpixel((100, 100))
+ assert pixel[channel] > 200 and pixel[1] < 20
+ assert source.read_bytes() == original
+ assert list(directory.iterdir()) == [source]
+
+ with tempfile.TemporaryDirectory(prefix="pullbox-pdf-probe-") as directory:
+ asyncio.run(probe(Path(directory)))
+
+
def main() -> None:
"""Run all container runtime security assertions."""
for package in ("safety", "nltk"):
@@ -42,6 +116,8 @@ def main() -> None:
print(f"Container Expat runtime verified: {pyexpat.EXPAT_VERSION}")
verify_utf16_xml_boundary()
print("Container malformed UTF-16 XML guard verified")
+ verify_paired_pdf_boundary()
+ print("Container paired PDF staging and source-preservation boundary verified")
if __name__ == "__main__":
diff --git a/src/pullbox/__init__.py b/src/pullbox/__init__.py
index dd412321..03c0962d 100644
--- a/src/pullbox/__init__.py
+++ b/src/pullbox/__init__.py
@@ -2,7 +2,7 @@
from datetime import UTC, datetime
-__version__ = "1.3.1-dev"
+__version__ = "2.0.0-dev"
# Set once at process start; used by System > About for uptime calculation.
STARTED_AT: datetime = datetime.now(UTC)
diff --git a/src/pullbox/api/middleware.py b/src/pullbox/api/middleware.py
index 3c643ca6..f548082f 100644
--- a/src/pullbox/api/middleware.py
+++ b/src/pullbox/api/middleware.py
@@ -428,7 +428,8 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: "
"https://comicvine.gamespot.com https://*.gamespot.com "
- "https://s3.amazonaws.com; "
+ "https://s3.amazonaws.com https://metron.cloud/media/ "
+ "https://static.metron.cloud/media/; "
"connect-src 'self'; "
"font-src 'self'; "
"frame-ancestors 'none'"
diff --git a/src/pullbox/api/v1/config.py b/src/pullbox/api/v1/config.py
index 5f55e597..59a2761b 100644
--- a/src/pullbox/api/v1/config.py
+++ b/src/pullbox/api/v1/config.py
@@ -3,7 +3,7 @@
from pathlib import Path
import structlog
-from fastapi import APIRouter, Query, Request
+from fastapi import APIRouter, HTTPException, Query, Request
from sqlalchemy import select
from pullbox.api.deps import DbSession, InteractiveOperatorUser
@@ -769,13 +769,45 @@ async def save_comicvine_key(
body: dict[str, str],
) -> dict[str, object]:
"""Save (encrypted) the ComicVine API key to the database."""
- from pullbox.core.comicvine_key import obfuscate_api_key, save_comicvine_api_key
+ from pydantic import SecretStr
+
+ from pullbox.core.comicvine_key import obfuscate_api_key
+ from pullbox.core.metadata_identity import MetadataSource
+ from pullbox.schemas.metadata_sources import SourcePolicyWrite
+ from pullbox.services.metadata_sources import (
+ SourceConfigurationConflictError,
+ read_source_policies,
+ save_source_policy,
+ )
api_key = body.get("api_key", "").strip()
if not api_key:
return {"saved": False, "message": "No API key provided."}
- await save_comicvine_api_key(session, api_key)
+ policy = next(
+ item
+ for item in await read_source_policies(session)
+ if item.source is MetadataSource.COMICVINE_API
+ )
+ if policy.configuration_status is not None:
+ raise HTTPException(409, "Source configuration needs attention before saving a new key")
+ try:
+ await save_source_policy(
+ session,
+ MetadataSource.COMICVINE_API,
+ SourcePolicyWrite(
+ revision=policy.revision,
+ enabled=policy.enabled,
+ priority=policy.priority,
+ domain_priorities=policy.domain_priorities,
+ settings=policy.settings,
+ credential=SecretStr(api_key),
+ ),
+ )
+ except SourceConfigurationConflictError as exc:
+ raise HTTPException(409, str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(400, "Could not save the provider token; check its format") from exc
return {
"saved": True,
"message": "API key saved.",
diff --git a/src/pullbox/api/v1/covers.py b/src/pullbox/api/v1/covers.py
index 0f438bc9..59cf6d17 100644
--- a/src/pullbox/api/v1/covers.py
+++ b/src/pullbox/api/v1/covers.py
@@ -174,14 +174,14 @@ async def get_issue_cover(
if issue is None:
raise NotFoundError("Issue", issue_id)
- # Format issue number for filename (e.g. 1.0 → "001", 1.5 → "001.5")
- num = issue.issue_number
- issue_num_str = f"{int(num):03d}" if num == int(num) else f"{num:06.1f}"
+ from pullbox.services.provider_artwork import issue_cover_stem
+
+ stem = issue_cover_stem(issue.issue_number, issue.issue_number_text)
# 1. Try series folder (by issue number — human-readable, survives DB rebuilds)
if issue.series and issue.series.path:
series_path = Path(issue.series.path)
- cover = _find_cover_file(series_path, f"issue_{issue_num_str}")
+ cover = _find_cover_file(series_path, stem)
if cover:
return _serve_issue_image(cover)
@@ -191,7 +191,7 @@ async def get_issue_cover(
covers_base = await resolve_covers_dir(session)
if issue.series:
covers_dir = covers_base / str(issue.series_id)
- cover = _find_cover_file(covers_dir, f"issue_{issue_num_str}")
+ cover = _find_cover_file(covers_dir, stem)
if cover:
return _serve_issue_image(cover)
diff --git a/src/pullbox/api/v1/issue_metadata_links.py b/src/pullbox/api/v1/issue_metadata_links.py
new file mode 100644
index 00000000..790b69d9
--- /dev/null
+++ b/src/pullbox/api/v1/issue_metadata_links.py
@@ -0,0 +1,197 @@
+"""Operator review and descriptive refresh for existing issues."""
+
+from typing import Annotated
+
+from fastapi import APIRouter, HTTPException, Path
+from sqlalchemy import JSON, Text, cast, select, type_coerce
+
+from pullbox.api.deps import DbSession, InteractiveOperatorUser, Settings
+from pullbox.core.exceptions import ValidationError
+from pullbox.models.operation_progress import OperationProgress, OperationProgressType
+from pullbox.schemas.issue_file_metadata import (
+ FileMetadataApproval,
+ FileMetadataPreview,
+ FileMetadataReview,
+)
+from pullbox.schemas.issue_metadata_links import (
+ IssueCandidatesQuery,
+ IssueLinkPreview,
+ IssueLinkQuery,
+ IssueLinksRead,
+ IssueRefreshRead,
+)
+from pullbox.schemas.metadata_identity_review import IdentityReviewReceiptRead
+from pullbox.schemas.metadata_sources import MetadataFetch, MetadataPage, ProviderIssueRead
+from pullbox.schemas.series_metadata_links import SeriesLinkConfirm
+from pullbox.services.archive_metadata_publication import ArchivePublicationError
+from pullbox.services.issue_file_metadata import file_metadata_error, prepare_file_metadata
+from pullbox.services.issue_metadata_links import (
+ confirm_issue_link,
+ issue_link_candidates,
+ preview_issue_link,
+ read_issue_links,
+)
+from pullbox.services.metadata_issue_refresh import refresh_issue_from_sources
+from pullbox.utilities.import_guards import ensure_no_active_import_file_mutation
+from pullbox.utilities.models import JobType, UtilityJob, UtilityJobItem
+from pullbox.utilities.router import _get_manager, _schedule_dispatch
+
+router = APIRouter(prefix="/issues", tags=["issues"])
+LocalId = Annotated[int, Path(gt=0, lt=2**63)]
+
+
+@router.get("/{issue_id}/metadata-links", response_model=IssueLinksRead)
+async def links(
+ issue_id: LocalId, session: DbSession, _user: InteractiveOperatorUser, settings: Settings
+) -> IssueLinksRead:
+ try:
+ return await read_issue_links(
+ session, issue_id, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(409, str(exc)) from exc
+
+
+@router.post(
+ "/{issue_id}/metadata-links/candidates",
+ response_model=MetadataFetch[MetadataPage[ProviderIssueRead]],
+)
+async def candidates(
+ issue_id: LocalId,
+ body: IssueCandidatesQuery,
+ session: DbSession,
+ _user: InteractiveOperatorUser,
+ settings: Settings,
+) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ try:
+ return await issue_link_candidates(
+ session, issue_id, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(409, str(exc)) from exc
+
+
+@router.post("/{issue_id}/metadata-links/preview", response_model=IssueLinkPreview)
+async def preview(
+ issue_id: LocalId,
+ body: IssueLinkQuery,
+ session: DbSession,
+ _user: InteractiveOperatorUser,
+ settings: Settings,
+) -> IssueLinkPreview:
+ try:
+ return await preview_issue_link(
+ session, issue_id, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(409, str(exc)) from exc
+
+
+@router.post("/{issue_id}/metadata-links/confirm", response_model=IdentityReviewReceiptRead)
+async def confirm(
+ issue_id: LocalId,
+ body: SeriesLinkConfirm,
+ session: DbSession,
+ user: InteractiveOperatorUser,
+ settings: Settings,
+) -> IdentityReviewReceiptRead:
+ try:
+ return await confirm_issue_link(
+ session,
+ issue_id,
+ body,
+ user_id=user.id,
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ )
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(409, str(exc)) from exc
+
+
+@router.post("/{issue_id}/refresh-metadata", response_model=IssueRefreshRead)
+async def refresh(
+ issue_id: LocalId, session: DbSession, _user: InteractiveOperatorUser, settings: Settings
+) -> IssueRefreshRead:
+ try:
+ return await refresh_issue_from_sources(
+ session, issue_id, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(409, str(exc)) from exc
+
+
+@router.post("/{issue_id}/file-metadata/preview", response_model=FileMetadataPreview)
+async def file_preview(
+ issue_id: LocalId,
+ session: DbSession,
+ _user: InteractiveOperatorUser,
+ body: FileMetadataReview | None = None,
+) -> FileMetadataPreview:
+ try:
+ return (
+ await prepare_file_metadata(session, issue_id, choices=body.choices if body else None)
+ ).preview
+ except (ValueError, OSError) as exc:
+ raise HTTPException(409, file_metadata_error(exc)) from exc
+
+
+@router.post("/{issue_id}/file-metadata/write", status_code=202)
+async def file_write(
+ issue_id: LocalId, body: FileMetadataApproval, session: DbSession, user: InteractiveOperatorUser
+) -> dict[str, str]:
+ try:
+ prepared = await prepare_file_metadata(session, issue_id, choices=body.choices)
+ if not prepared.preview.ready:
+ raise ArchivePublicationError("unresolved_conflicts")
+ if prepared.preview.review_key != body.review_key:
+ raise ArchivePublicationError("approval_changed")
+ await ensure_no_active_import_file_mutation(session)
+ manager = _get_manager()
+ job = await manager.create_job(
+ session,
+ JobType.FILE_METADATA,
+ f"Write metadata: {prepared.preview.file_name}",
+ {"issue_id": issue_id, "review_key": body.review_key, "choices": body.choices},
+ created_by=user.username,
+ )
+ await session.commit()
+ _schedule_dispatch(manager)
+ return {"job_id": job.id, "state": job.state}
+ except (ValueError, OSError) as exc:
+ raise HTTPException(409, file_metadata_error(exc)) from exc
+
+
+@router.get("/{issue_id}/file-metadata/job")
+async def file_job(
+ issue_id: LocalId, session: DbSession, _user: InteractiveOperatorUser
+) -> dict[str, object]:
+ document = JSON().with_variant(Text(), "sqlite")
+ job = await session.scalar(
+ select(UtilityJob)
+ .where(
+ UtilityJob.job_type == JobType.FILE_METADATA,
+ type_coerce(cast(UtilityJob.config, document), JSON)["issue_id"].as_integer()
+ == issue_id,
+ )
+ .order_by(UtilityJob.created_at.desc(), UtilityJob.id.desc())
+ .limit(1)
+ )
+ if job is None:
+ return {"job": None}
+ item = await session.scalar(
+ select(UtilityJobItem).where(UtilityJobItem.job_id == job.id).limit(1)
+ )
+ progress = await session.scalar(
+ select(OperationProgress).where(
+ OperationProgress.operation_type == OperationProgressType.UTILITY,
+ OperationProgress.operation_key == job.id,
+ )
+ )
+ return {
+ "job": {
+ "id": job.id,
+ "state": job.state,
+ "percent": progress.overall_percent if progress else job.progress_pct,
+ "message": progress.message if progress else job.state,
+ "error": (item.error_message if item else None) or job.error_message,
+ }
+ }
diff --git a/src/pullbox/api/v1/library.py b/src/pullbox/api/v1/library.py
index 71a1153b..dff7692e 100644
--- a/src/pullbox/api/v1/library.py
+++ b/src/pullbox/api/v1/library.py
@@ -257,7 +257,9 @@ async def _folder_has_tracked_descendants(session: DbSession, target: Path) -> b
file_count = int(
(
await session.execute(
- select(func.count(LibraryFile.id)).where(LibraryFile.file_path.like(f"{prefix}/%"))
+ select(func.count(LibraryFile.id)).where(
+ LibraryFile.file_path.startswith(f"{prefix}/", autoescape=True)
+ )
)
).scalar_one()
or 0
@@ -276,7 +278,9 @@ async def _folder_has_tracked_descendants(session: DbSession, target: Path) -> b
series_count = int(
(
await session.execute(
- select(func.count(Series.id)).where(Series.path.like(f"{prefix}/%"))
+ select(func.count(Series.id)).where(
+ Series.path.startswith(f"{prefix}/", autoescape=True)
+ )
)
).scalar_one()
or 0
diff --git a/src/pullbox/api/v1/metadata_arc_catalog.py b/src/pullbox/api/v1/metadata_arc_catalog.py
new file mode 100644
index 00000000..3aa46c4e
--- /dev/null
+++ b/src/pullbox/api/v1/metadata_arc_catalog.py
@@ -0,0 +1,139 @@
+"""Source-bound Story Arc catalog commands."""
+
+from typing import Annotated, NoReturn
+
+from fastapi import APIRouter, BackgroundTasks, HTTPException, Path, Request
+from sqlalchemy.exc import IntegrityError
+
+from pullbox.api.deps import AuthenticatedUser, DbSession, Settings, get_request_session_factory
+from pullbox.api.v1.story_arcs import _load_arc_response
+from pullbox.schemas.metadata_arc_catalog import (
+ ArcCatalogAdd,
+ ArcCatalogPreviewRead,
+ ArcCatalogRefresh,
+ ArcCatalogSelection,
+)
+from pullbox.schemas.story_arc import StoryArcResponse
+from pullbox.services.metadata_arc_catalog import StoryArcSourceError
+from pullbox.services.metadata_arc_commands import (
+ ArcCommandResult,
+ describe_arc_catalog,
+ fetch_current_arc_catalog,
+ source_arc_add_transaction,
+ source_arc_refresh_transaction,
+)
+from pullbox.services.story_arc_catalog_types import StoryArcCatalogError
+from pullbox.services.story_arc_placement_integration import StoryArcPlacementIntegrationError
+from pullbox.services.story_arc_service import StoryArcServiceError
+
+router = APIRouter(prefix="/metadata/story-arcs/catalog", tags=["metadata"])
+
+
+_ArcId = Annotated[int, Path(gt=0, lt=2**63)]
+
+
+def _failure(exc: Exception) -> NoReturn:
+ detail = {
+ "code": "catalog_conflict",
+ "message": (
+ "The arc was not changed. Review the current source and storage settings, then retry."
+ ),
+ }
+ headers = None
+ if isinstance(exc, StoryArcCatalogError):
+ detail["code"] = exc.code
+ detail["message"] = str(exc)
+ if isinstance(exc, StoryArcSourceError):
+ detail["source_status"] = exc.source_status.value
+ if exc.retry_after_seconds is not None:
+ headers = {"Retry-After": str(exc.retry_after_seconds)}
+ raise HTTPException(409, detail, headers=headers) from exc
+
+
+def _schedule(result: ArcCommandResult, request: Request, tasks: BackgroundTasks) -> None:
+ # Called only after the owned transaction exits successfully.
+ if result.search_on_add:
+ from pullbox.tasks.story_arc_search_task import schedule_story_arc_search
+
+ schedule_story_arc_search(result.arc.id)
+ if result.initial_placements:
+ from pullbox.services.story_arc_catalog_placement import run_catalog_initial_placements
+
+ tasks.add_task(
+ run_catalog_initial_placements,
+ result.arc.id,
+ session_factory=get_request_session_factory(request),
+ )
+
+
+@router.post("/preview", response_model=ArcCatalogPreviewRead)
+async def preview_catalog(
+ body: ArcCatalogSelection, session: DbSession, _user: AuthenticatedUser, settings: Settings
+) -> ArcCatalogPreviewRead:
+ try:
+ preview = await fetch_current_arc_catalog(
+ session, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ return await describe_arc_catalog(session, preview)
+ except (StoryArcServiceError, StoryArcPlacementIntegrationError) as exc:
+ _failure(exc)
+
+
+@router.post("", response_model=StoryArcResponse, status_code=201)
+async def add_catalog(
+ body: ArcCatalogAdd,
+ session: DbSession,
+ _user: AuthenticatedUser,
+ settings: Settings,
+ request: Request,
+ background_tasks: BackgroundTasks,
+) -> StoryArcResponse:
+ try:
+ preview = await fetch_current_arc_catalog(
+ session, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ async with source_arc_add_transaction(session, preview, body) as result:
+ response = await _load_arc_response(session, result.arc.id)
+ except (StoryArcServiceError, StoryArcPlacementIntegrationError, IntegrityError) as exc:
+ _failure(exc)
+ _schedule(result, request, background_tasks)
+ return response
+
+
+@router.post("/{story_arc_id}/preview", response_model=ArcCatalogPreviewRead)
+async def preview_catalog_refresh(
+ story_arc_id: _ArcId,
+ body: ArcCatalogSelection,
+ session: DbSession,
+ _user: AuthenticatedUser,
+ settings: Settings,
+) -> ArcCatalogPreviewRead:
+ try:
+ preview = await fetch_current_arc_catalog(
+ session, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ return await describe_arc_catalog(session, preview, story_arc_id=story_arc_id)
+ except (StoryArcServiceError, StoryArcPlacementIntegrationError) as exc:
+ _failure(exc)
+
+
+@router.post("/{story_arc_id}", response_model=StoryArcResponse)
+async def refresh_catalog(
+ story_arc_id: _ArcId,
+ body: ArcCatalogRefresh,
+ session: DbSession,
+ _user: AuthenticatedUser,
+ settings: Settings,
+ request: Request,
+ background_tasks: BackgroundTasks,
+) -> StoryArcResponse:
+ try:
+ preview = await fetch_current_arc_catalog(
+ session, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ async with source_arc_refresh_transaction(session, story_arc_id, preview, body) as result:
+ response = await _load_arc_response(session, result.arc.id)
+ except (StoryArcServiceError, StoryArcPlacementIntegrationError, IntegrityError) as exc:
+ _failure(exc)
+ _schedule(result, request, background_tasks)
+ return response
diff --git a/src/pullbox/api/v1/metadata_identities.py b/src/pullbox/api/v1/metadata_identities.py
new file mode 100644
index 00000000..b3d89e2a
--- /dev/null
+++ b/src/pullbox/api/v1/metadata_identities.py
@@ -0,0 +1,105 @@
+"""Interactive review of saved identity claims; no provider or file mutation."""
+
+from typing import Annotated
+
+from fastapi import APIRouter, HTTPException, Path, Query
+
+from pullbox.api.deps import DbSession, InteractiveOperatorUser
+from pullbox.core.metadata_identity import MetadataEntityKind
+from pullbox.core.metadata_identity_events import IdentityEventReplayConflictError
+from pullbox.core.metadata_identity_state import (
+ IdentityReviewRequiredError,
+ IdentityVerificationAction,
+)
+from pullbox.schemas.metadata_identity_review import (
+ IdentityClaimRead,
+ IdentityReviewRead,
+ IdentityReviewReceiptRead,
+ IdentityReviewWrite,
+)
+from pullbox.schemas.pagination import PaginatedResponse
+from pullbox.services.metadata_identity_attachment import IdentityAttachmentConflictError
+from pullbox.services.metadata_identity_review import (
+ IdentityReviewNotFoundError,
+ apply_identity_review,
+ list_identity_claims,
+ preview_identity_review,
+)
+
+router = APIRouter(prefix="/metadata-identities", tags=["metadata-identities"])
+LocalId = Annotated[int, Path(gt=0, lt=2**63)]
+
+
+def _error(exc: ValueError) -> HTTPException:
+ if isinstance(exc, IdentityReviewNotFoundError):
+ return HTTPException(404, str(exc))
+ return HTTPException(409, str(exc))
+
+
+@router.get("/{kind}/{local_id}/claims", response_model=PaginatedResponse[IdentityClaimRead])
+async def identity_claims(
+ kind: MetadataEntityKind,
+ local_id: LocalId,
+ session: DbSession,
+ _user: InteractiveOperatorUser,
+ limit: int = Query(default=50, ge=1, le=200),
+ offset: int = Query(default=0, ge=0),
+) -> PaginatedResponse[IdentityClaimRead]:
+ try:
+ items, total = await list_identity_claims(
+ session, kind, local_id, limit=limit, offset=offset
+ )
+ except IdentityAttachmentConflictError as exc:
+ raise _error(exc) from exc
+ return PaginatedResponse(
+ items=[IdentityClaimRead.model_validate(item) for item in items],
+ total=total,
+ limit=limit,
+ offset=offset,
+ has_more=offset + len(items) < total,
+ )
+
+
+@router.get("/{kind}/{local_id}/review/{event_id}", response_model=IdentityReviewRead)
+async def identity_review_preview(
+ kind: MetadataEntityKind,
+ local_id: LocalId,
+ event_id: LocalId,
+ session: DbSession,
+ _user: InteractiveOperatorUser,
+) -> IdentityReviewRead:
+ try:
+ result = await preview_identity_review(session, kind, local_id, event_id)
+ except (IdentityReviewNotFoundError, IdentityAttachmentConflictError) as exc:
+ raise _error(exc) from exc
+ return IdentityReviewRead.model_validate(result)
+
+
+@router.post("/{kind}/{local_id}/review/{event_id}", response_model=IdentityReviewReceiptRead)
+async def identity_review_apply(
+ kind: MetadataEntityKind,
+ local_id: LocalId,
+ event_id: LocalId,
+ body: IdentityReviewWrite,
+ session: DbSession,
+ user: InteractiveOperatorUser,
+) -> IdentityReviewReceiptRead:
+ try:
+ receipt = await apply_identity_review(
+ session,
+ kind,
+ local_id,
+ event_id,
+ action=IdentityVerificationAction(body.action),
+ fingerprint=body.fingerprint,
+ review_revision=body.review_revision,
+ actor_user_id=user.id,
+ )
+ except (
+ IdentityReviewNotFoundError,
+ IdentityAttachmentConflictError,
+ IdentityReviewRequiredError,
+ IdentityEventReplayConflictError,
+ ) as exc:
+ raise _error(exc) from exc
+ return IdentityReviewReceiptRead(event_id=receipt.event_id, replayed=receipt.replayed)
diff --git a/src/pullbox/api/v1/metadata_sources.py b/src/pullbox/api/v1/metadata_sources.py
new file mode 100644
index 00000000..67e229f3
--- /dev/null
+++ b/src/pullbox/api/v1/metadata_sources.py
@@ -0,0 +1,316 @@
+"""Metadata source configuration, discovery and explicit connection checks."""
+
+from datetime import UTC, datetime
+from typing import Literal
+
+import structlog
+from fastapi import APIRouter, HTTPException, Query, Request
+
+from pullbox.api.deps import AuthenticatedUser, DbSession, InteractiveOperatorUser, Settings
+from pullbox.core.exceptions import ConfigurationError
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.models.library import LibraryRoot
+from pullbox.providers.metadata.gcd_local import GcdLocalSource
+from pullbox.schemas.metadata_sources import (
+ DeferredMetadataRead,
+ MetadataFetch,
+ MetadataPage,
+ ProviderIssueRead,
+ SeriesAddPreviewQuery,
+ SeriesDiscoveryQuery,
+ SeriesDiscoveryRead,
+ SeriesIssuePageQuery,
+ SeriesIssuePageRead,
+ SeriesPreviewRead,
+ SourceDescriptor,
+ SourcePolicyRead,
+ SourcePolicyWrite,
+ SourcePriorityWrite,
+ SourceStatus,
+ SourceTestRead,
+ StoryArcDiscoveryQuery,
+ StoryArcDiscoveryRead,
+ StoryArcIssuePageQuery,
+ StoryArcPreviewQuery,
+ StoryArcPreviewRead,
+)
+from pullbox.schemas.pagination import PaginatedResponse
+from pullbox.services.gcd_local_activation import activate_snapshot, validate_for_request
+from pullbox.services.metadata_arc_preview import preview_source_arc
+from pullbox.services.metadata_discovery import MetadataSourceError, MetadataSourceRegistry
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_series_adoption import SeriesAdoptionError
+from pullbox.services.metadata_series_artwork import representative_series_cover
+from pullbox.services.metadata_series_preview import preview_series_folder, preview_source_series
+from pullbox.services.metadata_source_status import deferred_work, source_status
+from pullbox.services.metadata_sources import (
+ SourceConfigurationConflictError,
+ load_source_runtime,
+ read_source_policies,
+ record_source_health,
+ save_source_policy,
+ save_source_priorities,
+)
+
+router = APIRouter(prefix="/metadata", tags=["metadata"])
+logger = structlog.get_logger(__name__)
+
+
+async def _preview_root(session: DbSession, root_id: int) -> LibraryRoot:
+ root = await session.get(LibraryRoot, root_id, populate_existing=True)
+ if root is None or not root.enabled or not root.allow_managed_writes:
+ raise HTTPException(409, "Choose an enabled managed library root, then retry the preview.")
+ return root
+
+
+async def _require_source_revision(
+ session: DbSession,
+ source: MetadataSource,
+ revision: int,
+ *,
+ subject: Literal["series", "story arc"] = "series",
+) -> None:
+ policies = await read_source_policies(session)
+ if next(policy.revision for policy in policies if policy.source is source) != revision:
+ raise HTTPException(409, f"Metadata source settings changed. Preview the {subject} again.")
+
+
+@router.post("/story-arcs/search", response_model=StoryArcDiscoveryRead)
+async def search_story_arcs(
+ body: StoryArcDiscoveryQuery, session: DbSession, _user: AuthenticatedUser, settings: Settings
+) -> StoryArcDiscoveryRead:
+ runtime = await load_source_runtime(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ await session.rollback()
+ return await MetadataSourceRegistry(
+ runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ ).discover_arcs(body)
+
+
+@router.post("/story-arcs/preview", response_model=StoryArcPreviewRead)
+async def preview_story_arc(
+ body: StoryArcPreviewQuery, session: DbSession, _user: AuthenticatedUser, settings: Settings
+) -> StoryArcPreviewRead:
+ runtime = await load_source_runtime(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ await session.rollback()
+ result = await preview_source_arc(
+ MetadataSourceRegistry(
+ runtime,
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ read_cache=source_read_cache(session),
+ ),
+ body.source,
+ body.external_id,
+ )
+ await _require_source_revision(
+ session, body.source, result.source_revision, subject="story arc"
+ )
+ return result
+
+
+@router.post("/story-arcs/issues", response_model=MetadataFetch[MetadataPage[ProviderIssueRead]])
+async def story_arc_issues(
+ body: StoryArcIssuePageQuery, session: DbSession, _user: AuthenticatedUser, settings: Settings
+) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ runtime = await load_source_runtime(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ if (
+ next(item.policy.revision for item in runtime if item.policy.source is body.source)
+ != body.source_revision
+ ):
+ raise HTTPException(409, "Metadata source settings changed. Preview the story arc again.")
+ await session.rollback()
+ result = await MetadataSourceRegistry(
+ runtime,
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ read_cache=source_read_cache(session),
+ ).story_arc_issues(body.source, body.external_id, page=body.page)
+ await _require_source_revision(session, body.source, body.source_revision, subject="story arc")
+ return result
+
+
+@router.post("/series/preview", response_model=SeriesPreviewRead)
+async def preview_series(
+ body: SeriesAddPreviewQuery, session: DbSession, _user: AuthenticatedUser, settings: Settings
+) -> SeriesPreviewRead:
+ if body.library_root_id is not None:
+ await _preview_root(session, body.library_root_id)
+ runtime = await load_source_runtime(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ await session.rollback()
+ try:
+ result = await preview_source_series(
+ MetadataSourceRegistry(
+ runtime,
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ read_cache=source_read_cache(session),
+ ),
+ body.source,
+ body.external_id,
+ )
+ except SeriesAdoptionError as exc:
+ raise HTTPException(409, str(exc)) from exc
+ await _require_source_revision(session, body.source, result.source_revision)
+ if body.library_root_id is not None:
+ root = await _preview_root(session, body.library_root_id)
+ if result.series.data is not None:
+ try:
+ result.folder_preview = await preview_series_folder(
+ session, result.series.data, root
+ )
+ except (ConfigurationError, ValueError) as exc:
+ raise HTTPException(
+ 409, "Check this library root's naming settings, then retry the preview."
+ ) from exc
+ return result
+
+
+@router.post("/series/issues", response_model=SeriesIssuePageRead)
+async def series_issues(
+ body: SeriesIssuePageQuery, session: DbSession, _user: AuthenticatedUser, settings: Settings
+) -> SeriesIssuePageRead:
+ runtime = await load_source_runtime(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ if (
+ next(item.policy.revision for item in runtime if item.policy.source is body.source)
+ != body.source_revision
+ ):
+ raise HTTPException(409, "Metadata source settings changed. Preview the series again.")
+ await session.rollback()
+ result = await MetadataSourceRegistry(
+ runtime,
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ read_cache=source_read_cache(session),
+ ).issues(body.source, body.external_id, page=body.page)
+ await _require_source_revision(session, body.source, body.source_revision)
+ return SeriesIssuePageRead(
+ **result.model_dump(),
+ series_cover_url=representative_series_cover(
+ body.source, body.external_id, result.data.results
+ )
+ if body.page == 1 and result.status is SourceStatus.OK and result.data is not None
+ else None,
+ )
+
+
+@router.get("/sources", response_model=list[SourceDescriptor])
+async def sources(
+ session: DbSession, _user: InteractiveOperatorUser, settings: Settings
+) -> list[SourceDescriptor]:
+ return await source_status(session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled)
+
+
+@router.get("/retries", response_model=PaginatedResponse[DeferredMetadataRead])
+async def retries(
+ session: DbSession,
+ _user: InteractiveOperatorUser,
+ settings: Settings,
+ source: MetadataSource | None = None,
+ limit: int = Query(default=10, ge=1, le=100),
+ offset: int = Query(default=0, ge=0, le=1_000_000),
+) -> PaginatedResponse[DeferredMetadataRead]:
+ return await deferred_work(
+ session,
+ source=source,
+ limit=limit,
+ offset=offset,
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ )
+
+
+@router.put("/priorities", response_model=list[SourceDescriptor])
+async def save_priorities(
+ body: SourcePriorityWrite, session: DbSession, user: InteractiveOperatorUser, settings: Settings
+) -> list[SourceDescriptor]:
+ try:
+ await save_source_priorities(session, body)
+ except SourceConfigurationConflictError as exc:
+ raise HTTPException(409, str(exc)) from exc
+ logger.info("metadata_source_priorities_updated", user_id=user.id)
+ return await source_status(session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled)
+
+
+@router.put("/sources/{source}", response_model=SourcePolicyRead)
+async def save_source(
+ source: MetadataSource,
+ body: SourcePolicyWrite,
+ request: Request,
+ session: DbSession,
+ user: InteractiveOperatorUser,
+ settings: Settings,
+) -> SourcePolicyRead:
+ user_id = user.id
+ try:
+ candidate = None
+ if source is MetadataSource.GCD_LOCAL and body.enabled:
+ await _require_source_revision(session, source, body.revision)
+ await session.rollback()
+ candidate = await validate_for_request(
+ body.settings.database_path, request.is_disconnected
+ )
+ # Prove the semantic joins, not just table names, before activation.
+ await GcdLocalSource(candidate).check()
+ result = await save_source_policy(
+ session, source, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ if candidate is not None:
+ await activate_snapshot(session, candidate)
+ except SourceConfigurationConflictError as exc:
+ raise HTTPException(409, str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ except MetadataSourceError as exc:
+ raise HTTPException(
+ 400,
+ "The GCD database could not pass its catalog read check. "
+ "Validate a current official dump.",
+ ) from exc
+ logger.info(
+ "metadata_source_policy_updated",
+ source=source.value,
+ revision=result.revision,
+ enabled=result.enabled,
+ user_id=user_id,
+ )
+ return result
+
+
+@router.post("/search", response_model=SeriesDiscoveryRead)
+async def search(
+ body: SeriesDiscoveryQuery, session: DbSession, _user: AuthenticatedUser, settings: Settings
+) -> SeriesDiscoveryRead:
+ runtime = await load_source_runtime(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ await session.rollback()
+ registry = MetadataSourceRegistry(runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled)
+ return await registry.discover(body)
+
+
+@router.post("/sources/{source}/test", response_model=SourceTestRead)
+async def test_source(
+ source: MetadataSource, session: DbSession, _user: InteractiveOperatorUser, settings: Settings
+) -> SourceTestRead:
+ runtime = await load_source_runtime(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ revision = next(item.policy.revision for item in runtime if item.policy.source is source)
+ await session.rollback()
+ checked_at = datetime.now(UTC)
+ outcome = await MetadataSourceRegistry(
+ runtime, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ ).check(source, retry_authentication=True)
+ recorded = await record_source_health(session, source, revision, outcome, checked_at)
+ logger.info(
+ "metadata_source_test_complete",
+ source=source.value,
+ status=outcome.status.value,
+ recorded=recorded,
+ )
+ return SourceTestRead(outcome=outcome, recorded=recorded)
diff --git a/src/pullbox/api/v1/router.py b/src/pullbox/api/v1/router.py
index 4ec73d50..fbd13aec 100644
--- a/src/pullbox/api/v1/router.py
+++ b/src/pullbox/api/v1/router.py
@@ -22,11 +22,16 @@
from pullbox.api.v1.import_safety_bulk import router as import_safety_bulk_router
from pullbox.api.v1.indexers import router as indexers_router
from pullbox.api.v1.intervention import router as intervention_router
+from pullbox.api.v1.issue_metadata_links import router as issue_metadata_links_router
from pullbox.api.v1.issues import router as issues_router
from pullbox.api.v1.library import router as library_router
+from pullbox.api.v1.metadata_arc_catalog import router as metadata_arc_catalog_router
+from pullbox.api.v1.metadata_identities import router as metadata_identities_router
+from pullbox.api.v1.metadata_sources import router as metadata_sources_router
from pullbox.api.v1.reader import router as reader_router
from pullbox.api.v1.search import router as search_router
from pullbox.api.v1.series import router as series_router
+from pullbox.api.v1.series_metadata_links import router as series_metadata_links_router
from pullbox.api.v1.series_rescan import router as series_rescan_router
from pullbox.api.v1.story_arc_placements import router as story_arc_placements_router
from pullbox.api.v1.story_arcs import router as story_arcs_router
@@ -47,7 +52,12 @@
v1_router.include_router(story_arcs_router)
v1_router.include_router(story_arc_placements_router)
v1_router.include_router(issues_router)
+v1_router.include_router(issue_metadata_links_router)
v1_router.include_router(library_router)
+v1_router.include_router(metadata_identities_router)
+v1_router.include_router(metadata_sources_router)
+v1_router.include_router(series_metadata_links_router)
+v1_router.include_router(metadata_arc_catalog_router)
v1_router.include_router(reader_router)
v1_router.include_router(downloads_router)
v1_router.include_router(direct_providers_router)
diff --git a/src/pullbox/api/v1/series.py b/src/pullbox/api/v1/series.py
index bc23e6ef..9814ec52 100644
--- a/src/pullbox/api/v1/series.py
+++ b/src/pullbox/api/v1/series.py
@@ -11,6 +11,7 @@
from sqlalchemy.orm import contains_eager
from pullbox.api.deps import AuthenticatedUser, DbSession
+from pullbox.config import get_settings
from pullbox.core.events import IssueWanted, get_event_bus
from pullbox.core.exceptions import NotFoundError, ValidationError
from pullbox.core.library_policy import load_search_on_add_default
@@ -28,9 +29,19 @@
SeriesListResponse,
SeriesResponse,
SeriesUpdate,
+ SourceSeriesCreate,
)
from pullbox.services.cover_url_service import build_series_cover_url
+from pullbox.services.metadata_catalog_review import approve_catalog_review
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_series_add import source_series_add_transaction
+from pullbox.services.metadata_series_adoption import (
+ SeriesAdoptionError,
+ fetch_source_series_bundle,
+)
from pullbox.services.metadata_service import MetadataService
+from pullbox.services.metadata_sources import load_source_runtime
from pullbox.services.search_targets import load_series_wanted_search_targets
from pullbox.services.series_service import SeriesService
from pullbox.tasks.search_task import search_series_issues
@@ -372,15 +383,46 @@ async def get_series(
@router.post("", response_model=SeriesResponse, status_code=201)
async def add_series(
- body: SeriesCreate,
+ body: SeriesCreate | SourceSeriesCreate,
_user: AuthenticatedUser,
session: DbSession,
) -> SeriesResponse:
- """Add a series to the library from ComicVine."""
+ """Add a source-selected series, retaining the legacy ComicVine request."""
search_on_add = await load_search_on_add_default(session)
if body.search_on_add is not None and body.search_on_add != search_on_add:
raise ValidationError("Search on add is now controlled by the global import policy.")
+ if isinstance(body, SourceSeriesCreate):
+ gcd_enabled = get_settings().metadata_gcd_api_v2_enabled
+ runtime = await load_source_runtime(session, gcd_api_enabled=gcd_enabled)
+ await session.rollback()
+ try:
+ bundle = await fetch_source_series_bundle(
+ MetadataSourceRegistry(
+ runtime,
+ gcd_api_enabled=gcd_enabled,
+ read_cache=source_read_cache(session),
+ revalidate_reads=True,
+ ),
+ body.source,
+ body.external_id,
+ source_revision=body.source_revision,
+ )
+ bundle = approve_catalog_review(bundle, body.catalog_review_token)
+ # This command owns commit/cleanup; serialize before committing so
+ # a failed response cannot leave an unexpected library addition.
+ async with source_series_add_transaction(
+ session,
+ bundle,
+ library_root_id=body.library_root_id,
+ search_on_add=search_on_add,
+ event_bus=get_event_bus(),
+ ) as result:
+ response = await _load_series_response(session, result.series.id)
+ return response
+ except SeriesAdoptionError as exc:
+ raise HTTPException(409, str(exc)) from exc
+
series_svc = await _build_series_service(session)
series = await series_svc.add_from_comicvine(
@@ -566,7 +608,12 @@ async def refresh_series(
_user: AuthenticatedUser,
session: DbSession,
) -> SeriesResponse:
- """Refresh series metadata from ComicVine."""
+ """Refresh metadata and the issue catalog from configured, verified sources."""
+ from pullbox.services.metadata_series_refresh import (
+ SeriesRefreshError,
+ source_series_refresh_transaction,
+ )
+
series = await session.get(Series, series_id)
if series is None:
raise NotFoundError("Series", series_id)
@@ -575,9 +622,12 @@ async def refresh_series(
status_code=409,
detail="Initial metadata sync is already in progress.",
)
- metadata_svc = await _build_metadata_service(session)
- await metadata_svc.refresh_series(session, series_id, force=True)
- return await _load_series_response(session, series_id)
+ try:
+ async with source_series_refresh_transaction(session, series_id):
+ response = await _load_series_response(session, series_id)
+ return response
+ except SeriesRefreshError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/{series_id}/rename-folder", status_code=200)
diff --git a/src/pullbox/api/v1/series_metadata_links.py b/src/pullbox/api/v1/series_metadata_links.py
new file mode 100644
index 00000000..dab9f830
--- /dev/null
+++ b/src/pullbox/api/v1/series_metadata_links.py
@@ -0,0 +1,101 @@
+"""Operator review of additional identities for an existing series."""
+
+from typing import Annotated
+
+from fastapi import APIRouter, HTTPException, Path
+
+from pullbox.api.deps import DbSession, InteractiveOperatorUser, Settings
+from pullbox.core.exceptions import ValidationError
+from pullbox.schemas.metadata_identity_review import IdentityReviewReceiptRead
+from pullbox.schemas.metadata_sources import SeriesPreviewQuery
+from pullbox.schemas.series_metadata_links import (
+ SeriesLinkConfirm,
+ SeriesLinkPreview,
+ SeriesLinksRead,
+)
+from pullbox.schemas.series_sidecar import (
+ SeriesSidecarApproval,
+ SeriesSidecarPreview,
+ SeriesSidecarWriteRead,
+)
+from pullbox.services.series_metadata_links import (
+ confirm_series_link,
+ preview_series_link,
+ read_series_links,
+)
+from pullbox.services.series_sidecar import prepare_series_sidecar, write_series_sidecar
+
+router = APIRouter(prefix="/series", tags=["series"])
+LocalId = Annotated[int, Path(gt=0, lt=2**63)]
+
+
+@router.post("/{series_id}/sidecar/preview", response_model=SeriesSidecarPreview)
+async def sidecar_preview(
+ series_id: LocalId, session: DbSession, _user: InteractiveOperatorUser
+) -> SeriesSidecarPreview:
+ try:
+ return (await prepare_series_sidecar(session, series_id)).preview
+ except (ValueError, ValidationError):
+ raise HTTPException(
+ 409,
+ "Series metadata is not ready for file output. Finish metadata syncing "
+ "and verify its provider links, then preview again.",
+ ) from None
+
+
+@router.post("/{series_id}/sidecar/write", response_model=SeriesSidecarWriteRead)
+async def sidecar_write(
+ series_id: LocalId,
+ body: SeriesSidecarApproval,
+ session: DbSession,
+ _user: InteractiveOperatorUser,
+) -> SeriesSidecarWriteRead:
+ try:
+ return await write_series_sidecar(session, series_id, body.review_key)
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(409, str(exc)) from None
+
+
+@router.get("/{series_id}/metadata-links", response_model=SeriesLinksRead)
+async def links(
+ series_id: LocalId, session: DbSession, _user: InteractiveOperatorUser, settings: Settings
+) -> SeriesLinksRead:
+ return await read_series_links(
+ session, series_id, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+
+
+@router.post("/{series_id}/metadata-links/preview", response_model=SeriesLinkPreview)
+async def preview(
+ series_id: LocalId,
+ body: SeriesPreviewQuery,
+ session: DbSession,
+ _user: InteractiveOperatorUser,
+ settings: Settings,
+) -> SeriesLinkPreview:
+ try:
+ return await preview_series_link(
+ session, series_id, body, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(409, str(exc)) from exc
+
+
+@router.post("/{series_id}/metadata-links/confirm", response_model=IdentityReviewReceiptRead)
+async def confirm(
+ series_id: LocalId,
+ body: SeriesLinkConfirm,
+ session: DbSession,
+ user: InteractiveOperatorUser,
+ settings: Settings,
+) -> IdentityReviewReceiptRead:
+ try:
+ return await confirm_series_link(
+ session,
+ series_id,
+ body,
+ user_id=user.id,
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ )
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(409, str(exc)) from exc
diff --git a/src/pullbox/app.py b/src/pullbox/app.py
index 3f086aca..a6e3812b 100644
--- a/src/pullbox/app.py
+++ b/src/pullbox/app.py
@@ -297,6 +297,18 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.warning("library_path_reconciliation_failed", exc_info=True)
+ # Restore uncommitted removals before conversion recovery or background writers.
+ from pullbox.services.library_removal import recover_library_removals
+
+ async with get_session_factory()() as session:
+ await recover_library_removals(session)
+
+ # Retained conversion intent must be reconciled before background file writers.
+ from pullbox.services.library_conversion_recovery import recover_library_conversions
+
+ async with get_session_factory()() as session:
+ await recover_library_conversions(session)
+
# One-time migration: move covers from series folders to .covers/
from pullbox.core.cover_migration import migrate_covers_to_dotcovers
@@ -309,8 +321,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
logger.warning("cover_migration_failed", subsystem="cover_migration", exc_info=True)
# Ensure utility directories (trash and export) exist
+ from pullbox.services.library_trash_cleanup import cleanup_trash
from pullbox.utilities.settings import (
- cleanup_utility_trash_retention,
ensure_utility_directories,
resolve_utility_directory,
)
@@ -351,7 +363,8 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
)
await ensure_utility_directories(trash_dir, export_dir)
retention_days = int(util_cfg.get("utility_trash_retention_days", "30") or "30")
- cleanup_utility_trash_retention(trash_dir, retention_days)
+ async with factory() as session:
+ await cleanup_trash(session, trash_dir, retention_days=retention_days)
except Exception:
logger.warning("utility_directory_creation_failed", subsystem="utility_dirs", exc_info=True)
@@ -750,6 +763,7 @@ def _cleanup_whats_new_startup_task(task: asyncio.Task[object]) -> None:
from pullbox.utilities.executors.db_check_cleanup import DBCheckCleanupExecutor
from pullbox.utilities.executors.export_library import ExportLibraryExecutor
from pullbox.utilities.executors.file_converter import FileConverterExecutor
+ from pullbox.utilities.executors.file_metadata import FileMetadataExecutor
from pullbox.utilities.executors.integrity_checker import IntegrityCheckerExecutor
from pullbox.utilities.executors.library_permissions import LibraryPermissionsExecutor
from pullbox.utilities.executors.mass_convert_pipeline import MassConvertPipelineExecutor
@@ -761,6 +775,7 @@ def _cleanup_whats_new_startup_task(task: asyncio.Task[object]) -> None:
queue_mgr = JobQueueManager(session_factory=get_session_factory())
queue_mgr.register_executor("file_convert", FileConverterExecutor)
+ queue_mgr.register_executor("file_metadata", FileMetadataExecutor)
queue_mgr.register_executor("mass_convert_pipeline", MassConvertPipelineExecutor)
queue_mgr.register_executor("mass_rename", MassRenameExecutor)
queue_mgr.register_executor("integrity_check", IntegrityCheckerExecutor)
diff --git a/src/pullbox/config.py b/src/pullbox/config.py
index 38fc8bc8..2b967fbc 100644
--- a/src/pullbox/config.py
+++ b/src/pullbox/config.py
@@ -104,6 +104,9 @@ class PullboxSettings(BaseSettings):
# ── ComicVine ──────────────────────────────────────────────────────
comicvine_api_key: str = ""
+ metadata_gcd_api_v2_enabled: bool = False
+ # Keep paired archive publication opt-in until its import workflow is qualified.
+ metadata_paired_import_writer_enabled: bool = False
comicvine_rate_limit: int = 200 # Requests per hour
# ── Import Debug ───────────────────────────────────────────────────
diff --git a/src/pullbox/core/archive.py b/src/pullbox/core/archive.py
index 0ebc3e58..5c5c57dc 100644
--- a/src/pullbox/core/archive.py
+++ b/src/pullbox/core/archive.py
@@ -14,6 +14,7 @@
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from stat import S_ISLNK
+from typing import TYPE_CHECKING
import structlog
@@ -21,6 +22,9 @@
from pullbox.core.comicinfo import ComicInfoData, parse_comicinfo
from pullbox.core.rar_backend import RarBackendUnavailableError, configure_rarfile_backend
+if TYPE_CHECKING:
+ from pullbox.core.archive_metadata import ArchiveMetadataFiles
+
logger = structlog.get_logger(__name__)
_ARCHIVE_IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp", ".gif", ".tif", ".tiff"})
@@ -160,6 +164,16 @@ def read_comicinfo(self, *, entries: list[str] | None = None) -> ComicInfoData |
xml_bytes = self.read_file(comicinfo_name)
return parse_comicinfo(xml_bytes.decode("utf-8", errors="replace"))
+ def read_metadata_files(
+ self, *, max_solid_scan_bytes: int = 2000 * 1024 * 1024
+ ) -> ArchiveMetadataFiles:
+ """Read ComicInfo and MetronInfo together without changing legacy readers."""
+ from pullbox.core.archive_metadata import read_archive_metadata
+
+ return read_archive_metadata(
+ self._path, self.format, max_solid_scan_bytes=max_solid_scan_bytes
+ )
+
# -- CBZ (ZIP) ----------------------------------------------------------
def _list_zip(self) -> list[str]:
diff --git a/src/pullbox/core/archive_metadata.py b/src/pullbox/core/archive_metadata.py
new file mode 100644
index 00000000..96998972
--- /dev/null
+++ b/src/pullbox/core/archive_metadata.py
@@ -0,0 +1,352 @@
+"""Read bounded embedded metadata without extracting comic pages to disk."""
+
+from __future__ import annotations
+
+import enum
+import io
+import tarfile
+import threading
+import zipfile
+from dataclasses import dataclass, replace
+from pathlib import PurePosixPath
+from stat import S_IFMT, S_ISREG
+from typing import IO, TYPE_CHECKING
+
+from py7zr.io import Py7zIO, WriterFactory
+
+from pullbox.core.archive import ArchiveError, ArchiveResourceLimitError, comicinfo_member_sort_key
+from pullbox.core.file_safety import has_archive_member_path_traversal
+from pullbox.core.rar_backend import RarBackendUnavailableError
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+ from contextlib import AbstractContextManager
+ from pathlib import Path
+
+MAX_METADATA_BYTES = 2 * 1024 * 1024
+
+
+class MetadataReadDiagnostic(enum.StrEnum):
+ DUPLICATE_ENTRIES = "duplicate_entries"
+ UNSAFE_ENTRY = "unsafe_entry"
+ SIZE_LIMIT = "size_limit"
+ SOLID_SCAN_LIMIT = "solid_scan_limit"
+ UNREADABLE = "unreadable"
+
+
+@dataclass(frozen=True)
+class MetadataFile:
+ name: str
+ entry_count: int = 0
+ payload: bytes | None = None
+ diagnostics: tuple[MetadataReadDiagnostic, ...] = ()
+
+
+@dataclass(frozen=True)
+class ArchiveMetadataFiles:
+ comicinfo: MetadataFile
+ metroninfo: MetadataFile
+
+
+def read_archive_metadata(
+ path: Path, archive_type: str, *, max_solid_scan_bytes: int
+) -> ArchiveMetadataFiles:
+ """Read both XML members in one archive session; this is not a safety approval."""
+ if isinstance(max_solid_scan_bytes, bool) or max_solid_scan_bytes <= 0:
+ raise ValueError("Solid metadata scan budget must be positive")
+ try:
+ if archive_type == "cbz":
+ with zipfile.ZipFile(path, "r") as archive:
+ return read_open_zip_metadata(archive)
+ if archive_type == "cbt":
+ with tarfile.open(path, "r:*") as tar:
+ candidates = [
+ _Candidate(member, member.name, member.size, None, member.isfile())
+ for member in tar.getmembers()
+ ]
+
+ def open_tar(member: tarfile.TarInfo) -> IO[bytes]:
+ stream = tar.extractfile(member)
+ if stream is None:
+ raise ArchiveError("Metadata member is unavailable")
+ return stream
+
+ return _read_members(candidates, open_tar, (tarfile.TarError,))
+ if archive_type == "cbr":
+ return _read_rar(path, max_solid_scan_bytes)
+ if archive_type == "cb7":
+ return _read_7z(path, max_solid_scan_bytes)
+ except (
+ OSError,
+ EOFError,
+ RuntimeError,
+ ValueError,
+ zipfile.BadZipFile,
+ tarfile.TarError,
+ ) as exc:
+ raise ArchiveError("Archive metadata could not be inspected") from exc
+ raise ArchiveError("Unsupported archive format for embedded metadata")
+
+
+@dataclass(frozen=True)
+class _Candidate[T]:
+ member: T
+ name: str
+ size: int
+ compressed_size: int | None
+ regular: bool
+
+
+def _select[T](
+ candidates: Sequence[_Candidate[T]], name: str
+) -> tuple[MetadataFile, _Candidate[T] | None]:
+ matches = sorted(
+ (
+ candidate
+ for candidate in candidates
+ if PurePosixPath(candidate.name.replace("\\", "/")).name.casefold() == name.casefold()
+ ),
+ key=lambda candidate: comicinfo_member_sort_key(candidate.name),
+ )
+ diagnostics = []
+ if len(matches) > 1:
+ diagnostics.append(MetadataReadDiagnostic.DUPLICATE_ENTRIES)
+ result = MetadataFile(name, len(matches), diagnostics=tuple(diagnostics))
+ if not matches:
+ return result, None
+ chosen = matches[0]
+ if (
+ not chosen.regular
+ or has_archive_member_path_traversal(chosen.name)
+ or "\x00" in chosen.name
+ ):
+ return _with_error(result, MetadataReadDiagnostic.UNSAFE_ENTRY), None
+ if (
+ chosen.size < 0
+ or chosen.size > MAX_METADATA_BYTES
+ or (
+ chosen.compressed_size is not None
+ and (chosen.compressed_size < 0 or chosen.compressed_size > MAX_METADATA_BYTES)
+ )
+ ):
+ return _with_error(result, MetadataReadDiagnostic.SIZE_LIMIT), None
+ return result, chosen
+
+
+def _with_error(result: MetadataFile, diagnostic: MetadataReadDiagnostic) -> MetadataFile:
+ return replace(result, payload=None, diagnostics=(*result.diagnostics, diagnostic))
+
+
+def _read_members[T](
+ candidates: Sequence[_Candidate[T]],
+ open_member: Callable[[T], AbstractContextManager[IO[bytes]]],
+ errors: tuple[type[Exception], ...] = (),
+ *,
+ solid_blocked: bool = False,
+) -> ArchiveMetadataFiles:
+ results = []
+ read_errors: tuple[type[Exception], ...] = (
+ OSError,
+ EOFError,
+ RuntimeError,
+ ValueError,
+ ArchiveError,
+ *errors,
+ )
+ for name in ("ComicInfo.xml", "MetronInfo.xml"):
+ result, chosen = _select(candidates, name)
+ if chosen is not None:
+ if solid_blocked:
+ result = _with_error(result, MetadataReadDiagnostic.SOLID_SCAN_LIMIT)
+ else:
+ try:
+ with open_member(chosen.member) as stream:
+ payload = stream.read(MAX_METADATA_BYTES + 1)
+ if len(payload) > MAX_METADATA_BYTES:
+ result = _with_error(result, MetadataReadDiagnostic.SIZE_LIMIT)
+ elif len(payload) != chosen.size:
+ result = _with_error(result, MetadataReadDiagnostic.UNREADABLE)
+ else:
+ result = replace(result, payload=payload)
+ except read_errors:
+ result = _with_error(result, MetadataReadDiagnostic.UNREADABLE)
+ results.append(result)
+ return ArchiveMetadataFiles(*results)
+
+
+def read_open_zip_metadata(archive: zipfile.ZipFile) -> ArchiveMetadataFiles:
+ """Reuse an already-open ZIP inspection without reopening or extracting it."""
+ return read_open_metadata_members(archive.infolist(), lambda member: archive.open(member, "r"))
+
+
+def read_open_metadata_members(
+ members: Sequence[zipfile.ZipInfo],
+ open_member: Callable[[zipfile.ZipInfo], AbstractContextManager[IO[bytes]]],
+) -> ArchiveMetadataFiles:
+ """Read both documents from validated, normalized archive member descriptors."""
+ candidates = [
+ _Candidate(
+ member,
+ member.filename,
+ member.file_size,
+ member.compress_size,
+ not member.is_dir()
+ and (S_IFMT(member.external_attr >> 16) == 0 or S_ISREG(member.external_attr >> 16)),
+ )
+ for member in members
+ ]
+ return _read_members(candidates, open_member, (zipfile.BadZipFile,))
+
+
+def _read_rar(path: Path, max_solid_scan_bytes: int) -> ArchiveMetadataFiles:
+ import rarfile # type: ignore[import-untyped]
+
+ from pullbox.core.rar_backend import configure_rarfile_backend
+
+ try:
+ configure_rarfile_backend()
+ with rarfile.RarFile(path, "r") as archive:
+ candidates = [
+ _Candidate(
+ member,
+ str(member.filename),
+ int(member.file_size),
+ int(member.compress_size),
+ bool(member.is_file()) and not bool(member.is_symlink()),
+ )
+ for member in archive.infolist()
+ ]
+ return _read_members(
+ candidates,
+ lambda member: archive.open(member, "r"),
+ (rarfile.Error,),
+ solid_blocked=bool(archive.is_solid())
+ and sum(member.size for member in candidates) > max_solid_scan_bytes,
+ )
+ except (rarfile.Error, RarBackendUnavailableError) as exc:
+ raise ArchiveError("RAR metadata could not be inspected") from exc
+
+
+class _BoundedBuffer(Py7zIO):
+ def __init__(self) -> None:
+ self._buffer = io.BytesIO()
+ self._size = 0
+ self._lock = threading.Lock()
+
+ def write(self, data: bytes | bytearray) -> int:
+ with self._lock:
+ end = self._buffer.tell() + len(data)
+ if end > MAX_METADATA_BYTES:
+ raise ArchiveResourceLimitError("Embedded metadata size limit exceeded")
+ written = self._buffer.write(data)
+ self._size = max(self._size, end)
+ return written
+
+ def read(self, size: int | None = None) -> bytes:
+ with self._lock:
+ return self._buffer.read(size)
+
+ def seek(self, offset: int, whence: int = 0) -> int:
+ with self._lock:
+ base = {0: 0, 1: self._buffer.tell(), 2: self._size}.get(whence)
+ if base is None or not 0 <= base + offset <= MAX_METADATA_BYTES:
+ raise ArchiveResourceLimitError("Embedded metadata seek limit exceeded")
+ return self._buffer.seek(offset, whence)
+
+ def flush(self) -> None:
+ return None
+
+ def size(self) -> int:
+ with self._lock:
+ return self._size
+
+
+class _MetadataFactory(WriterFactory):
+ def __init__(self, names: Sequence[str]) -> None:
+ self.products = {name: _BoundedBuffer() for name in names}
+
+ def create(self, filename: str) -> Py7zIO:
+ product = self.products.get(filename)
+ if product is None:
+ raise ArchiveError("Unexpected metadata extraction target")
+ return product
+
+
+def _read_7z(path: Path, max_solid_scan_bytes: int) -> ArchiveMetadataFiles:
+ import py7zr
+ from py7zr.exceptions import ArchiveError as SevenZipError
+ from py7zr.exceptions import PasswordRequired
+
+ try:
+ with py7zr.SevenZipFile(path, "r", max_extract_size=max_solid_scan_bytes) as archive:
+ candidates = [
+ _Candidate(
+ member.filename,
+ member.filename,
+ int(member.uncompressed or 0),
+ # Solid 7z reports the packed block, not a member's compressed size.
+ None,
+ bool(member.is_file) and not bool(member.is_symlink),
+ )
+ for member in archive.list()
+ ]
+ plans = [_select(candidates, name) for name in ("ComicInfo.xml", "MetronInfo.xml")]
+ solid_blocked = (
+ archive.archiveinfo().solid
+ and sum(member.size for member in candidates) > max_solid_scan_bytes
+ )
+ targets = []
+ results = []
+ for result, chosen in plans:
+ if chosen is not None:
+ if solid_blocked:
+ result = _with_error(result, MetadataReadDiagnostic.SOLID_SCAN_LIMIT)
+ elif sum(member.name == chosen.name for member in candidates) > 1:
+ # py7zr selects by name, so it cannot address one exact duplicate safely.
+ result = _with_error(result, MetadataReadDiagnostic.UNREADABLE)
+ else:
+ targets.append(chosen)
+ results.append(result)
+ if not targets:
+ return ArchiveMetadataFiles(*results)
+ factory = _MetadataFactory([member.name for member in targets])
+ try:
+ archive.extract(
+ targets=[member.name for member in targets], recursive=False, factory=factory
+ )
+ for index, (_result, chosen) in enumerate(plans):
+ if chosen not in targets or chosen is None:
+ continue
+ buffer = factory.products[chosen.name]
+ if buffer.size() != chosen.size:
+ results[index] = _with_error(
+ results[index], MetadataReadDiagnostic.UNREADABLE
+ )
+ else:
+ buffer.seek(0)
+ results[index] = replace(results[index], payload=buffer.read())
+ except ArchiveResourceLimitError:
+ results = [
+ _with_error(result, MetadataReadDiagnostic.SIZE_LIMIT)
+ if chosen in targets
+ else result
+ for result, (_old, chosen) in zip(results, plans, strict=True)
+ ]
+ except (
+ SevenZipError,
+ PasswordRequired,
+ ArchiveError,
+ OSError,
+ EOFError,
+ RuntimeError,
+ ValueError,
+ ):
+ results = [
+ _with_error(result, MetadataReadDiagnostic.UNREADABLE)
+ if chosen in targets
+ else result
+ for result, (_old, chosen) in zip(results, plans, strict=True)
+ ]
+ return ArchiveMetadataFiles(*results)
+ except (SevenZipError, PasswordRequired) as exc:
+ raise ArchiveError("7z metadata could not be inspected") from exc
diff --git a/src/pullbox/core/archive_metadata_fields.py b/src/pullbox/core/archive_metadata_fields.py
new file mode 100644
index 00000000..617925d1
--- /dev/null
+++ b/src/pullbox/core/archive_metadata_fields.py
@@ -0,0 +1,51 @@
+"""The credit-role overlap between ComicInfo and richer canonical metadata."""
+
+from pullbox.schemas.metadata_credits import MetadataCredit, parse_credits
+
+COMICINFO_ROLES = {
+ "Writer": "writer",
+ "Penciller": "penciller",
+ "Inker": "inker",
+ "Colorist": "colorist",
+ "Letterer": "letterer",
+ "CoverArtist": "cover",
+ "Editor": "editor",
+ "Translator": "translator",
+}
+
+ARCHIVE_FORMATS = {
+ "standard": "Single Issue",
+ "tpb": "Trade Paperback",
+ "one_shot": "One-Shot",
+ "annual": "Annual",
+ "hardcover": "Hardcover",
+ "omnibus": "Omnibus",
+ "graphic_novel": "Graphic Novel",
+}
+COMICINFO_ONLY_FORMATS = frozenset({"special", "compendium", "deluxe", "volume"})
+
+
+def archive_format_label(value: str | None) -> str | None:
+ if value in COMICINFO_ONLY_FORMATS:
+ return value.title()
+ return ARCHIVE_FORMATS.get(value, value) if value is not None else None
+
+
+def canonical_archive_format(value: str) -> str:
+ """Normalize only the exact reversible labels our writer emits."""
+ for canonical in (*ARCHIVE_FORMATS, *COMICINFO_ONLY_FORMATS):
+ if archive_format_label(canonical) == value:
+ return canonical
+ return value
+
+
+def comicinfo_credits(credits: tuple[MetadataCredit, ...]) -> tuple[MetadataCredit, ...]:
+ """Project supported roles, without losing rich roles in the canonical record."""
+ supported = set(COMICINFO_ROLES.values())
+ return parse_credits(
+ tuple(
+ {"name": credit.name, "role": ", ".join(roles)}
+ for credit in credits
+ if (roles := [role for role in credit.role.split(", ") if role in supported])
+ )
+ )
diff --git a/src/pullbox/core/comicvine_arc_identity.py b/src/pullbox/core/comicvine_arc_identity.py
new file mode 100644
index 00000000..6f32092f
--- /dev/null
+++ b/src/pullbox/core/comicvine_arc_identity.py
@@ -0,0 +1,17 @@
+"""ComicVine Story Arc identifiers at legacy import boundaries."""
+
+from __future__ import annotations
+
+
+def normalize_comicvine_arc_id(value: object) -> str:
+ """Normalize an exact numeric ID or typed 4045 resource ID, never a URL."""
+ if type(value) is int:
+ if 0 < value < 2**63:
+ return str(value)
+ elif isinstance(value, str) and len(value) <= 255:
+ text = value.strip(" ").removeprefix("4045-")
+ if text.isascii() and text.isdecimal():
+ normalized = text.lstrip("0")
+ if normalized and int(normalized) < 2**63:
+ return normalized
+ raise ValueError("Invalid ComicVine Story Arc ID")
diff --git a/src/pullbox/core/events.py b/src/pullbox/core/events.py
index ca561844..1baf9bcc 100644
--- a/src/pullbox/core/events.py
+++ b/src/pullbox/core/events.py
@@ -76,7 +76,7 @@ class SeriesAdded:
"""Emitted when a new series is added to the library."""
series_id: int
- comicvine_id: int
+ comicvine_id: int | None = None
@dataclass(frozen=True)
diff --git a/src/pullbox/core/file_publication.py b/src/pullbox/core/file_publication.py
index 9ec56132..37df96eb 100644
--- a/src/pullbox/core/file_publication.py
+++ b/src/pullbox/core/file_publication.py
@@ -16,6 +16,15 @@
_RENAME_EXCL = 4
+def rename_path_without_overwrite(source: Path, target: Path) -> None:
+ """Atomically claim a rename destination, including for directories."""
+ if not stat.S_ISDIR(source.lstat().st_mode):
+ publish_file_without_overwrite(source, target)
+ elif not _native_rename_without_overwrite(source, target):
+ # Directories cannot use the hard-link fallback; plain rename can overwrite.
+ raise OSError(errno.ENOTSUP, "Storage does not support exclusive directory rename")
+
+
def publish_file_without_overwrite(stage: Path, target: Path) -> None:
"""Consume a stage using an atomic destination claim, never plain POSIX rename.
diff --git a/src/pullbox/core/metadata_archive_source.py b/src/pullbox/core/metadata_archive_source.py
new file mode 100644
index 00000000..04b2be05
--- /dev/null
+++ b/src/pullbox/core/metadata_archive_source.py
@@ -0,0 +1,297 @@
+"""Bounded archive inputs for single-construction paired CBZ conversion."""
+
+import io
+import os
+import stat
+import tarfile
+import tempfile
+import threading
+import zipfile
+from collections.abc import Callable, Iterator
+from contextlib import AbstractContextManager, ExitStack, contextmanager
+from dataclasses import dataclass
+from pathlib import Path
+from typing import IO, TYPE_CHECKING, BinaryIO
+
+from py7zr.io import Py7zIO, WriterFactory
+
+from pullbox.core.file_safety import FileSafetyError
+
+if TYPE_CHECKING:
+ from pullbox.core.metadata_pdf_source import PdfQuality
+
+
+@dataclass
+class MetadataArchiveSource:
+ entries: list[zipfile.ZipInfo]
+ open: Callable[[zipfile.ZipInfo], AbstractContextManager[IO[bytes]]]
+ close: Callable[[], None]
+ comment: bytes = b""
+
+
+def _entry(name: str, size: int, directory: bool = False) -> zipfile.ZipInfo:
+ if "\x00" in name:
+ raise FileSafetyError("Archive contains an unsafe member name")
+ entry = zipfile.ZipInfo(name + ("/" if directory and not name.endswith("/") else ""))
+ entry.file_size = size
+ entry.compress_size = 0
+ entry.compress_type = zipfile.ZIP_DEFLATED
+ entry.external_attr = ((stat.S_IFDIR | 0o755) if directory else (stat.S_IFREG | 0o644)) << 16
+ return entry
+
+
+@contextmanager
+def open_metadata_archive(
+ stream: BinaryIO,
+ path: Path,
+ *,
+ limit: int,
+ scratch_parent: Path,
+ validate: Callable[[list[zipfile.ZipInfo]], None],
+ check_cancelled: Callable[[], None],
+ progress: Callable[[int, int], None],
+ pdf_quality: "PdfQuality" = "medium",
+ pdf_progress: Callable[[int, int], None] | None = None,
+) -> Iterator[MetadataArchiveSource]:
+ """Never extract archive-supplied paths; solid 7z uses private numbered spools.
+
+ The caller owns the open source descriptor and verifies its identity again
+ before publication. No intermediate CBZ is constructed for non-ZIP input.
+ """
+ header = stream.read(512)
+ stream.seek(0)
+ with ExitStack() as stack:
+ if header.startswith((b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")):
+ archive = stack.enter_context(zipfile.ZipFile(stream))
+ result = MetadataArchiveSource(
+ archive.infolist(), lambda entry: archive.open(entry), stack.close, archive.comment
+ )
+ elif header.startswith(b"Rar!\x1a\x07"):
+ result = _open_rar(stream, stack)
+ elif header.startswith(b"7z\xbc\xaf\x27\x1c"):
+ result = _open_7z(
+ stream, stack, limit, scratch_parent, validate, check_cancelled, progress
+ )
+ elif header.startswith(b"%PDF-"):
+ from pullbox.core.metadata_pdf_source import open_pdf_pages
+
+ result = open_pdf_pages(
+ stream,
+ stack,
+ limit=limit,
+ scratch_parent=scratch_parent,
+ quality=pdf_quality,
+ check_cancelled=check_cancelled,
+ progress=pdf_progress or progress,
+ )
+ elif header[257:262] == b"ustar" or path.suffix.casefold() in {".cbt", ".tar"}:
+ result = _open_tar(stream, stack, limit, check_cancelled)
+ else:
+ raise FileSafetyError("Unsupported or unreadable source archive for paired metadata")
+ validate(result.entries)
+ check_cancelled()
+ yield result
+
+
+def _open_tar(
+ stream: BinaryIO, stack: ExitStack, limit: int, check_cancelled: Callable[[], None]
+) -> MetadataArchiveSource:
+ try:
+ archive = stack.enter_context(tarfile.open(fileobj=stream, mode="r:*")) # noqa: SIM115
+ members = []
+ total = 0
+ for member in archive:
+ check_cancelled()
+ if not (member.isfile() or member.isdir()):
+ raise FileSafetyError("Archive contains an unsafe or unsupported member")
+ total += member.size
+ if member.size < 0 or total > limit:
+ raise FileSafetyError("Archive decompressed size exceeds limit")
+ members.append(member)
+ entries = [_entry(member.name, member.size, member.isdir()) for member in members]
+ original = dict(zip(entries, members, strict=True))
+
+ @contextmanager
+ def read(entry: zipfile.ZipInfo) -> Iterator[IO[bytes]]:
+ if entry.is_dir():
+ yield io.BytesIO()
+ return
+ try:
+ opened = archive.extractfile(original[entry])
+ if opened is None:
+ raise FileSafetyError("Archive member could not be read")
+ with opened:
+ yield opened
+ except tarfile.TarError as exc:
+ raise FileSafetyError("Source TAR archive member could not be read") from exc
+
+ return MetadataArchiveSource(entries, read, stack.close)
+ except tarfile.TarError as exc:
+ raise FileSafetyError("Source TAR archive could not be inspected") from exc
+
+
+def _open_rar(stream: BinaryIO, stack: ExitStack) -> MetadataArchiveSource:
+ import rarfile # type: ignore[import-untyped]
+
+ from pullbox.core.rar_backend import configure_rarfile_backend
+
+ configure_rarfile_backend()
+ try:
+ archive = stack.enter_context(rarfile.RarFile(stream, errors="strict"))
+ members = archive.infolist()
+ if archive.needs_password() or any(
+ member.is_symlink()
+ or getattr(member, "file_redir", None) is not None
+ or not (member.is_file() or member.is_dir())
+ or (
+ member.host_os == rarfile.RAR_OS_UNIX
+ and stat.S_IFMT(member.mode) not in {0, stat.S_IFREG, stat.S_IFDIR}
+ )
+ or (member.flags & (rarfile.RAR_FILE_SPLIT_BEFORE | rarfile.RAR_FILE_SPLIT_AFTER))
+ for member in members
+ ):
+ raise FileSafetyError("Archive contains an unsafe or unsupported member")
+ entries = [
+ _entry(str(member.filename), int(member.file_size), bool(member.is_dir()))
+ for member in members
+ ]
+ original = dict(zip(entries, members, strict=True))
+
+ @contextmanager
+ def read(entry: zipfile.ZipInfo) -> Iterator[IO[bytes]]:
+ if entry.is_dir():
+ yield io.BytesIO()
+ return
+ try:
+ opened: IO[bytes] = archive.open(original[entry])
+ with opened:
+ yield opened
+ except rarfile.Error as exc:
+ raise FileSafetyError("Source RAR archive member could not be read") from exc
+
+ comment = archive.comment
+ return MetadataArchiveSource(
+ entries, read, stack.close, comment.encode("utf-8") if comment else b""
+ )
+ except rarfile.Error as exc:
+ raise FileSafetyError("Source RAR archive could not be inspected") from exc
+
+
+class _DiskMember(Py7zIO):
+ def __init__(
+ self, path: Path, expected_size: int, stack: ExitStack, advanced: Callable[[int], None]
+ ) -> None:
+ self.expected_size = expected_size
+ fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
+ self.stream = stack.enter_context(os.fdopen(fd, "w+b"))
+ self.written = 0
+ self.advanced = advanced
+
+ def write(self, data: bytes | bytearray) -> int:
+ if self.stream.tell() != self.written or self.written + len(data) > self.expected_size:
+ raise FileSafetyError("Archive expanded beyond its declared size")
+ count = self.stream.write(data)
+ self.written += count
+ self.advanced(count)
+ return count
+
+ def read(self, size: int | None = None) -> bytes:
+ return self.stream.read(size if size is not None else -1)
+
+ def seek(self, offset: int, whence: int = 0) -> int:
+ return self.stream.seek(offset, whence)
+
+ def flush(self) -> None:
+ self.stream.flush()
+
+ def size(self) -> int:
+ return self.written
+
+ def close(self) -> None:
+ self.stream.close()
+
+
+class _DiskFactory(WriterFactory):
+ def __init__(
+ self,
+ directory: Path,
+ entries: list[zipfile.ZipInfo],
+ stack: ExitStack,
+ advanced: Callable[[int], None],
+ ) -> None:
+ self.paths = {entry.filename: directory / str(index) for index, entry in enumerate(entries)}
+ self.entries = {entry.filename: entry for entry in entries}
+ self.products: dict[str, _DiskMember] = {}
+ self.stack = stack
+ self.advanced = advanced
+
+ def create(self, filename: str) -> Py7zIO:
+ entry = self.entries.get(filename)
+ if entry is None or entry.is_dir() or filename in self.products:
+ raise FileSafetyError("Unexpected archive extraction target")
+ product = _DiskMember(self.paths[filename], entry.file_size, self.stack, self.advanced)
+ self.products[filename] = product
+ return product
+
+
+def _open_7z(
+ stream: BinaryIO,
+ stack: ExitStack,
+ limit: int,
+ scratch_parent: Path,
+ validate: Callable[[list[zipfile.ZipInfo]], None],
+ check_cancelled: Callable[[], None],
+ progress: Callable[[int, int], None],
+) -> MetadataArchiveSource:
+ import py7zr
+ from py7zr.exceptions import ArchiveError, PasswordRequired
+
+ try:
+ archive = stack.enter_context(py7zr.SevenZipFile(stream, "r", max_extract_size=limit))
+ members = archive.list()
+ if archive.needs_password() or any(
+ member.is_symlink or not (member.is_file or member.is_directory) for member in members
+ ):
+ raise FileSafetyError("Archive contains an unsafe or unsupported member")
+ entries = [
+ _entry(member.filename, member.uncompressed, member.is_directory) for member in members
+ ]
+ validate(entries)
+ check_cancelled()
+ directory = Path(
+ stack.enter_context(
+ tempfile.TemporaryDirectory(prefix=".pullbox-7z-", dir=scratch_parent)
+ )
+ )
+ total = sum(entry.file_size for entry in entries)
+ current = 0
+ lock = threading.Lock()
+
+ def advanced(count: int) -> None:
+ nonlocal current
+ with lock:
+ check_cancelled()
+ current += count
+ progress(current, total)
+
+ factory = _DiskFactory(directory, entries, stack, advanced)
+ progress(0, total)
+ archive.extract(
+ targets=[entry.filename for entry in entries if not entry.is_dir()],
+ recursive=False,
+ factory=factory,
+ )
+ for entry in entries:
+ if entry.is_dir():
+ continue
+ product = factory.products.get(entry.filename)
+ if product is None or product.size() != entry.file_size:
+ raise FileSafetyError("Archive payload size disagrees with its directory")
+ product.close()
+
+ def read(entry: zipfile.ZipInfo) -> IO[bytes]:
+ return io.BytesIO() if entry.is_dir() else factory.paths[entry.filename].open("rb")
+
+ return MetadataArchiveSource(entries, read, stack.close)
+ except (ArchiveError, PasswordRequired) as exc:
+ raise FileSafetyError("Source 7z archive could not be inspected") from exc
diff --git a/src/pullbox/core/metadata_identity.py b/src/pullbox/core/metadata_identity.py
new file mode 100644
index 00000000..c90f4b62
--- /dev/null
+++ b/src/pullbox/core/metadata_identity.py
@@ -0,0 +1,148 @@
+"""Provider identity primitives; no provider execution or persistence policy."""
+
+from __future__ import annotations
+
+import enum
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+
+class IdentityNamespace(enum.StrEnum):
+ """Provider namespaces, independent of transport and configuration."""
+
+ COMICVINE = "comicvine"
+ METRON = "metron"
+ GCD = "gcd"
+ LOCG = "locg"
+
+
+class MetadataSource(enum.StrEnum):
+ """Known source names, not a registry of enabled providers."""
+
+ COMICVINE_LOCAL = "comicvine_local"
+ COMICVINE_API = "comicvine_api"
+ METRON_API = "metron_api"
+ GCD_LOCAL = "gcd_local"
+ GCD_API_V2 = "gcd_api_v2"
+
+ @property
+ def identity_namespace(self) -> IdentityNamespace:
+ return _SOURCE_NAMESPACES[self]
+
+
+_SOURCE_NAMESPACES = {
+ MetadataSource.COMICVINE_LOCAL: IdentityNamespace.COMICVINE,
+ MetadataSource.COMICVINE_API: IdentityNamespace.COMICVINE,
+ MetadataSource.METRON_API: IdentityNamespace.METRON,
+ MetadataSource.GCD_LOCAL: IdentityNamespace.GCD,
+ MetadataSource.GCD_API_V2: IdentityNamespace.GCD,
+}
+
+
+class MetadataEntityKind(enum.StrEnum):
+ """Kinds of canonical metadata entities, not release variants."""
+
+ SERIES = "series"
+ ISSUE = "issue"
+ STORY_ARC = "story_arc"
+
+
+class IdentityEvidenceKind(enum.StrEnum):
+ """Origin of an identity assertion; origin alone does not establish trust."""
+
+ LEGACY_BACKFILL = "legacy_backfill"
+ MYLAR_DATABASE = "mylar_database"
+ SERIES_JSON = "series_json"
+ COMICINFO_XML = "comicinfo_xml"
+ METRONINFO_XML = "metroninfo_xml"
+ PROVIDER_RESULT = "provider_result"
+ PROVIDER_CROSSWALK = "provider_crosswalk"
+ USER_SELECTION = "user_selection"
+ MIGRATION = "migration"
+
+
+@dataclass(frozen=True)
+class ExternalIdentityRef:
+ """Canonical numeric provider key, not an issue designation or release ID.
+
+ These keys do not establish cross-provider sameness or verification. Adapters
+ must validate provider URLs and extract their IDs before constructing a key.
+ LOCG release/variant IDs belong to discovery references, not canonical issues.
+ """
+
+ namespace: IdentityNamespace
+ entity_kind: MetadataEntityKind
+ external_id: str
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.namespace, IdentityNamespace):
+ raise ValueError("Unknown identity namespace")
+ if not isinstance(self.entity_kind, MetadataEntityKind):
+ raise ValueError("Unknown metadata entity kind")
+ if not isinstance(self.external_id, str) or len(self.external_id) > 255:
+ raise ValueError("Invalid external ID: expected bounded decimal text")
+ value = self.external_id.strip(" ")
+ if not value.isascii() or not value.isdecimal() or not value.lstrip("0"):
+ raise ValueError("Invalid external ID: expected a positive decimal ID")
+ object.__setattr__(self, "external_id", value.lstrip("0"))
+
+
+@dataclass(frozen=True)
+class ExactIdentityEvidence:
+ """An exact-ID assertion, not proof of trust or authorization to attach it."""
+
+ identity: ExternalIdentityRef
+ evidence_kind: IdentityEvidenceKind
+ source_instance: MetadataSource | None = None
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.evidence_kind, IdentityEvidenceKind):
+ raise ValueError("Unknown identity evidence kind")
+ if self.source_instance is not None and not isinstance(
+ self.source_instance, MetadataSource
+ ):
+ raise ValueError("Unknown metadata source instance")
+
+
+@dataclass(frozen=True)
+class ExactIdentityConflict:
+ """Competing IDs for one proposed target, with their evidence retained."""
+
+ namespace: IdentityNamespace
+ entity_kind: MetadataEntityKind
+ evidence: tuple[ExactIdentityEvidence, ...]
+
+
+def find_exact_identity_conflicts(
+ evidence: Iterable[ExactIdentityEvidence],
+) -> tuple[ExactIdentityConflict, ...]:
+ """Find same-provider disagreement for one target; never select a winner.
+
+ Call once per proposed local target, not across a scan or search result list.
+ No conflicts does not mean the assertions are verified, linked, unowned, or
+ safe to attach. Persistence and crosswalk resolution enforce those separately.
+ """
+ grouped: dict[tuple[IdentityNamespace, MetadataEntityKind], set[ExactIdentityEvidence]] = (
+ defaultdict(set)
+ )
+ for observation in evidence:
+ identity = observation.identity
+ grouped[(identity.namespace, identity.entity_kind)].add(observation)
+
+ return tuple(
+ ExactIdentityConflict(namespace, kind, tuple(sorted(observations, key=_evidence_sort_key)))
+ for (namespace, kind), observations in sorted(grouped.items())
+ if len({item.identity.external_id for item in observations}) > 1
+ )
+
+
+def _evidence_sort_key(evidence: ExactIdentityEvidence) -> tuple[str, str, str]:
+ return (
+ evidence.identity.external_id,
+ evidence.evidence_kind.value,
+ evidence.source_instance.value if evidence.source_instance is not None else "",
+ )
diff --git a/src/pullbox/core/metadata_identity_events.py b/src/pullbox/core/metadata_identity_events.py
new file mode 100644
index 00000000..2739b73b
--- /dev/null
+++ b/src/pullbox/core/metadata_identity_events.py
@@ -0,0 +1,253 @@
+"""Validated identity evidence and semantic retry contracts, not authorization."""
+
+from __future__ import annotations
+
+import enum
+import hashlib
+import json
+from dataclasses import dataclass
+from uuid import UUID
+
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_identity_state import IdentityVerificationAction
+
+
+class IdentityEvidenceRecordKind(enum.StrEnum):
+ SERIES = "series"
+ ISSUE = "issue"
+ STORY_ARC = "story_arc"
+ STORY_ARC_IDENTITY = "story_arc_identity"
+ SERIES_IDENTITY_EVENT = "series_identity_event"
+ ISSUE_IDENTITY_EVENT = "issue_identity_event"
+ STORY_ARC_IDENTITY_EVENT = "story_arc_identity_event"
+ IMPORTED_SERIES = "imported_series"
+ IMPORTED_FILE = "imported_file"
+ IMPORTED_STORY_ARC = "imported_story_arc"
+ IMPORT_JOB_ACTION = "import_job_action"
+ LIBRARY_FILE = "library_file"
+
+
+class IdentityEventActor(enum.StrEnum):
+ AUTOMATION = "automation"
+ USER = "user"
+ MIGRATION = "migration"
+
+
+@dataclass(frozen=True)
+class IdentityEvidenceLocator:
+ """Safe local record locator, not a path or evidence payload."""
+
+ record_kind: IdentityEvidenceRecordKind
+ record_id: int
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.record_kind, IdentityEvidenceRecordKind) or not _positive_id(
+ self.record_id
+ ):
+ raise ValueError("Invalid identity evidence record locator")
+
+
+@dataclass(frozen=True)
+class IdentityEventEvidence:
+ """One revision of local or provider evidence; it does not establish trust."""
+
+ claim: ExactIdentityEvidence
+ revision: str
+ locator: IdentityEvidenceLocator | None = None
+ source_identity: ExternalIdentityRef | None = None
+ parent_identity: ExternalIdentityRef | None = None
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.claim, ExactIdentityEvidence) or not isinstance(
+ self.claim.identity, ExternalIdentityRef
+ ):
+ raise ValueError("Invalid identity evidence claim")
+ if not _is_digest(self.revision):
+ raise ValueError("Invalid identity evidence revision digest")
+ if self.parent_identity is not None and (
+ not isinstance(self.parent_identity, ExternalIdentityRef)
+ or self.claim.identity.entity_kind is not MetadataEntityKind.ISSUE
+ or self.parent_identity.entity_kind is not MetadataEntityKind.SERIES
+ or self.parent_identity.namespace != self.claim.identity.namespace
+ ):
+ raise ValueError("Invalid issue parent identity evidence")
+ provider_kinds = {
+ IdentityEvidenceKind.PROVIDER_RESULT,
+ IdentityEvidenceKind.PROVIDER_CROSSWALK,
+ }
+ source = self.claim.source_instance
+ if source is None:
+ if (
+ not isinstance(self.locator, IdentityEvidenceLocator)
+ or self.source_identity is not None
+ or self.claim.evidence_kind in provider_kinds
+ ):
+ raise ValueError("Invalid local identity evidence provenance")
+ return
+ if (
+ self.locator is not None
+ or not isinstance(self.source_identity, ExternalIdentityRef)
+ or self.source_identity.namespace != source.identity_namespace
+ or self.source_identity.entity_kind != self.claim.identity.entity_kind
+ or self.claim.evidence_kind
+ not in provider_kinds | {IdentityEvidenceKind.USER_SELECTION}
+ ):
+ raise ValueError("Invalid provider identity evidence provenance")
+ if self.claim.evidence_kind is IdentityEvidenceKind.PROVIDER_CROSSWALK:
+ if self.claim.identity.namespace == self.source_identity.namespace:
+ raise ValueError("A crosswalk must identify another provider namespace")
+ elif self.source_identity != self.claim.identity:
+ raise ValueError("Provider result identity disagrees with its source record")
+
+
+@dataclass(frozen=True)
+class IdentityEventRequest:
+ """Server-assembled logical action; typed actors/UUIDs do not authorize it.
+
+ Keep operation_id durable across retries. A new observation run or explicit
+ decision receives a new server-generated operation ID. Source adapters bind
+ revision to the evidence snapshot. Auth, review freshness, exact agreement,
+ and ownership checks still belong to the application transaction.
+ """
+
+ operation_id: UUID
+ local_id: int
+ action: IdentityVerificationAction
+ evidence: IdentityEventEvidence
+ actor: IdentityEventActor = IdentityEventActor.AUTOMATION
+ actor_user_id: int | None = None
+ review_revision: int | None = None
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.operation_id, UUID) or not self.operation_id.int:
+ raise ValueError("Invalid identity operation ID")
+ if not _positive_id(self.local_id) or not isinstance(
+ self.action, IdentityVerificationAction
+ ):
+ raise ValueError("Invalid identity event target or action")
+ if not isinstance(self.evidence, IdentityEventEvidence):
+ raise ValueError("Invalid identity event evidence")
+ if not isinstance(self.actor, IdentityEventActor):
+ raise ValueError("Invalid identity event actor")
+ if self.actor is IdentityEventActor.USER:
+ if not _positive_id(self.actor_user_id):
+ raise ValueError("User identity event actor requires a user ID")
+ elif self.actor_user_id is not None:
+ raise ValueError("Non-user identity event actor cannot supply a user ID")
+ if self.review_revision is not None and not _positive_id(self.review_revision):
+ raise ValueError("Invalid identity review revision")
+ if (
+ self.action in {IdentityVerificationAction.CONFIRM, IdentityVerificationAction.REJECT}
+ or self.evidence.claim.evidence_kind is IdentityEvidenceKind.USER_SELECTION
+ ) and (self.actor is not IdentityEventActor.USER or self.review_revision is None):
+ raise ValueError("Explicit identity review requires a user and review revision")
+
+
+@dataclass(frozen=True)
+class PreparedIdentityEvent:
+ event_key: str
+ request_fingerprint: str
+ request_json: str
+
+
+class IdentityEventReplayConflictError(ValueError):
+ """A stored operation cannot be reinterpreted as a different request."""
+
+
+def prepare_identity_event(request: IdentityEventRequest) -> PreparedIdentityEvent:
+ """Build a stable retry slot and separate semantic request fingerprint."""
+ if not isinstance(request, IdentityEventRequest):
+ raise ValueError("Invalid identity event request")
+ evidence = request.evidence
+ identity = evidence.claim.identity
+ origin: dict[str, object]
+ if evidence.locator is not None:
+ origin = {
+ "record_kind": evidence.locator.record_kind.value,
+ "record_id": evidence.locator.record_id,
+ }
+ else:
+ assert evidence.claim.source_instance is not None and evidence.source_identity is not None
+ origin = {
+ "source_instance": evidence.claim.source_instance.value,
+ "source_identity": _identity_payload(evidence.source_identity),
+ }
+ slot = {
+ "version": 1,
+ "operation_id": str(request.operation_id),
+ "entity_kind": identity.entity_kind.value,
+ "local_id": request.local_id,
+ "identity_namespace": identity.namespace.value,
+ "action": request.action.value,
+ "origin": origin,
+ "evidence_revision": evidence.revision,
+ }
+ payload = {
+ "version": 1,
+ "operation_id": str(request.operation_id),
+ "local_id": request.local_id,
+ "identity": _identity_payload(identity),
+ "action": request.action.value,
+ "evidence_kind": evidence.claim.evidence_kind.value,
+ "evidence_revision": evidence.revision,
+ "origin": origin,
+ "actor": request.actor.value,
+ "actor_user_id": request.actor_user_id,
+ "review_revision": request.review_revision,
+ }
+ # Keep existing v1 requests byte-identical when no parent was recorded.
+ if evidence.parent_identity is not None:
+ payload["parent_identity"] = _identity_payload(evidence.parent_identity)
+ serialized = _canonical_json(payload)
+ return PreparedIdentityEvent(_digest(_canonical_json(slot)), _digest(serialized), serialized)
+
+
+def validate_identity_event_replay(
+ request: PreparedIdentityEvent, *, event_key: str, request_fingerprint: str
+) -> None:
+ """Reject a reused retry slot with changed semantics; never update history."""
+ if (
+ not _is_digest(event_key)
+ or not _is_digest(request_fingerprint)
+ or not _is_digest(request.event_key)
+ or not _is_digest(request.request_fingerprint)
+ or request.event_key != event_key
+ or request.request_fingerprint != request_fingerprint
+ ):
+ raise IdentityEventReplayConflictError(
+ "Identity operation was already recorded with a different request"
+ )
+
+
+def _positive_id(value: object) -> bool:
+ return type(value) is int and 0 < value < 2**63
+
+
+def _is_digest(value: object) -> bool:
+ return (
+ isinstance(value, str)
+ and len(value) == 64
+ and all(char in "0123456789abcdef" for char in value)
+ )
+
+
+def _identity_payload(identity: ExternalIdentityRef) -> dict[str, str]:
+ return {
+ "namespace": identity.namespace.value,
+ "entity_kind": identity.entity_kind.value,
+ "external_id": identity.external_id,
+ }
+
+
+def _canonical_json(value: dict[str, object]) -> str:
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
+
+
+def _digest(value: str) -> str:
+ # Content/retry identifiers only; not credentials or authorization tokens.
+ return hashlib.sha256(value.encode("ascii")).hexdigest()
diff --git a/src/pullbox/core/metadata_identity_state.py b/src/pullbox/core/metadata_identity_state.py
new file mode 100644
index 00000000..67d2588b
--- /dev/null
+++ b/src/pullbox/core/metadata_identity_state.py
@@ -0,0 +1,62 @@
+"""Identity evidence lifecycle; state changes do not authorize attachment."""
+
+import enum
+from typing import assert_never
+
+
+class IdentityVerificationState(enum.StrEnum):
+ OBSERVED = "observed"
+ VERIFIED = "verified"
+ CONFLICTED = "conflicted"
+ STALE = "stale"
+ REJECTED = "rejected"
+
+
+class IdentityVerificationAction(enum.StrEnum):
+ OBSERVE = "observe"
+ VERIFY = "verify"
+ REPORT_CONFLICT = "report_conflict"
+ MARK_STALE = "mark_stale"
+ REJECT = "reject"
+ CONFIRM = "confirm"
+
+
+class IdentityReviewRequiredError(ValueError):
+ """An automatic verification cannot replace an unresolved human decision."""
+
+
+def transition_identity_state(
+ state: IdentityVerificationState, action: IdentityVerificationAction
+) -> IdentityVerificationState:
+ """Apply a validated lifecycle action without database or provider work.
+
+ Callers must validate evidence before VERIFY and explicit user intent before
+ CONFIRM or REJECT. Enum values are not authorization tokens. A verified state
+ does not prove cross-provider agreement, parent membership, or uniqueness.
+ Those checks and any attachment/history writes belong in one transaction.
+ """
+ if not isinstance(state, IdentityVerificationState):
+ raise ValueError("Unknown identity verification state")
+ if not isinstance(action, IdentityVerificationAction):
+ raise ValueError("Unknown identity verification action")
+ match action:
+ case IdentityVerificationAction.OBSERVE:
+ return state
+ case IdentityVerificationAction.VERIFY:
+ if state in (IdentityVerificationState.CONFLICTED, IdentityVerificationState.REJECTED):
+ raise IdentityReviewRequiredError("This identity requires explicit review")
+ return IdentityVerificationState.VERIFIED
+ case IdentityVerificationAction.CONFIRM:
+ return IdentityVerificationState.VERIFIED
+ case IdentityVerificationAction.REJECT:
+ return IdentityVerificationState.REJECTED
+ case IdentityVerificationAction.REPORT_CONFLICT:
+ if state is IdentityVerificationState.REJECTED:
+ return state
+ return IdentityVerificationState.CONFLICTED
+ case IdentityVerificationAction.MARK_STALE:
+ if state in (IdentityVerificationState.REJECTED, IdentityVerificationState.CONFLICTED):
+ return state
+ return IdentityVerificationState.STALE
+ case _:
+ assert_never(action)
diff --git a/src/pullbox/core/metadata_pdf_source.py b/src/pullbox/core/metadata_pdf_source.py
new file mode 100644
index 00000000..750eeb05
--- /dev/null
+++ b/src/pullbox/core/metadata_pdf_source.py
@@ -0,0 +1,208 @@
+"""Bounded PDF page spools for the single-construction paired archive writer."""
+
+import io
+import math
+import os
+import queue
+import re
+import subprocess
+import tempfile
+import threading
+import time
+from collections.abc import Callable
+from contextlib import ExitStack
+from pathlib import Path
+from typing import BinaryIO, Literal
+
+from PIL import Image
+
+from pullbox.core.file_safety import FileSafetyError
+from pullbox.core.metadata_archive_source import MetadataArchiveSource, _entry
+
+type PdfQuality = Literal["high", "medium", "low"]
+_PRESETS = {"high": (300, "png", 0), "medium": (200, "jpg", 90), "low": (150, "jpg", 80)}
+_MAX_PAGES = 10_000
+_MAX_PAGE_PIXELS = 80_000_000
+_INFO_BYTES = 4 * 1024 * 1024
+_CHUNK_BYTES = 64 * 1024
+
+
+def _run_bounded(
+ command: list[str],
+ output: BinaryIO,
+ *,
+ limit: int,
+ timeout: float,
+ check_cancelled: Callable[[], None],
+) -> int:
+ """Bound pipe memory/disk and reap Poppler even on callback failure or timeout."""
+ check_cancelled()
+ try:
+ process = subprocess.Popen(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ env={**os.environ, "LC_ALL": "C"},
+ )
+ except OSError as exc:
+ raise FileSafetyError("PDF renderer is unavailable") from exc
+ assert process.stdout is not None
+ pipe = process.stdout
+ chunks: queue.Queue[bytes | OSError | None] = queue.Queue(maxsize=2)
+ stop = threading.Event()
+
+ def send(chunk: bytes | OSError | None) -> None:
+ while not stop.is_set():
+ try:
+ chunks.put(chunk, timeout=0.05)
+ return
+ except queue.Full:
+ continue
+
+ def read() -> None:
+ try:
+ while not stop.is_set():
+ chunk = pipe.read(_CHUNK_BYTES)
+ if not chunk:
+ break
+ send(chunk)
+ except OSError as exc:
+ send(exc)
+ finally:
+ send(None)
+
+ reader = threading.Thread(target=read, name="pdf-output", daemon=True)
+ reader.start()
+ deadline = time.monotonic() + timeout
+ written = 0
+ eof = False
+ try:
+ while not eof or process.poll() is None:
+ check_cancelled()
+ if time.monotonic() >= deadline:
+ raise FileSafetyError("PDF rendering exceeded its time limit")
+ if eof:
+ stop.wait(0.05)
+ continue
+ try:
+ chunk = chunks.get(timeout=0.05)
+ except queue.Empty:
+ continue
+ if chunk is None:
+ eof = True
+ elif isinstance(chunk, OSError):
+ raise FileSafetyError("PDF renderer output could not be read") from chunk
+ else:
+ if written + len(chunk) > limit:
+ raise FileSafetyError("PDF rendered size exceeds limit")
+ output.write(chunk)
+ written += len(chunk)
+ if process.returncode != 0:
+ raise FileSafetyError("PDF could not be inspected or rendered")
+ check_cancelled()
+ return written
+ finally:
+ stop.set()
+ if process.poll() is None:
+ process.kill()
+ process.wait()
+ reader.join()
+ pipe.close()
+
+
+def open_pdf_pages(
+ stream: BinaryIO,
+ stack: ExitStack,
+ *,
+ limit: int,
+ scratch_parent: Path,
+ quality: PdfQuality,
+ check_cancelled: Callable[[], None],
+ progress: Callable[[int, int], None],
+) -> MetadataArchiveSource:
+ """Render one page at a time; never reopen the caller's mutable source path."""
+ dpi, extension, jpeg_quality = _PRESETS[quality]
+ directory = Path(
+ stack.enter_context(tempfile.TemporaryDirectory(prefix=".pullbox-pdf-", dir=scratch_parent))
+ )
+ private_input = directory / "input.pdf"
+ with private_input.open("xb") as copied:
+ total = 0
+ while chunk := stream.read(_CHUNK_BYTES):
+ check_cancelled()
+ total += len(chunk)
+ if total > limit:
+ raise FileSafetyError("PDF source size exceeds limit")
+ copied.write(chunk)
+
+ info = io.BytesIO()
+ _run_bounded(
+ ["pdfinfo", "-f", "1", "-l", str(_MAX_PAGES), str(private_input)],
+ info,
+ limit=_INFO_BYTES,
+ timeout=15,
+ check_cancelled=check_cancelled,
+ )
+ details = info.getvalue().decode("utf-8", errors="replace")
+ counts = re.findall(r"^Pages:[ \t]+(\d+)[ \t]*$", details, re.MULTILINE)
+ if len(counts) != 1 or len(counts[0]) > 5:
+ raise FileSafetyError("PDF page count could not be verified")
+ count = int(counts[0])
+ if count < 1 or count > _MAX_PAGES:
+ raise FileSafetyError("PDF page count exceeds limit or is empty")
+ if re.findall(r"^Encrypted:[ \t]+(.+)$", details, re.MULTILINE) != ["no"]:
+ raise FileSafetyError("Encrypted PDF cannot be converted safely")
+ sizes = re.findall(r"^Page\s+(\d+) size:\s+(\S+) x (\S+) pts.*$", details, re.MULTILINE)
+ if len(sizes) != count:
+ raise FileSafetyError("PDF page dimensions could not be verified")
+ dimensions = []
+ for expected, (number, width, height) in enumerate(sizes, 1):
+ try:
+ pixels = (float(width) * dpi / 72, float(height) * dpi / 72)
+ if int(number) != expected or any(not math.isfinite(p) or p <= 0 for p in pixels):
+ raise ValueError
+ size = (math.ceil(pixels[0]), math.ceil(pixels[1]))
+ except (ValueError, OverflowError) as exc:
+ raise FileSafetyError("PDF page dimensions could not be verified") from exc
+ # pdfinfo rounds physical sizes; allow one raster pixel without widening the cap.
+ if (size[0] + 1) * (size[1] + 1) > _MAX_PAGE_PIXELS:
+ raise FileSafetyError("PDF page pixel count exceeds limit")
+ dimensions.append(size)
+
+ progress(0, count)
+ entries = []
+ written = 0
+ for number, expected_size in enumerate(dimensions, 1):
+ check_cancelled()
+ name = f"page_{number - 1:04d}.{extension}"
+ path = directory / name
+ command = ["pdftoppm", "-f", str(number), "-l", str(number), "-singlefile", "-r", str(dpi)]
+ command += (
+ ["-png"] if extension == "png" else ["-jpeg", "-jpegopt", f"quality={jpeg_quality}"]
+ )
+ command.append(str(private_input))
+ with path.open("xb") as output:
+ page_bytes = _run_bounded(
+ command, output, limit=limit - written, timeout=120, check_cancelled=check_cancelled
+ )
+ try:
+ with Image.open(path) as image:
+ if image.width * image.height > _MAX_PAGE_PIXELS or not any(
+ all(
+ abs(actual - expected) <= 1
+ for actual, expected in zip(image.size, size, strict=True)
+ )
+ for size in (expected_size, expected_size[::-1])
+ ):
+ raise FileSafetyError("PDF rendered page dimensions disagree with inspection")
+ image.verify()
+ except (OSError, ValueError, Image.DecompressionBombError) as exc:
+ raise FileSafetyError("PDF rendered page could not be verified") from exc
+ written += page_bytes
+ entries.append(_entry(name, page_bytes))
+ progress(number, count)
+ private_input.unlink()
+ return MetadataArchiveSource(
+ entries, lambda entry: (directory / entry.filename).open("rb"), stack.close
+ )
diff --git a/src/pullbox/core/metadata_schemas/METRONINFO-LICENSE b/src/pullbox/core/metadata_schemas/METRONINFO-LICENSE
new file mode 100644
index 00000000..6fab07ec
--- /dev/null
+++ b/src/pullbox/core/metadata_schemas/METRONINFO-LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Brian Pepple
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/pullbox/core/metadata_schemas/MetronInfo-1.1.xsd b/src/pullbox/core/metadata_schemas/MetronInfo-1.1.xsd
new file mode 100644
index 00000000..220312b1
--- /dev/null
+++ b/src/pullbox/core/metadata_schemas/MetronInfo-1.1.xsd
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pullbox/core/metadata_schemas/README.md b/src/pullbox/core/metadata_schemas/README.md
new file mode 100644
index 00000000..2ef6238e
--- /dev/null
+++ b/src/pullbox/core/metadata_schemas/README.md
@@ -0,0 +1,27 @@
+# Embedded Metadata Schemas
+
+`MetronInfo-1.1.xsd` is the unmodified MetronInfo 1.1 schema from
+[Metron-Project/metroninfo](https://github.com/Metron-Project/metroninfo), pinned
+at commit `77b9fdbe489568b9e047206005e7f6b0700bb3f0`, path
+`schema/v1.1/MetronInfo.xsd`. The upstream MIT license is included as
+`METRONINFO-LICENSE`.
+
+SHA-256: `c0af59fc39e17e32c1a01714edc42d920bddc9d14e880ffc55ab960b8243a092`.
+
+These files ship in the Python package. Validation uses `xmlschema.XMLSchema11`
+offline; do not replace it with an XSD 1.0 validator or omit `xs:assert` rules.
+The schema is not downloaded at runtime. Instance schema hints, remote resource
+locations, and external entity expansion must never be followed.
+
+`xmlschema` 4.3.2 incorrectly counts explicit `primary="false"` / `primary="0"`
+attributes in these assertion contexts. The write validator normalizes valid
+false flags to absence only on its private parsed `IDS/ID` and `URLs/URL` nodes.
+Those flags are optional and have no default in the pinned schema, so this does
+not change their meaning. The source XML and upstream schema stay unchanged;
+true flags, malformed booleans, required attributes, and all other schema rules
+still receive full XSD validation. Regression tests cover both representations,
+multiple true flags, and false flags alongside unrelated schema errors.
+
+This schema checks representation, not identity, ownership, or file safety.
+Callers still have to reconcile both embedded documents against the canonical
+snapshot and obey the archive mutation/ownership contract before writing.
diff --git a/src/pullbox/core/metadata_xml.py b/src/pullbox/core/metadata_xml.py
new file mode 100644
index 00000000..b3af0a11
--- /dev/null
+++ b/src/pullbox/core/metadata_xml.py
@@ -0,0 +1,94 @@
+"""Shared bounded XML envelope for embedded metadata, with no external I/O."""
+
+from __future__ import annotations
+
+import io
+import re
+from xml.etree import ElementTree
+
+from defusedxml import ElementTree as DefusedElementTree
+from defusedxml.common import DefusedXmlException
+
+from pullbox.core.xml_security import UnsafeXmlError, normalize_xml_for_expat
+
+MAX_METADATA_XML_BYTES = 2 * 1024 * 1024
+MAX_METADATA_XML_DEPTH = 32
+MAX_METADATA_XML_NODES = 4096
+MAX_METADATA_XML_ATTRIBUTES = 16
+
+
+class MetadataXmlError(ValueError):
+ """Safe diagnostic code, never the untrusted XML body."""
+
+ def __init__(self, code: str) -> None:
+ super().__init__(code)
+ self.code = code
+
+
+def parse_metadata_xml(
+ payload: bytes | str, *, root_name: str, preserve_misc: bool = False
+) -> ElementTree.Element:
+ if len(payload) > MAX_METADATA_XML_BYTES:
+ raise MetadataXmlError("too_large")
+ try:
+ if isinstance(payload, str) and len(payload.encode("utf-8")) > MAX_METADATA_XML_BYTES:
+ raise MetadataXmlError("too_large")
+ normalized = normalize_xml_for_expat(payload)
+ header = normalized[:512]
+ if isinstance(header, bytes):
+ header = header.decode("ascii", errors="ignore")
+ encoding = re.search(r"<\?xml\b[^>]*\bencoding\s*=\s*['\"]([^'\"]+)", header)
+ if encoding and encoding[1].lower() not in {
+ "utf-8",
+ "utf-16",
+ "utf-16le",
+ "utf-16be",
+ "us-ascii",
+ }:
+ raise MetadataXmlError("unsupported_encoding")
+ stream = io.StringIO(normalized) if isinstance(normalized, str) else io.BytesIO(normalized)
+ depth = nodes = 0
+ root: ElementTree.Element | None = None
+ iterator = DefusedElementTree.iterparse(
+ stream,
+ events=("start", "end", "comment", "pi"),
+ parser=DefusedElementTree.DefusedXMLParser(
+ target=ElementTree.TreeBuilder(
+ insert_comments=preserve_misc, insert_pis=preserve_misc
+ ),
+ forbid_dtd=True,
+ forbid_entities=True,
+ forbid_external=True,
+ ),
+ forbid_dtd=True,
+ forbid_entities=True,
+ forbid_external=True,
+ )
+ for event, node in iterator:
+ if event in {"comment", "pi"}:
+ nodes += 1
+ if nodes > MAX_METADATA_XML_NODES:
+ raise MetadataXmlError("complexity_limit")
+ continue
+ if event == "start":
+ if root is None:
+ root = node
+ depth += 1
+ nodes += 1
+ if (
+ depth > MAX_METADATA_XML_DEPTH
+ or nodes > MAX_METADATA_XML_NODES
+ or len(node.attrib) > MAX_METADATA_XML_ATTRIBUTES
+ ):
+ raise MetadataXmlError("complexity_limit")
+ if node.tag.startswith("{http://www.w3.org/2001/XInclude}"):
+ raise MetadataXmlError("unsafe_xml")
+ else:
+ depth -= 1
+ except DefusedXmlException as exc:
+ raise MetadataXmlError("unsafe_xml") from exc
+ except (ElementTree.ParseError, UnsafeXmlError, UnicodeError, LookupError) as exc:
+ raise MetadataXmlError("invalid_xml") from exc
+ if root is None or root.tag != root_name:
+ raise MetadataXmlError("invalid_root")
+ return root
diff --git a/src/pullbox/core/metroninfo.py b/src/pullbox/core/metroninfo.py
new file mode 100644
index 00000000..9a947380
--- /dev/null
+++ b/src/pullbox/core/metroninfo.py
@@ -0,0 +1,483 @@
+"""Bounded, read-only MetronInfo evidence; never a matching or write decision."""
+
+from __future__ import annotations
+
+import enum
+import re
+from dataclasses import dataclass, replace
+from datetime import date
+from typing import TYPE_CHECKING
+
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+ find_exact_identity_conflicts,
+)
+from pullbox.core.metadata_xml import (
+ MAX_METADATA_XML_BYTES,
+ MAX_METADATA_XML_DEPTH,
+ MAX_METADATA_XML_NODES,
+ MetadataXmlError,
+ parse_metadata_xml,
+)
+
+if TYPE_CHECKING:
+ from xml.etree import ElementTree
+
+MAX_METRONINFO_BYTES = MAX_METADATA_XML_BYTES
+MAX_METRONINFO_DEPTH = MAX_METADATA_XML_DEPTH
+MAX_METRONINFO_NODES = MAX_METADATA_XML_NODES
+MAX_METRONINFO_CREDITS = 128
+MAX_METRONINFO_ROLES = 32
+_MAX_FIELD_LENGTH = 4096
+_CALENDAR_TIMEZONE = r"(?:Z|[+-](?:(?:0[0-9]|1[0-3]):[0-5][0-9]|14:00))?"
+_SCHEMA_HINT = "{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation"
+_SOURCES = {
+ "metron": IdentityNamespace.METRON,
+ "comic vine": IdentityNamespace.COMICVINE,
+ "comicvine": IdentityNamespace.COMICVINE,
+ "grand comics database": IdentityNamespace.GCD,
+ "gcd": IdentityNamespace.GCD,
+ "league of comic geeks": IdentityNamespace.LOCG,
+ "locg": IdentityNamespace.LOCG,
+}
+
+
+class MetronInfoDiagnosticCode(enum.StrEnum):
+ INVALID_XML = "invalid_xml"
+ UNSAFE_XML = "unsafe_xml"
+ TOO_LARGE = "too_large"
+ COMPLEXITY_LIMIT = "complexity_limit"
+ INVALID_ROOT = "invalid_root"
+ UNSUPPORTED_VERSION = "unsupported_version"
+ UNSUPPORTED_ENCODING = "unsupported_encoding"
+ INVALID_ID = "invalid_id"
+ UNKNOWN_SOURCE = "unknown_source"
+ INVALID_PRIMARY = "invalid_primary"
+ MISSING_PRIMARY = "missing_primary"
+ AMBIGUOUS_PRIMARY = "ambiguous_primary"
+ INVALID_FIELD = "invalid_field"
+ AMBIGUOUS_FIELD = "ambiguous_field"
+ EXACT_ID_CONFLICT = "exact_id_conflict"
+ UNMAPPED_CONTENT = "unmapped_content"
+
+
+@dataclass(frozen=True)
+class MetronInfoDiagnostic:
+ code: MetronInfoDiagnosticCode
+ locator: str
+
+
+@dataclass(frozen=True)
+class MetronInfoIdentity:
+ evidence: ExactIdentityEvidence
+ primary: bool = False
+
+
+@dataclass(frozen=True)
+class MetronInfoReleaseReference:
+ """LOCG issue/variant reference, not a canonical issue attachment."""
+
+ external_id: str
+ primary: bool = False
+
+
+@dataclass(frozen=True)
+class MetronInfoArc:
+ name: str
+ number: int | None = None
+ evidence: ExactIdentityEvidence | None = None
+
+
+@dataclass(frozen=True)
+class MetronInfoResource:
+ """Descriptive resource with an opaque local ID, never an identity claim."""
+
+ value: str
+ resource_id: str | None = None
+ language: str | None = None
+
+
+@dataclass(frozen=True)
+class MetronInfoCredit:
+ creator: MetronInfoResource
+ roles: tuple[MetronInfoResource, ...] = ()
+
+
+@dataclass(frozen=True)
+class MetronInfoData:
+ series: str | None = None
+ number: str | None = None
+ alternative_number: str | None = None
+ publisher: str | None = None
+ start_year: int | None = None
+ volume: int | None = None
+ series_format: str | None = None
+ issue_count: int | None = None
+ cover_date: date | None = None
+ store_date: date | None = None
+ stories: tuple[str, ...] = ()
+ arcs: tuple[MetronInfoArc, ...] = ()
+ primary_source: IdentityNamespace | None = None
+ identities: tuple[MetronInfoIdentity, ...] = ()
+ release_references: tuple[MetronInfoReleaseReference, ...] = ()
+ diagnostics: tuple[MetronInfoDiagnostic, ...] = ()
+ has_unmapped_content: bool = False
+ sort_name: str | None = None
+ language: str | None = None
+ volume_count: int | None = None
+ publisher_id: str | None = None
+ imprint: MetronInfoResource | None = None
+ alternative_names: tuple[MetronInfoResource, ...] = ()
+ collection_title: str | None = None
+ manga_volume: str | None = None
+ summary: str | None = None
+ notes: str | None = None
+ page_count: int | None = None
+ age_rating: str | None = None
+ story_resources: tuple[MetronInfoResource, ...] = ()
+ genres: tuple[MetronInfoResource, ...] = ()
+ tags: tuple[MetronInfoResource, ...] = ()
+ characters: tuple[MetronInfoResource, ...] = ()
+ teams: tuple[MetronInfoResource, ...] = ()
+ locations: tuple[MetronInfoResource, ...] = ()
+ reprints: tuple[MetronInfoResource, ...] = ()
+ credits: tuple[MetronInfoCredit, ...] | None = None
+
+ @property
+ def evidence(self) -> tuple[ExactIdentityEvidence, ...]:
+ return tuple(item.evidence for item in self.identities)
+
+
+def parse_metroninfo(payload: bytes | str) -> MetronInfoData:
+ """Read local metadata only; callers retain authority and archive safety checks."""
+ try:
+ root = parse_metadata_xml(payload, root_name="MetronInfo")
+ except MetadataXmlError as exc:
+ return MetronInfoData(
+ diagnostics=(MetronInfoDiagnostic(MetronInfoDiagnosticCode(exc.code), "MetronInfo"),)
+ )
+ return _Reader(root).read()
+
+
+class _Reader:
+ def __init__(self, root: ElementTree.Element) -> None:
+ self.root = root
+ self.diagnostics: list[MetronInfoDiagnostic] = []
+ self.diagnostic_keys: set[MetronInfoDiagnostic] = set()
+ self.seen: dict[ElementTree.Element, set[str]] = {root: {"version", _SCHEMA_HINT}}
+ self.text_nodes: set[ElementTree.Element] = set()
+ self.identities: dict[ExactIdentityEvidence, bool] = {}
+ self.releases: dict[str, bool] = {}
+
+ def diagnostic(self, code: MetronInfoDiagnosticCode, locator: str) -> None:
+ item = MetronInfoDiagnostic(code, locator)
+ if item not in self.diagnostic_keys:
+ self.diagnostic_keys.add(item)
+ self.diagnostics.append(item)
+
+ def single(
+ self, parent: ElementTree.Element | None, tag: str, locator: str
+ ) -> ElementTree.Element | None:
+ if parent is None:
+ return None
+ nodes = parent.findall(tag)
+ if len(nodes) > 1:
+ self.diagnostic(MetronInfoDiagnosticCode.AMBIGUOUS_FIELD, locator)
+ return None
+ if not nodes:
+ return None
+ self.seen.setdefault(nodes[0], set())
+ return nodes[0]
+
+ def read_text(self, node: ElementTree.Element | None, locator: str) -> str | None:
+ if node is None:
+ return None
+ value = (node.text or "").strip()
+ if len(node) or len(value) > _MAX_FIELD_LENGTH:
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator)
+ return None
+ self.text_nodes.add(node)
+ return value or None
+
+ def field(self, parent: ElementTree.Element | None, tag: str, locator: str) -> str | None:
+ return self.read_text(self.single(parent, tag, locator), locator)
+
+ def attribute(self, node: ElementTree.Element | None, name: str, locator: str) -> str | None:
+ if node is None or name not in node.attrib:
+ return None
+ value = node.attrib[name]
+ if len(value) > _MAX_FIELD_LENGTH:
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, f"{locator}/@{name}")
+ return None
+ self.seen[node].add(name)
+ return value
+
+ def language(self, node: ElementTree.Element | None, locator: str) -> str | None:
+ value = self.attribute(node, "lang", locator)
+ if value is not None and not re.fullmatch(r"[a-z]{2}", value):
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, f"{locator}/@lang")
+ return None
+ return value
+
+ def resource(
+ self, node: ElementTree.Element | None, locator: str, *, localized: bool = False
+ ) -> MetronInfoResource | None:
+ if node is None:
+ return None
+ self.seen.setdefault(node, set())
+ value = self.read_text(node, locator)
+ resource_id = self.attribute(node, "id", locator)
+ language = self.language(node, locator) if localized else None
+ if value is None:
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator)
+ return None
+ return MetronInfoResource(value, resource_id, language)
+
+ def resources(
+ self,
+ parent: ElementTree.Element | None,
+ group_tag: str,
+ tag: str,
+ locator: str,
+ *,
+ localized: bool = False,
+ ) -> tuple[MetronInfoResource, ...]:
+ group = self.single(parent, group_tag, locator)
+ if group is None:
+ return ()
+ result = []
+ for index, node in enumerate(group.findall(tag), 1):
+ item = self.resource(node, f"{locator}/{tag}[{index}]", localized=localized)
+ if item is not None:
+ result.append(item)
+ return tuple(result)
+
+ def read_credits(self) -> tuple[MetronInfoCredit, ...] | None:
+ group = self.single(self.root, "Credits", "Credits")
+ if group is None:
+ return None
+ diagnostic_count = len(self.diagnostics)
+ nodes = group.findall("Credit")
+ if len(nodes) > MAX_METRONINFO_CREDITS:
+ self.diagnostic(MetronInfoDiagnosticCode.COMPLEXITY_LIMIT, "Credits")
+ return ()
+ credits = []
+ for index, node in enumerate(nodes, 1):
+ locator = f"Credits/Credit[{index}]"
+ self.seen[node] = set()
+ creator = self.resource(
+ self.single(node, "Creator", f"{locator}/Creator"), f"{locator}/Creator"
+ )
+ if creator is None:
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, f"{locator}/Creator")
+ roles = self.single(node, "Roles", f"{locator}/Roles")
+ if roles is not None and len(roles.findall("Role")) > MAX_METRONINFO_ROLES:
+ self.diagnostic(MetronInfoDiagnosticCode.COMPLEXITY_LIMIT, f"{locator}/Roles")
+ return ()
+ values = self.resources(node, "Roles", "Role", f"{locator}/Roles")
+ if creator is not None:
+ credits.append(MetronInfoCredit(creator, values))
+ return tuple(credits) if len(self.diagnostics) == diagnostic_count else ()
+
+ def integer(
+ self,
+ parent: ElementTree.Element | None,
+ tag: str,
+ locator: str,
+ minimum: int = 0,
+ maximum: int = 2**31 - 1,
+ ) -> int | None:
+ value = self.field(parent, tag, locator)
+ if value is None:
+ return None
+ if re.fullmatch(r"\+?[0-9]{1,10}", value) and minimum <= int(value) <= maximum:
+ return int(value)
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator)
+ return None
+
+ def year(self, parent: ElementTree.Element | None, tag: str, locator: str) -> int | None:
+ value = self.field(parent, tag, locator)
+ if value is None:
+ return None
+ match = re.fullmatch(rf"([0-9]{{4}}){_CALENDAR_TIMEZONE}", value)
+ if match and int(match[1]) > 0:
+ return int(match[1])
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator)
+ return None
+
+ def date(self, tag: str) -> date | None:
+ value = self.field(self.root, tag, tag)
+ if value is None:
+ return None
+ try:
+ match = re.fullmatch(rf"([0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}){_CALENDAR_TIMEZONE}", value)
+ if match:
+ return date.fromisoformat(match[1])
+ except ValueError:
+ pass
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, tag)
+ return None
+
+ def external_id(self, value: str | None, locator: str) -> str | None:
+ normalized = (value or "").strip()
+ if (
+ len(normalized) <= 255
+ and normalized.isascii()
+ and normalized.isdecimal()
+ and normalized.lstrip("0")
+ ):
+ return normalized.lstrip("0")
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_ID, locator)
+ return None
+
+ def claim(
+ self, namespace: IdentityNamespace, kind: MetadataEntityKind, value: str
+ ) -> ExactIdentityEvidence:
+ return ExactIdentityEvidence(
+ ExternalIdentityRef(namespace, kind, value), IdentityEvidenceKind.METRONINFO_XML
+ )
+
+ def read_ids(self) -> IdentityNamespace | None:
+ declarations: list[IdentityNamespace | None] = []
+ invalid_primary = False
+ groups = self.root.findall("IDS")
+ if len(groups) > 1:
+ self.diagnostic(MetronInfoDiagnosticCode.AMBIGUOUS_FIELD, "IDS")
+ invalid_primary = True
+ for group_index, group in enumerate(groups, 1):
+ self.seen[group] = set()
+ for index, node in enumerate(group.findall("ID"), 1):
+ locator = f"IDS[{group_index}]/ID[{index}]"
+ self.seen[node] = {"source", "primary"}
+ namespace = _SOURCES.get(" ".join(node.get("source", "").lower().split()))
+ primary = node.get("primary", "false").strip()
+ if primary not in {"true", "false", "1", "0"}:
+ invalid_primary = True
+ self.diagnostic(MetronInfoDiagnosticCode.INVALID_PRIMARY, locator)
+ is_primary = primary in {"true", "1"}
+ if is_primary:
+ declarations.append(namespace)
+ if namespace is None:
+ self.diagnostic(MetronInfoDiagnosticCode.UNKNOWN_SOURCE, locator)
+ continue
+ value = self.external_id(self.read_text(node, locator), locator)
+ if value is None:
+ continue
+ if namespace is IdentityNamespace.LOCG:
+ self.releases[value] = self.releases.get(value, False) or is_primary
+ else:
+ claim = self.claim(namespace, MetadataEntityKind.ISSUE, value)
+ self.identities[claim] = self.identities.get(claim, False) or is_primary
+ if len(declarations) > 1:
+ self.diagnostic(MetronInfoDiagnosticCode.AMBIGUOUS_PRIMARY, "IDS")
+ if invalid_primary or len(declarations) != 1:
+ return None
+ return declarations[0]
+
+ def parent_identity(
+ self,
+ node: ElementTree.Element | None,
+ namespace: IdentityNamespace | None,
+ kind: MetadataEntityKind,
+ locator: str,
+ ) -> ExactIdentityEvidence | None:
+ if node is None or "id" not in node.attrib:
+ return None
+ self.seen[node].add("id")
+ if namespace is None:
+ self.diagnostic(MetronInfoDiagnosticCode.MISSING_PRIMARY, locator)
+ return None
+ value = self.external_id(node.get("id"), locator)
+ return self.claim(namespace, kind, value) if value is not None else None
+
+ def read_arcs(self, primary: IdentityNamespace | None) -> tuple[MetronInfoArc, ...]:
+ group = self.single(self.root, "Arcs", "Arcs")
+ if group is None:
+ return ()
+ arcs = []
+ for index, node in enumerate(group.findall("Arc"), 1):
+ self.seen[node] = set()
+ locator = f"Arcs/Arc[{index}]"
+ name = self.field(node, "Name", f"{locator}/Name")
+ number = self.integer(node, "Number", f"{locator}/Number", minimum=1)
+ identity = self.parent_identity(node, primary, MetadataEntityKind.STORY_ARC, locator)
+ if name:
+ arcs.append(MetronInfoArc(name, number, identity))
+ return tuple(arcs)
+
+ def read(self) -> MetronInfoData:
+ compatible = self.root.get("version") in {None, "1.0", "1.1"}
+ if not compatible:
+ self.diagnostic(MetronInfoDiagnosticCode.UNSUPPORTED_VERSION, "MetronInfo")
+ primary = self.read_ids() if compatible else None
+ series = self.single(self.root, "Series", "Series")
+ parent = self.parent_identity(series, primary, MetadataEntityKind.SERIES, "Series")
+ if parent is not None:
+ self.identities[parent] = True
+ if find_exact_identity_conflicts(self.identities):
+ self.diagnostic(MetronInfoDiagnosticCode.EXACT_ID_CONFLICT, "IDS")
+ stories = self.resources(self.root, "Stories", "Story", "Stories")
+ publisher = self.single(self.root, "Publisher", "Publisher")
+ data = MetronInfoData(
+ series=self.field(series, "Name", "Series/Name"),
+ number=self.field(self.root, "Number", "Number"),
+ alternative_number=self.field(self.root, "AlternativeNumber", "AlternativeNumber"),
+ publisher=self.field(publisher, "Name", "Publisher/Name"),
+ start_year=self.year(series, "StartYear", "Series/StartYear"),
+ volume=self.integer(series, "Volume", "Series/Volume"),
+ series_format=self.field(series, "Format", "Series/Format"),
+ issue_count=self.integer(series, "IssueCount", "Series/IssueCount", minimum=1),
+ cover_date=self.date("CoverDate"),
+ store_date=self.date("StoreDate"),
+ stories=tuple(item.value for item in stories),
+ arcs=self.read_arcs(primary),
+ primary_source=primary,
+ identities=tuple(
+ MetronInfoIdentity(claim, flag) for claim, flag in self.identities.items()
+ ),
+ release_references=tuple(
+ MetronInfoReleaseReference(value, flag) for value, flag in self.releases.items()
+ ),
+ sort_name=self.field(series, "SortName", "Series/SortName"),
+ language=self.language(series, "Series"),
+ volume_count=self.integer(series, "VolumeCount", "Series/VolumeCount", minimum=1),
+ publisher_id=self.attribute(publisher, "id", "Publisher"),
+ imprint=self.resource(
+ self.single(publisher, "Imprint", "Publisher/Imprint"), "Publisher/Imprint"
+ ),
+ alternative_names=self.resources(
+ series,
+ "AlternativeNames",
+ "AlternativeName",
+ "Series/AlternativeNames",
+ localized=True,
+ ),
+ collection_title=self.field(self.root, "CollectionTitle", "CollectionTitle"),
+ manga_volume=self.field(self.root, "MangaVolume", "MangaVolume"),
+ summary=self.field(self.root, "Summary", "Summary"),
+ notes=self.field(self.root, "Notes", "Notes"),
+ page_count=self.integer(self.root, "PageCount", "PageCount"),
+ age_rating=self.field(self.root, "AgeRating", "AgeRating"),
+ story_resources=stories,
+ genres=self.resources(self.root, "Genres", "Genre", "Genres"),
+ tags=self.resources(self.root, "Tags", "Tag", "Tags"),
+ characters=self.resources(self.root, "Characters", "Character", "Characters"),
+ teams=self.resources(self.root, "Teams", "Team", "Teams"),
+ locations=self.resources(self.root, "Locations", "Location", "Locations"),
+ reprints=self.resources(self.root, "Reprints", "Reprint", "Reprints"),
+ credits=self.read_credits(),
+ )
+ unmapped = any(
+ node not in self.seen
+ or set(node.attrib) - self.seen[node]
+ or (node not in self.text_nodes and (node.text or "").strip())
+ or (node.tail or "").strip()
+ for node in self.root.iter()
+ )
+ if unmapped:
+ self.diagnostic(MetronInfoDiagnosticCode.UNMAPPED_CONTENT, "MetronInfo")
+ return replace(data, diagnostics=tuple(self.diagnostics), has_unmapped_content=unmapped)
diff --git a/src/pullbox/core/metroninfo_schema.py b/src/pullbox/core/metroninfo_schema.py
new file mode 100644
index 00000000..b9c3b75a
--- /dev/null
+++ b/src/pullbox/core/metroninfo_schema.py
@@ -0,0 +1,45 @@
+"""Offline MetronInfo 1.1 validation before paired archive metadata writes."""
+
+from functools import lru_cache
+from importlib.resources import files
+
+from xmlschema import XMLResource, XMLSchema11
+
+from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml
+
+
+@lru_cache(maxsize=1)
+def _schema() -> XMLSchema11:
+ payload = files("pullbox").joinpath("core/metadata_schemas/MetronInfo-1.1.xsd").read_bytes()
+ # The pinned schema bytes retain namespace declarations used by QName attributes.
+ return XMLSchema11(
+ payload,
+ allow="none",
+ defuse="always",
+ use_fallback=False,
+ use_cache=False,
+ use_location_hints=False,
+ )
+
+
+def validate_metroninfo_xml(payload: bytes | str) -> None:
+ """Reject unsafe or schema-invalid output without exposing its contents."""
+ root = parse_metadata_xml(payload, root_name="MetronInfo")
+ # xmlschema 4.3's assertion context treats explicit false attributes as true.
+ # In this pinned XSD, an absent optional primary flag is equivalent to false.
+ # Normalize only valid false lexemes on this private tree; never alter the XML
+ # payload, schema, true flags, other attributes, or other validation rules.
+ for path in ("IDS/ID", "URLs/URL"):
+ for node in root.findall(path):
+ flag = node.get("primary")
+ if flag is None:
+ continue
+ normalized = flag.strip(" \t\r\n")
+ if normalized not in {"false", "0", "true", "1"}:
+ raise MetadataXmlError("invalid_schema")
+ if normalized in {"false", "0"}:
+ del node.attrib["primary"]
+ # Only a bounded, defused tree reaches the validator, never a caller-provided URI.
+ resource = XMLResource(root, allow="none", defuse="always")
+ if not _schema().is_valid(resource, use_defaults=False, use_location_hints=False):
+ raise MetadataXmlError("invalid_schema")
diff --git a/src/pullbox/core/series_sidecar.py b/src/pullbox/core/series_sidecar.py
new file mode 100644
index 00000000..db7bbe96
--- /dev/null
+++ b/src/pullbox/core/series_sidecar.py
@@ -0,0 +1,177 @@
+"""Bounded JSON output from the same canonical snapshot used by archive writers."""
+
+import json
+import math
+import re
+from datetime import datetime
+from pathlib import PurePosixPath, PureWindowsPath
+from typing import Any
+from urllib.parse import urlsplit
+
+from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind
+from pullbox.core.source_sidecars import parse_source_sidecar
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot
+
+MAX_SIDECAR_BYTES = 1024 * 1024
+_PRIVATE_KEY = re.compile(r"(?:password|credential|api[_-]?key|access[_-]?token|secret)", re.I)
+
+
+def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ result = {}
+ for key, value in pairs:
+ if key in result:
+ raise ValueError("Existing series.json contains duplicate keys. Review it first.")
+ result[key] = value
+ return result
+
+
+def _safe(value: object, *, depth: int = 0) -> None:
+ if depth > 20:
+ raise ValueError("Existing series.json is too deeply nested. Review it first.")
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if _PRIVATE_KEY.search(key):
+ raise ValueError(
+ "Existing series.json contains private configuration. Review it first."
+ )
+ _safe(item, depth=depth + 1)
+ elif isinstance(value, list):
+ for item in value:
+ _safe(item, depth=depth + 1)
+ elif isinstance(value, float) and not math.isfinite(value):
+ raise ValueError("Existing series.json contains invalid numeric values. Review it first.")
+ elif isinstance(value, str):
+ if (
+ PurePosixPath(value).is_absolute()
+ or PureWindowsPath(value).is_absolute()
+ or value.startswith(("../", "..\\", "file:"))
+ ):
+ raise ValueError("Existing series.json contains filesystem paths. Review it first.")
+ try:
+ url = urlsplit(value)
+ except ValueError:
+ return
+ if url.scheme in {"http", "https"} and (
+ url.username or url.password or _PRIVATE_KEY.search(url.query)
+ ):
+ raise ValueError("Existing series.json contains a private URL. Review it first.")
+
+
+def read_series_sidecar(payload: bytes | None) -> dict[str, Any]:
+ if payload is None:
+ return {}
+ if len(payload) > MAX_SIDECAR_BYTES:
+ raise ValueError("Existing series.json exceeds the 1 MiB safety limit. Review it first.")
+ try:
+ result = json.loads(payload, object_pairs_hook=_pairs)
+ if not isinstance(result, dict):
+ raise ValueError("Expected an object")
+ _safe(result)
+ return result
+ except (UnicodeError, json.JSONDecodeError, RecursionError):
+ raise ValueError(
+ "Existing series.json is invalid. Repair it before writing metadata."
+ ) from None
+
+
+def render_series_sidecar(
+ snapshot: MetadataSnapshot,
+ aliases: tuple[str, ...],
+ existing: bytes | None,
+ *,
+ updated_at: datetime,
+) -> bytes:
+ """Keep safe user keys; never turn sidecar IDs into verified library identities."""
+ if snapshot.entity_kind is not MetadataEntityKind.SERIES:
+ raise ValueError("A series sidecar requires a series snapshot.")
+ data = read_series_sidecar(existing)
+ refs = {ref.namespace.value: ref.external_id for ref in snapshot.identities}
+ legacy = parse_source_sidecar(existing.decode("utf-8") if existing else "")
+ if legacy.get("_identity_conflicts") or (
+ legacy.get("comicid") is not None
+ and str(legacy["comicid"]) != refs.get(IdentityNamespace.COMICVINE.value)
+ ):
+ raise ValueError(
+ "Existing series.json identifies a different series. Review the match first."
+ )
+ prior = data.get("pullbox")
+ if prior is not None:
+ if not isinstance(prior, dict) or prior.get("schema_version") != 1:
+ raise ValueError("Existing Pullbox sidecar version is unsupported. Review it first.")
+ previous = MetadataSnapshot.model_validate(prior.get("snapshot"))
+ if previous.entity_kind is not MetadataEntityKind.SERIES or not set(
+ previous.identities
+ ) <= set(snapshot.identities):
+ raise ValueError(
+ "Existing series.json provider identities disagree. Review the match first."
+ )
+ values = snapshot.values
+ fields: dict[str, object] = {
+ "name": values.title,
+ "year": values.year_start,
+ "publisher": values.publisher,
+ "booktype": "Print" if values.series_type == "standard" else values.series_type,
+ "status": values.status,
+ "total_issues": values.issue_count,
+ }
+ if IdentityNamespace.COMICVINE.value in refs:
+ fields["comicid"] = int(refs[IdentityNamespace.COMICVINE.value])
+ metadata_keys = [key for key in data if key.casefold() == "metadata"]
+ layers = [data]
+ for key in metadata_keys:
+ nested = data[key]
+ if not isinstance(nested, dict):
+ raise ValueError("Existing Mylar metadata is invalid. Review it first.")
+ layers.append(nested)
+ aliases_by_field = {
+ "name": {"name", "series", "title"},
+ "year": {"year", "year_start"},
+ "status": {"status", "series_status"},
+ "total_issues": {"total_issues", "issue_count"},
+ "booktype": {"booktype", "series_type", "type"},
+ "comicid": {"comicid", "comicvine_id", "comicvineid", "cv_vol_id", "cvid"},
+ }
+ # Update every recognized layer, including cleared values and case variants.
+ for layer in layers:
+ for name, value in fields.items():
+ for key in tuple(layer):
+ if key.casefold() in aliases_by_field.get(name, {name}):
+ layer[key] = value
+ destination = layers[-1]
+ destination.update(fields)
+ compiled = {
+ **(
+ {key: value for key, value in prior.items() if key != "updated_at"}
+ if isinstance(prior, dict)
+ else {}
+ ),
+ "schema_version": 1,
+ "snapshot": snapshot.model_dump(mode="json"),
+ "aliases": list(aliases),
+ "links": {
+ key: template.format(value)
+ for key, template in {
+ "comicvine": "https://comicvine.gamespot.com/volume/4050-{}/",
+ "metron": "https://metron.cloud/series/{}/",
+ "gcd": "https://www.comics.org/series/{}/",
+ }.items()
+ if (value := refs.get(key)) is not None
+ },
+ }
+ # Fresh preview times are not a reason to rewrite an otherwise identical sidecar.
+ if isinstance(prior, dict):
+ comparison = {key: value for key, value in prior.items() if key != "updated_at"}
+ if comparison == compiled:
+ compiled["updated_at"] = prior.get("updated_at")
+ else:
+ compiled["updated_at"] = updated_at.isoformat()
+ else:
+ compiled["updated_at"] = updated_at.isoformat()
+ data["pullbox"] = compiled
+ _safe(data)
+ encoded = (
+ json.dumps(data, ensure_ascii=True, sort_keys=True, indent=2, allow_nan=False) + "\n"
+ ).encode()
+ if len(encoded) > MAX_SIDECAR_BYTES:
+ raise ValueError("Compiled series.json exceeds the 1 MiB safety limit.")
+ return encoded
diff --git a/src/pullbox/core/subscribers.py b/src/pullbox/core/subscribers.py
index 1714b4bd..19f46e70 100644
--- a/src/pullbox/core/subscribers.py
+++ b/src/pullbox/core/subscribers.py
@@ -145,10 +145,12 @@ async def _download_covers_for_series(event: SeriesAdded) -> None:
"""
from sqlalchemy import select
- from pullbox.core.comicvine_key import get_comicvine_api_key
from pullbox.models.series import Series
- from pullbox.providers.metadata.comicvine import ComicVineProvider
- from pullbox.services.metadata_service import MetadataService
+ from pullbox.services.provider_artwork import (
+ ProviderArtworkClient,
+ issue_cover_stem,
+ pending_provider_cover,
+ )
log = logger.bind(series_id=event.series_id)
log.info("subscriber_cover_download_start")
@@ -166,21 +168,23 @@ async def _download_covers_for_series(event: SeriesAdded) -> None:
log.warning("subscriber_cover_download_series_not_found")
return
- api_key = await get_comicvine_api_key(session)
series_id = series.id
series_cover_url = series.cover_url
series_has_cover = bool(series.cover_path)
- # Collect issue cover work list: (issue_id, cover_url, issue_number)
+ # Preserve exact designations: numeric compatibility values can collide.
result = await session.execute(
- select(Issue.id, Issue.cover_url, Issue.issue_number).where(
+ select(
+ Issue.id, Issue.cover_url, Issue.issue_number, Issue.issue_number_text
+ ).where(
Issue.series_id == event.series_id,
Issue.cover_url.isnot(None),
Issue.cover_path.is_(None),
)
)
- issue_work: list[tuple[int, str, float]] = [
- (row.id, row.cover_url, row.issue_number) for row in result.all()
+ issue_work: list[tuple[int, str, float, str | None]] = [
+ (row.id, row.cover_url, row.issue_number, row.issue_number_text)
+ for row in result.all()
]
except Exception:
log.exception("subscriber_cover_download_load_failed")
@@ -198,47 +202,55 @@ async def _download_covers_for_series(event: SeriesAdded) -> None:
covers_dir = covers_base / str(series_id)
- provider = ComicVineProvider(api_key=api_key)
- svc = MetadataService(provider=provider, covers_dir=covers_base)
-
- # Download series cover
- if series_cover_url and not series_has_cover:
- try:
- series_cover_dest = covers_dir / "series.jpg"
- await svc.download_cover(series_cover_url, series_cover_dest)
- if series_cover_dest.exists():
- async with factory() as session:
- series_obj = await session.get(Series, series_id)
- if series_obj and not series_obj.cover_path:
- series_obj.cover_path = f"/api/v1/series/{series_id}/cover"
- await session.commit()
- log.info("subscriber_series_cover_downloaded")
- except Exception:
- log.exception("subscriber_series_cover_failed")
-
- # Download issue covers
downloaded = 0
- for issue_id, cover_url, issue_number in issue_work:
- try:
- num_str = (
- f"{int(issue_number):03d}"
- if issue_number == int(issue_number)
- else f"{issue_number:06.1f}"
- )
- cover_dest = covers_dir / f"issue_{num_str}.jpg"
- await svc.download_cover(cover_url, cover_dest)
- if cover_dest.exists():
- async with factory() as session:
- issue_obj = await session.get(Issue, issue_id)
- if issue_obj and not issue_obj.cover_path:
- issue_obj.cover_path = f"/api/v1/issues/{issue_id}/cover"
- await session.commit()
- downloaded += 1
- except Exception:
- log.exception("subscriber_issue_cover_failed", issue_id=issue_id)
-
- # Small delay to respect CDN rate limits
- await asyncio.sleep(0.2)
+ async with ProviderArtworkClient() as artwork:
+ if series_cover_url and not series_has_cover:
+ try:
+ series_cover_dest = covers_dir / "series.jpg"
+ async with pending_provider_cover(
+ artwork, series_cover_url, series_cover_dest
+ ) as pending:
+ if pending is not None:
+ async with factory() as session:
+ series_obj = await session.get(Series, series_id)
+ if (
+ series_obj
+ and not series_obj.cover_path
+ and series_obj.cover_url == series_cover_url
+ ):
+ pending.replace(series_cover_dest)
+ series_obj.cover_path = f"/api/v1/series/{series_id}/cover"
+ await session.commit()
+ log.info("subscriber_series_cover_downloaded")
+ except Exception:
+ log.exception("subscriber_series_cover_failed")
+
+ for issue_id, cover_url, issue_number, exact in issue_work:
+ try:
+ stem = issue_cover_stem(issue_number, exact)
+ cover_dest = covers_dir / f"{stem}.jpg"
+ async with pending_provider_cover(artwork, cover_url, cover_dest) as pending:
+ if pending is not None:
+ async with factory() as session:
+ issue_obj = await session.get(Issue, issue_id)
+ if (
+ issue_obj
+ and not issue_obj.cover_path
+ and issue_obj.series_id == series_id
+ and issue_obj.cover_url == cover_url
+ and issue_cover_stem(
+ issue_obj.issue_number, issue_obj.issue_number_text
+ )
+ == stem
+ ):
+ pending.replace(cover_dest)
+ issue_obj.cover_path = f"/api/v1/issues/{issue_id}/cover"
+ await session.commit()
+ downloaded += 1
+ except Exception:
+ log.exception("subscriber_issue_cover_failed", issue_id=issue_id)
+
+ await asyncio.sleep(0.2)
log.info(
"subscriber_cover_download_complete",
diff --git a/src/pullbox/models/__init__.py b/src/pullbox/models/__init__.py
index 4f16f6c6..584cddbe 100644
--- a/src/pullbox/models/__init__.py
+++ b/src/pullbox/models/__init__.py
@@ -5,6 +5,7 @@
"""
from pullbox.models.airdcpp import AirDcppAcquisition, AirDcppClientSettings
+from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication
from pullbox.models.audit_log import AuditEventType, AuditLog
from pullbox.models.base import Base, IdentityMixin, TimestampMixin
from pullbox.models.blocklist import BlocklistEntry, BlocklistReason
@@ -59,7 +60,25 @@
LibraryRootPolicySource,
MatchConfidence,
)
+from pullbox.models.library_conversion import LibraryConversion
+from pullbox.models.library_removal import LibraryRemoval
from pullbox.models.matching_suggestion import MatchingSuggestion, SuggestionStatus
+from pullbox.models.metadata_baseline import (
+ IssueMetadataBaseline,
+ SeriesMetadataBaseline,
+ StoryArcMetadataBaseline,
+)
+from pullbox.models.metadata_catalog_checkpoint import SeriesCatalogCheckpoint
+from pullbox.models.metadata_identity import (
+ IssueExternalIdentity,
+ IssueIdentityEvent,
+ SeriesExternalIdentity,
+ SeriesIdentityEvent,
+ StoryArcIdentityEvent,
+)
+from pullbox.models.metadata_series_retry import MetadataSeriesRetry
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.models.metadata_source_account import MetadataSourceAccount
from pullbox.models.operation_progress import (
OperationProgress,
OperationProgressState,
@@ -116,6 +135,7 @@
"APIKey",
"AirDcppAcquisition",
"AirDcppClientSettings",
+ "ArchiveMetadataPublication",
"AuditEventType",
"AuditLog",
"Base",
@@ -169,6 +189,9 @@
"Issue",
"IssueCatalogState",
"IssueCreator",
+ "IssueExternalIdentity",
+ "IssueIdentityEvent",
+ "IssueMetadataBaseline",
"IssueReaderState",
"IssueStatus",
"IssueStoryArc",
@@ -176,7 +199,9 @@
"ItemState",
"JobState",
"JobType",
+ "LibraryConversion",
"LibraryFile",
+ "LibraryRemoval",
"LibraryRoot",
"LibraryRootPolicy",
"LibraryRootPolicySource",
@@ -184,6 +209,9 @@
"MatchConfidence",
"MatchingSuggestion",
"MetadataProviderCacheEntry",
+ "MetadataSeriesRetry",
+ "MetadataSourceAccount",
+ "MetadataSourceConfig",
"OperationProgress",
"OperationProgressState",
"OperationProgressTone",
@@ -196,12 +224,18 @@
"SearchLog",
"SearchType",
"Series",
+ "SeriesCatalogCheckpoint",
+ "SeriesExternalIdentity",
+ "SeriesIdentityEvent",
+ "SeriesMetadataBaseline",
"SeriesStatus",
"SeriesStatusOverride",
"SeriesType",
"StoryArc",
"StoryArcExternalIdentity",
+ "StoryArcIdentityEvent",
"StoryArcLifecycle",
+ "StoryArcMetadataBaseline",
"StoryArcPlacement",
"StoryArcPlacementMode",
"StoryArcPlacementOwnership",
diff --git a/src/pullbox/models/archive_metadata_publication.py b/src/pullbox/models/archive_metadata_publication.py
new file mode 100644
index 00000000..af4c8504
--- /dev/null
+++ b/src/pullbox/models/archive_metadata_publication.py
@@ -0,0 +1,54 @@
+"""Retained publication intent, independent of the lifetime of a library row."""
+
+from enum import StrEnum
+
+from sqlalchemy import CheckConstraint, Enum, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin
+
+
+class PublicationState(StrEnum):
+ INTENDED = "intended"
+ PUBLISHED = "published"
+ ABANDONED = "abandoned"
+ REVIEW = "review"
+ FINALIZED = "finalized"
+ SETTLED = "settled"
+
+
+class ArchiveMetadataPublication(Base, IdentityMixin, TimestampMixin):
+ __tablename__ = "archive_metadata_publications"
+ __table_args__ = (
+ CheckConstraint("revision > 0", name="ck_archive_publication_revision"),
+ CheckConstraint(
+ "length(plan_json) BETWEEN 2 AND 4194304", name="ck_archive_publication_size"
+ ),
+ CheckConstraint(
+ "(state IN ('abandoned','finalized','settled') AND active_file_id IS NULL AND "
+ "active_path_key IS NULL) OR "
+ "(state NOT IN ('abandoned','finalized','settled') AND active_path_key IS NOT NULL)",
+ name="ck_archive_publication_reservation",
+ ),
+ )
+
+ operation_id: Mapped[str] = mapped_column(String(36), unique=True)
+ library_file_id: Mapped[int | None] = mapped_column(
+ ForeignKey("library_files.id", ondelete="SET NULL")
+ )
+ active_file_id: Mapped[int | None] = mapped_column(
+ ForeignKey("library_files.id", ondelete="SET NULL"), unique=True
+ )
+ active_path_key: Mapped[str | None] = mapped_column(String(64), unique=True)
+ revision: Mapped[int] = mapped_column(Integer, default=1)
+ state: Mapped[PublicationState] = mapped_column(
+ Enum(
+ PublicationState,
+ native_enum=False,
+ create_constraint=True,
+ values_callable=lambda enum: [item.value for item in enum],
+ name="archive_publication_state",
+ ),
+ default=PublicationState.INTENDED,
+ )
+ plan_json: Mapped[str] = mapped_column(Text)
diff --git a/src/pullbox/models/base.py b/src/pullbox/models/base.py
index e3a56266..e659899f 100644
--- a/src/pullbox/models/base.py
+++ b/src/pullbox/models/base.py
@@ -21,8 +21,9 @@ class UTCDateTime(TypeDecorator[datetime]):
so Pydantic serializes it with +00:00/Z and downstream code never
has to guess whether a timestamp is UTC or local.
- Write path: strips tzinfo before storage (SQLite ignores it anyway).
- Read path: attaches UTC tzinfo to naive datetimes coming from SQLite.
+ SQLite stores naive UTC. PostgreSQL receives aware UTC so its timestamptz
+ driver cannot interpret a naive value in the process's local timezone.
+ Naive input and database values are interpreted as UTC, never local time.
"""
impl = DateTime(timezone=True)
@@ -32,9 +33,8 @@ def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime |
"""Normalize to UTC before storing."""
if value is None:
return None
- if value.tzinfo is not None:
- return value.astimezone(UTC).replace(tzinfo=None)
- return value
+ normalized = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
+ return normalized if dialect.name == "postgresql" else normalized.replace(tzinfo=None)
def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None:
"""Tag naive datetimes from the DB as UTC."""
@@ -42,7 +42,7 @@ def process_result_value(self, value: datetime | None, dialect: Any) -> datetime
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
- return value
+ return value.astimezone(UTC)
class Base(DeclarativeBase):
diff --git a/src/pullbox/models/import_job.py b/src/pullbox/models/import_job.py
index 3a5a4a5d..43a07bf7 100644
--- a/src/pullbox/models/import_job.py
+++ b/src/pullbox/models/import_job.py
@@ -477,6 +477,7 @@ class ImportedFile(Base, IdentityMixin, TimestampMixin):
Index("ix_import_files_job_series", "import_job_id", "import_series_id"),
Index("ix_import_files_import_series_id", "import_series_id"),
Index("ix_import_files_matched_issue_id", "matched_issue_id"),
+ Index("ix_import_files_library_file_id", "library_file_id"),
Index("ix_import_files_duplicate_of_file_id", "duplicate_of_file_id"),
Index(
"ix_import_files_job_cohort_order",
diff --git a/src/pullbox/models/library_conversion.py b/src/pullbox/models/library_conversion.py
new file mode 100644
index 00000000..39019f84
--- /dev/null
+++ b/src/pullbox/models/library_conversion.py
@@ -0,0 +1,27 @@
+"""Retained conversion evidence independent of the registered file's lifetime."""
+
+from sqlalchemy import Boolean, CheckConstraint, ForeignKey, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin
+
+
+class LibraryConversion(Base, IdentityMixin, TimestampMixin):
+ __tablename__ = "library_conversions"
+ __table_args__ = (
+ CheckConstraint(
+ "state IN ('intended','registered','complete','abandoned','review')",
+ name="ck_library_conversion_state",
+ ),
+ CheckConstraint(
+ "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_conversion_plan_size"
+ ),
+ )
+
+ operation_id: Mapped[str] = mapped_column(String(36), unique=True)
+ library_file_id: Mapped[int | None] = mapped_column(
+ ForeignKey("library_files.id", ondelete="SET NULL")
+ )
+ active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
+ state: Mapped[str] = mapped_column(String(10), default="intended")
+ plan_json: Mapped[str] = mapped_column(Text)
diff --git a/src/pullbox/models/library_removal.py b/src/pullbox/models/library_removal.py
new file mode 100644
index 00000000..80d688ac
--- /dev/null
+++ b/src/pullbox/models/library_removal.py
@@ -0,0 +1,32 @@
+"""Retained removal authorization and private staging evidence."""
+
+from sqlalchemy import Boolean, CheckConstraint, Index, String, Text, false
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin
+
+
+class LibraryRemoval(Base, IdentityMixin, TimestampMixin):
+ __tablename__ = "library_removals"
+ __table_args__ = (
+ Index("ix_library_removal_workspace_pending", "workspace_cleaned", "active", "id"),
+ CheckConstraint(
+ "state IN ('intended','detached','complete','abandoned','review')",
+ name="ck_library_removal_state",
+ ),
+ CheckConstraint(
+ "length(plan_json) BETWEEN 2 AND 65536", name="ck_library_removal_plan_size"
+ ),
+ CheckConstraint(
+ "cleanup_json IS NULL OR length(cleanup_json) BETWEEN 2 AND 4096",
+ name="ck_library_removal_cleanup_size",
+ ),
+ )
+
+ operation_id: Mapped[str] = mapped_column(String(36), unique=True)
+ active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
+ state: Mapped[str] = mapped_column(String(10), default="intended")
+ plan_json: Mapped[str] = mapped_column(Text)
+ cleanup_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+ trash_path_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ workspace_cleaned: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
diff --git a/src/pullbox/models/metadata_baseline.py b/src/pullbox/models/metadata_baseline.py
new file mode 100644
index 00000000..05e8b8e1
--- /dev/null
+++ b/src/pullbox/models/metadata_baseline.py
@@ -0,0 +1,43 @@
+"""Last committed canonical values; not an ownership or provider-response cache."""
+
+from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin
+
+
+class _BaselineFields:
+ revision: Mapped[int] = mapped_column(Integer, nullable=False)
+ snapshot_json: Mapped[str] = mapped_column(Text, nullable=False)
+
+
+def _constraints(kind: str) -> tuple[CheckConstraint | UniqueConstraint, ...]:
+ return (
+ UniqueConstraint(f"{kind}_id", name=f"uq_{kind}_metadata_baseline_target"),
+ CheckConstraint("revision > 0", name=f"ck_{kind}_metadata_baseline_revision"),
+ CheckConstraint(
+ "length(snapshot_json) BETWEEN 2 AND 1048576",
+ name=f"ck_{kind}_metadata_baseline_size",
+ ),
+ )
+
+
+class SeriesMetadataBaseline(Base, IdentityMixin, TimestampMixin, _BaselineFields):
+ __tablename__ = "series_metadata_baselines"
+ __table_args__ = _constraints("series")
+
+ series_id: Mapped[int] = mapped_column(ForeignKey("series.id", ondelete="CASCADE"))
+
+
+class IssueMetadataBaseline(Base, IdentityMixin, TimestampMixin, _BaselineFields):
+ __tablename__ = "issue_metadata_baselines"
+ __table_args__ = _constraints("issue")
+
+ issue_id: Mapped[int] = mapped_column(ForeignKey("issues.id", ondelete="CASCADE"))
+
+
+class StoryArcMetadataBaseline(Base, IdentityMixin, TimestampMixin, _BaselineFields):
+ __tablename__ = "story_arc_metadata_baselines"
+ __table_args__ = _constraints("story_arc")
+
+ story_arc_id: Mapped[int] = mapped_column(ForeignKey("story_arcs.id", ondelete="CASCADE"))
diff --git a/src/pullbox/models/metadata_catalog_checkpoint.py b/src/pullbox/models/metadata_catalog_checkpoint.py
new file mode 100644
index 00000000..6af5a6d8
--- /dev/null
+++ b/src/pullbox/models/metadata_catalog_checkpoint.py
@@ -0,0 +1,44 @@
+"""Source-specific catalog progress; not metadata identity or provenance."""
+
+from datetime import datetime
+
+from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime
+
+
+class SeriesCatalogCheckpoint(Base, IdentityMixin, TimestampMixin):
+ __tablename__ = "series_catalog_checkpoints"
+ __table_args__ = (
+ UniqueConstraint("series_id", "source", name="uq_catalog_checkpoint_series_source"),
+ CheckConstraint(
+ "revision > 0 AND source_revision > 0 AND identity_revision > 0",
+ name="ck_catalog_checkpoint_revisions",
+ ),
+ CheckConstraint("length(external_id) BETWEEN 1 AND 255", name="ck_catalog_checkpoint_id"),
+ CheckConstraint("full_synced_at <= checked_at", name="ck_catalog_checkpoint_time"),
+ CheckConstraint(
+ "source NOT IN ('comicvine_local', 'gcd_local') OR source_updated_at IS NOT NULL",
+ name="ck_catalog_checkpoint_generation",
+ ),
+ Index("ix_catalog_checkpoint_source", "source"),
+ Index("ix_catalog_checkpoint_identity", "identity_id"),
+ )
+
+ series_id: Mapped[int] = mapped_column(
+ ForeignKey("series.id", ondelete="CASCADE"), nullable=False
+ )
+ source: Mapped[str] = mapped_column(
+ String(30), ForeignKey("metadata_source_configs.source", ondelete="CASCADE"), nullable=False
+ )
+ source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
+ identity_id: Mapped[int] = mapped_column(
+ ForeignKey("series_external_identities.id", ondelete="CASCADE"), nullable=False
+ )
+ identity_revision: Mapped[int] = mapped_column(Integer, nullable=False)
+ external_id: Mapped[str] = mapped_column(String(255), nullable=False)
+ revision: Mapped[int] = mapped_column(Integer, nullable=False)
+ checked_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
+ full_synced_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
+ source_updated_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
diff --git a/src/pullbox/models/metadata_identity.py b/src/pullbox/models/metadata_identity.py
new file mode 100644
index 00000000..8f074403
--- /dev/null
+++ b/src/pullbox/models/metadata_identity.py
@@ -0,0 +1,148 @@
+"""Provider ownership and retained evidence, independent of provider configuration."""
+
+from __future__ import annotations
+
+from datetime import datetime # noqa: TC003 - SQLAlchemy resolves mapped types at runtime
+from typing import TYPE_CHECKING
+
+from sqlalchemy import (
+ CheckConstraint,
+ ForeignKey,
+ Index,
+ Integer,
+ String,
+ Text,
+ UniqueConstraint,
+ func,
+)
+from sqlalchemy import Enum as SQLAlchemyEnum
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.core.metadata_identity import IdentityEvidenceKind, IdentityNamespace
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime
+
+if TYPE_CHECKING:
+ import enum
+
+
+def _stored_enum(kind: type[enum.Enum]) -> SQLAlchemyEnum:
+ return SQLAlchemyEnum(
+ kind,
+ native_enum=False,
+ create_constraint=True,
+ values_callable=lambda members: [member.value for member in members],
+ )
+
+
+def _decimal_id_check() -> str:
+ remaining = "external_id"
+ for digit in "0123456789":
+ remaining = f"replace({remaining}, '{digit}', '')"
+ return (
+ "length(external_id) BETWEEN 1 AND 255 AND "
+ "substr(external_id, 1, 1) IN ('1','2','3','4','5','6','7','8','9') AND "
+ f"{remaining} = ''"
+ )
+
+
+class _ClaimFields:
+ identity_namespace: Mapped[IdentityNamespace] = mapped_column(
+ _stored_enum(IdentityNamespace), nullable=False
+ )
+ external_id: Mapped[str] = mapped_column(String(255), nullable=False)
+ verification_state: Mapped[IdentityVerificationState] = mapped_column(
+ _stored_enum(IdentityVerificationState), nullable=False
+ )
+ evidence_kind: Mapped[IdentityEvidenceKind] = mapped_column(
+ _stored_enum(IdentityEvidenceKind), nullable=False
+ )
+
+
+class _OwnershipFields(_ClaimFields):
+ resource_url: Mapped[str | None] = mapped_column(String(1000))
+ evidence_locator: Mapped[str | None] = mapped_column(Text)
+ verified_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
+ last_seen_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
+ revision: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
+
+
+def _ownership_constraints(kind: str) -> tuple[UniqueConstraint | CheckConstraint, ...]:
+ prefix = f"{kind}_external_identities"
+ return (
+ UniqueConstraint("identity_namespace", "external_id", name=f"uq_{prefix}_owner"),
+ UniqueConstraint(f"{kind}_id", "identity_namespace", name=f"uq_{prefix}_namespace"),
+ CheckConstraint(
+ "verification_state IN ('verified', 'stale', 'conflicted')",
+ name=f"ck_{prefix}_active_state",
+ ),
+ CheckConstraint(_decimal_id_check(), name=f"ck_{prefix}_decimal_id"),
+ CheckConstraint("revision > 0", name=f"ck_{prefix}_revision"),
+ )
+
+
+class SeriesExternalIdentity(Base, IdentityMixin, TimestampMixin, _OwnershipFields):
+ __tablename__ = "series_external_identities"
+ __table_args__ = _ownership_constraints("series")
+
+ series_id: Mapped[int] = mapped_column(
+ ForeignKey("series.id", ondelete="CASCADE"), nullable=False
+ )
+
+
+class IssueExternalIdentity(Base, IdentityMixin, TimestampMixin, _OwnershipFields):
+ __tablename__ = "issue_external_identities"
+ __table_args__ = _ownership_constraints("issue")
+
+ issue_id: Mapped[int] = mapped_column(
+ ForeignKey("issues.id", ondelete="CASCADE"), nullable=False
+ )
+
+
+class _EventFields(_ClaimFields):
+ # Evidence events have no mutable timestamp; writers append new decisions.
+ event_key: Mapped[str] = mapped_column(String(64), nullable=False)
+ request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
+ request_json: Mapped[str] = mapped_column(Text, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ UTCDateTime, server_default=func.now(), nullable=False
+ )
+
+
+def _event_constraints(kind: str) -> tuple[UniqueConstraint | CheckConstraint | Index, ...]:
+ prefix = f"{kind}_identity_events"
+ return (
+ UniqueConstraint(f"{kind}_id", "event_key", name=f"uq_{prefix}_retry"),
+ CheckConstraint(_decimal_id_check(), name=f"ck_{prefix}_decimal_id"),
+ CheckConstraint("length(event_key) = 64", name=f"ck_{prefix}_event_key"),
+ CheckConstraint("length(request_fingerprint) = 64", name=f"ck_{prefix}_fingerprint"),
+ CheckConstraint("length(request_json) BETWEEN 2 AND 8192", name=f"ck_{prefix}_request"),
+ Index(f"ix_{prefix}_claim", f"{kind}_id", "identity_namespace", "external_id", "id"),
+ )
+
+
+class SeriesIdentityEvent(Base, IdentityMixin, _EventFields):
+ __tablename__ = "series_identity_events"
+ __table_args__ = _event_constraints("series")
+
+ series_id: Mapped[int] = mapped_column(
+ ForeignKey("series.id", ondelete="CASCADE"), nullable=False
+ )
+
+
+class IssueIdentityEvent(Base, IdentityMixin, _EventFields):
+ __tablename__ = "issue_identity_events"
+ __table_args__ = _event_constraints("issue")
+
+ issue_id: Mapped[int] = mapped_column(
+ ForeignKey("issues.id", ondelete="CASCADE"), nullable=False
+ )
+
+
+class StoryArcIdentityEvent(Base, IdentityMixin, _EventFields):
+ __tablename__ = "story_arc_identity_events"
+ __table_args__ = _event_constraints("story_arc")
+
+ story_arc_id: Mapped[int] = mapped_column(
+ ForeignKey("story_arcs.id", ondelete="CASCADE"), nullable=False
+ )
diff --git a/src/pullbox/models/metadata_series_retry.py b/src/pullbox/models/metadata_series_retry.py
new file mode 100644
index 00000000..562959d2
--- /dev/null
+++ b/src/pullbox/models/metadata_series_retry.py
@@ -0,0 +1,42 @@
+"""Deferred scheduled metadata work, separate from successful catalog progress."""
+
+from datetime import datetime
+
+from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime
+
+
+class MetadataSeriesRetry(Base, IdentityMixin, TimestampMixin):
+ __tablename__ = "metadata_series_retries"
+ __table_args__ = (
+ UniqueConstraint("task_id", "series_id", "source", name="uq_metadata_series_retry"),
+ CheckConstraint(
+ "task_id IN ('sync_new_issues','refresh_metadata')", name="ck_metadata_retry_task"
+ ),
+ CheckConstraint(
+ "source IN ('*','comicvine_local','comicvine_api','metron_api',"
+ "'gcd_local','gcd_api_v2')",
+ name="ck_metadata_retry_source",
+ ),
+ CheckConstraint("revision > 0", name="ck_metadata_retry_revision"),
+ CheckConstraint(
+ "status IN ('rate_limited','timeout','unavailable','authentication_failed')",
+ name="ck_metadata_retry_status",
+ ),
+ CheckConstraint(
+ "(status = 'authentication_failed' AND retry_at IS NULL) OR "
+ "(status != 'authentication_failed' AND retry_at IS NOT NULL)",
+ name="ck_metadata_retry_deadline",
+ ),
+ Index("ix_metadata_retry_due", "task_id", "retry_at", "series_id"),
+ )
+
+ task_id: Mapped[str] = mapped_column(String(30), nullable=False)
+ series_id: Mapped[int] = mapped_column(ForeignKey("series.id", ondelete="CASCADE"))
+ source: Mapped[str] = mapped_column(String(30), nullable=False)
+ config_key: Mapped[str] = mapped_column(String(64), nullable=False)
+ status: Mapped[str] = mapped_column(String(30), nullable=False)
+ retry_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
+ revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
diff --git a/src/pullbox/models/metadata_source.py b/src/pullbox/models/metadata_source.py
new file mode 100644
index 00000000..27dc7235
--- /dev/null
+++ b/src/pullbox/models/metadata_source.py
@@ -0,0 +1,35 @@
+"""Source configuration is independent of durable provider identities."""
+
+from datetime import datetime
+
+from sqlalchemy import JSON, Boolean, CheckConstraint, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime
+
+
+class MetadataSourceConfig(Base, IdentityMixin, TimestampMixin):
+ __tablename__ = "metadata_source_configs"
+ __table_args__ = (
+ CheckConstraint(
+ "source IN ('comicvine_local','comicvine_api','metron_api','gcd_local','gcd_api_v2')",
+ name="ck_metadata_source_slug",
+ ),
+ CheckConstraint("priority BETWEEN 0 AND 1000", name="ck_metadata_source_priority"),
+ CheckConstraint("revision > 0", name="ck_metadata_source_revision"),
+ CheckConstraint(
+ "credential_secret IS NULL OR source IN ('metron_api','gcd_api_v2')",
+ name="ck_metadata_source_credential_owner",
+ ),
+ )
+
+ source: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
+ enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)
+ priority: Mapped[int] = mapped_column(Integer, nullable=False)
+ domain_priorities: Mapped[dict[str, int]] = mapped_column(JSON, default=dict, nullable=False)
+ settings: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
+ credential_secret: Mapped[str | None] = mapped_column(Text)
+ revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
+ last_tested_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
+ last_success_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
+ last_status: Mapped[str | None] = mapped_column(String(40))
diff --git a/src/pullbox/models/metadata_source_account.py b/src/pullbox/models/metadata_source_account.py
new file mode 100644
index 00000000..876f28e7
--- /dev/null
+++ b/src/pullbox/models/metadata_source_account.py
@@ -0,0 +1,34 @@
+"""Bounded account admission state, independent of catalog or entity progress."""
+
+from datetime import datetime
+
+from sqlalchemy import CheckConstraint, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime
+
+
+class MetadataSourceAccount(Base, IdentityMixin, TimestampMixin):
+ __tablename__ = "metadata_source_accounts"
+ __table_args__ = (
+ CheckConstraint(
+ "source IN ('comicvine_api','metron_api','gcd_api_v2')",
+ name="ck_metadata_account_source",
+ ),
+ CheckConstraint("revision > 0", name="ck_metadata_account_revision"),
+ CheckConstraint(
+ "(status IS NULL AND retry_at IS NULL AND lease_until IS NULL) OR "
+ "(status IS NOT NULL AND status = 'authentication_failed' "
+ "AND retry_at IS NULL) OR "
+ "(status IS NOT NULL AND status IN ('rate_limited','timeout','unavailable') "
+ "AND retry_at IS NOT NULL)",
+ name="ck_metadata_account_state",
+ ),
+ )
+
+ source: Mapped[str] = mapped_column(String(30), nullable=False, unique=True)
+ account_key: Mapped[str] = mapped_column(String(64), nullable=False)
+ revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
+ status: Mapped[str | None] = mapped_column(String(30))
+ retry_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
+ lease_until: Mapped[datetime | None] = mapped_column(UTCDateTime)
diff --git a/src/pullbox/models/series.py b/src/pullbox/models/series.py
index 9caa638e..0d65394c 100644
--- a/src/pullbox/models/series.py
+++ b/src/pullbox/models/series.py
@@ -116,6 +116,9 @@ class Series(Base, IdentityMixin, TimestampMixin):
issue_catalog_last_synced_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
issue_catalog_last_checked_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
issue_catalog_error: Mapped[str | None] = mapped_column(Text)
+ catalog_exclusions: Mapped[list[dict[str, str]]] = mapped_column(
+ JSON, default=list, server_default="[]"
+ )
# Monitoring — binary on/off, controls auto-search and issue status transitions
monitored: Mapped[bool] = mapped_column(default=False, index=True)
diff --git a/src/pullbox/models/story_arc.py b/src/pullbox/models/story_arc.py
index 3363fd14..39199423 100644
--- a/src/pullbox/models/story_arc.py
+++ b/src/pullbox/models/story_arc.py
@@ -17,10 +17,13 @@
Text,
UniqueConstraint,
event,
+ text,
)
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
+from pullbox.core.metadata_identity import IdentityEvidenceKind
+from pullbox.core.metadata_identity_state import IdentityVerificationState
from pullbox.core.story_arc_identity import normalize_story_arc_name
from pullbox.models.base import Base, IdentityMixin, TimestampMixin, UTCDateTime
@@ -311,6 +314,23 @@ class StoryArcExternalIdentity(Base, IdentityMixin, TimestampMixin):
name="uq_story_arc_external_identity",
),
Index("ix_story_arc_external_identities_arc_id", "story_arc_id", "id"),
+ Index(
+ "uq_story_arc_canonical_provider",
+ "story_arc_id",
+ "source",
+ unique=True,
+ sqlite_where=text(
+ "namespace = 'story_arc' AND source IN ('comicvine','metron','gcd','locg')"
+ ),
+ postgresql_where=text(
+ "namespace = 'story_arc' AND source IN ('comicvine','metron','gcd','locg')"
+ ),
+ ),
+ CheckConstraint(
+ "verification_state IN ('verified','stale','conflicted')",
+ name="ck_story_arc_identity_active_state",
+ ),
+ CheckConstraint("revision > 0", name="ck_story_arc_identity_revision"),
)
story_arc_id: Mapped[int] = mapped_column(
@@ -323,6 +343,17 @@ class StoryArcExternalIdentity(Base, IdentityMixin, TimestampMixin):
evidence: Mapped[dict] = mapped_column( # type: ignore[type-arg]
JSON, default=dict, server_default="{}", nullable=False
)
+ # Scoped import references are not provider ownership and keep null proof.
+ verification_state: Mapped[IdentityVerificationState | None] = mapped_column(
+ story_arc_enum(IdentityVerificationState)
+ )
+ evidence_kind: Mapped[IdentityEvidenceKind | None] = mapped_column(
+ story_arc_enum(IdentityEvidenceKind)
+ )
+ evidence_locator: Mapped[str | None] = mapped_column(Text)
+ verified_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
+ last_seen_at: Mapped[datetime | None] = mapped_column(UTCDateTime)
+ revision: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
story_arc: Mapped[StoryArc] = relationship(back_populates="external_identities")
diff --git a/src/pullbox/providers/metadata/comicvine.py b/src/pullbox/providers/metadata/comicvine.py
index c1d00281..8632500a 100644
--- a/src/pullbox/providers/metadata/comicvine.py
+++ b/src/pullbox/providers/metadata/comicvine.py
@@ -16,6 +16,7 @@
import re
import time
import weakref
+from dataclasses import replace
from typing import TYPE_CHECKING, Any
import httpx
@@ -319,6 +320,24 @@ def _issue_metadata_from_item(
)
+def _strict_issue_metadata(item: dict[str, Any]) -> IssueMetadata:
+ if not isinstance(item, dict):
+ raise ValueError("Invalid ComicVine issue detail")
+ volume, number = item.get("volume"), item.get("issue_number")
+ if (
+ re.fullmatch(r"[1-9][0-9]{0,18}", str(item.get("id"))) is None
+ or not isinstance(volume, dict)
+ or re.fullmatch(r"[1-9][0-9]{0,18}", str(volume.get("id"))) is None
+ or not isinstance(number, str)
+ or not number.strip()
+ or len(number) > 320
+ ):
+ raise ValueError("Invalid ComicVine issue identity")
+ return replace(
+ _issue_metadata_from_item(item, fallback_provider_id=""), issue_number_text=number.strip()
+ )
+
+
def _normalize_bulk_provider_ids(provider_ids: Sequence[str]) -> list[str]:
if isinstance(provider_ids, (str, bytes)) or len(provider_ids) > _MAX_BULK_PROVIDER_IDS:
raise ValueError("ComicVine batch requests require at most 5000 provider IDs")
@@ -396,10 +415,12 @@ def __init__(
*,
retryable: bool = False,
retry_after_seconds: int | None = None,
+ timed_out: bool = False,
) -> None:
self.status_code = status_code
self.retryable = retryable
self.retry_after_seconds = retry_after_seconds
+ self.timed_out = timed_out
super().__init__(message)
@@ -495,6 +516,7 @@ async def _request(
0,
f"Request timed out: {endpoint}",
retryable=True,
+ timed_out=True,
) from None
except httpx.HTTPStatusError as exc:
http_status = exc.response.status_code
@@ -600,6 +622,7 @@ async def search_series_page(
limit: int = 20,
offset: int = 0,
suppress_errors: bool = True,
+ strict_response: bool = False,
) -> tuple[list[SeriesSearchResult], int]:
"""Search for series and return the current page plus ComicVine's total."""
log = logger.bind(query=query, year=year, limit=limit, offset=offset)
@@ -624,6 +647,13 @@ async def search_series_page(
return [], 0
raise
+ if strict_response and (
+ not isinstance(data.get("results"), list)
+ or type(data.get("number_of_total_results")) is not int
+ or data["number_of_total_results"] < 0
+ ):
+ raise ValueError("Invalid ComicVine search response")
+
total_results = _safe_int(data.get("number_of_total_results")) or 0
results: list[SeriesSearchResult] = []
@@ -639,6 +669,47 @@ async def search_series_page(
log.debug("comicvine_search_results", count=len(results), total_results=total_results)
return results, total_results
+ async def search_series_candidates_page(
+ self,
+ query: str,
+ *,
+ limit: int = _GLOBAL_SERIES_SEARCH_BATCH_SIZE,
+ offset: int = 0,
+ strict_response: bool = True,
+ ) -> tuple[list[SeriesSearchResult], int]:
+ """Read one volume-filter page without hiding failures as empty matches."""
+ if type(limit) is not int or not 1 <= limit <= _GLOBAL_SERIES_SEARCH_BATCH_SIZE:
+ raise ValueError("Invalid ComicVine candidate page size")
+ if type(offset) is not int or offset < 0 or (strict_response and offset > 10000):
+ raise ValueError("Invalid ComicVine candidate offset")
+ filter_value = _comicvine_name_filter(query)
+ if not filter_value:
+ return [], 0
+ data = await self._request(
+ "/volumes/",
+ {
+ "filter": filter_value,
+ "field_list": (
+ "id,name,start_year,publisher,count_of_issues,image,"
+ "description,deck,site_detail_url"
+ ),
+ "sort": "date_last_updated:desc",
+ "limit": limit,
+ "offset": offset,
+ },
+ )
+ if strict_response and (
+ not isinstance(data.get("results"), list)
+ or type(data.get("number_of_total_results")) is not int
+ or data["number_of_total_results"] < 0
+ ):
+ raise ValueError("Invalid ComicVine candidate response")
+ items = list(data.get("results", []))
+ total = _safe_int(data.get("number_of_total_results")) or 0
+ if strict_response and (len(items) > limit or (items and total < offset + len(items))):
+ raise ValueError("Inconsistent ComicVine candidate page")
+ return [_series_search_result_from_item(item) for item in items], total
+
async def search_series_globally(
self,
query: str,
@@ -687,7 +758,6 @@ async def search_series_globally(
inflight = asyncio.create_task(
self._fetch_global_series_search(
normalized_query,
- filter_value,
normalized_max,
normalized_batch,
cache_key,
@@ -714,7 +784,6 @@ def discard_finished_search(
async def _fetch_global_series_search(
self,
normalized_query: str,
- filter_value: str,
normalized_max: int,
normalized_batch: int,
cache_key: tuple[str, int, int],
@@ -729,15 +798,6 @@ async def _fetch_global_series_search(
)
log.debug("comicvine_global_series_search")
- params_base: dict[str, Any] = {
- "filter": filter_value,
- "field_list": (
- "id,name,start_year,publisher,count_of_issues,image,"
- "description,deck,site_detail_url"
- ),
- "sort": "date_last_updated:desc",
- }
-
results: list[SeriesSearchResult] = []
seen_ids: set[str] = set()
total_results = 0
@@ -745,13 +805,10 @@ async def _fetch_global_series_search(
while offset < normalized_max:
limit = min(normalized_batch, normalized_max - offset)
- params = {
- **params_base,
- "limit": limit,
- "offset": offset,
- }
try:
- data = await self._request("/volumes/", params)
+ items, page_total = await self.search_series_candidates_page(
+ normalized_query, limit=limit, offset=offset, strict_response=False
+ )
except ComicVineError:
log.exception("comicvine_global_series_search_failed")
if suppress_errors:
@@ -759,14 +816,11 @@ async def _fetch_global_series_search(
raise
if offset == 0:
- total_results = _safe_int(data.get("number_of_total_results")) or 0
-
- items = list(data.get("results", []))
+ total_results = page_total
if not items:
break
- for item in items:
- result = _series_search_result_from_item(item)
+ for result in items:
if result.provider_id in seen_ids:
continue
seen_ids.add(result.provider_id)
@@ -798,7 +852,9 @@ async def _fetch_global_series_search(
)
return result_tuple, effective_total
- async def get_series(self, provider_id: str) -> SeriesMetadata:
+ async def get_series(
+ self, provider_id: str, *, strict_response: bool = False
+ ) -> SeriesMetadata:
"""Get full series (volume) metadata by ComicVine volume ID."""
if re.fullmatch(r"[1-9][0-9]{0,18}", provider_id) is None:
raise ValueError("ComicVine provider IDs must be positive integers")
@@ -816,6 +872,14 @@ async def get_series(self, provider_id: str) -> SeriesMetadata:
data = await self._request(f"/volume/{_VOLUME_PREFIX}-{resource_id}/", params)
item: dict[str, Any] = data.get("results", {})
+ if strict_response and (
+ not isinstance(item, dict)
+ or str(item.get("id")) != provider_id
+ or not isinstance(item.get("name"), str)
+ or not item["name"].strip()
+ ):
+ raise ValueError("Invalid ComicVine series detail")
+
return _series_metadata_from_item(item, fallback_provider_id=provider_id)
async def get_series_batch(self, provider_ids: Sequence[str]) -> dict[str, SeriesMetadata]:
@@ -845,7 +909,7 @@ async def get_series_batch(self, provider_ids: Sequence[str]) -> dict[str, Serie
provider_id: found[provider_id] for provider_id in normalized if provider_id in found
}
- async def get_issue(self, provider_id: str) -> IssueMetadata:
+ async def get_issue(self, provider_id: str, *, strict_response: bool = False) -> IssueMetadata:
"""Get full issue metadata by ComicVine issue ID."""
if re.fullmatch(r"[1-9][0-9]{0,18}", provider_id) is None:
raise ValueError("ComicVine provider IDs must be positive integers")
@@ -864,8 +928,51 @@ async def get_issue(self, provider_id: str) -> IssueMetadata:
data = await self._request(f"/issue/{_ISSUE_PREFIX}-{resource_id}/", params)
item: dict[str, Any] = data.get("results", {})
+ if strict_response:
+ metadata = _strict_issue_metadata(item)
+ if metadata.provider_id != provider_id:
+ raise ValueError("Different ComicVine issue identity")
+ return metadata
return _issue_metadata_from_item(item, fallback_provider_id=provider_id)
+ async def get_issues_page(
+ self, series_provider_id: str, *, page: int = 1, newest_first: bool = False
+ ) -> tuple[list[IssueMetadata], int]:
+ """Read one complete page with explicit parent identity, without auto-pagination."""
+ if re.fullmatch(r"[1-9][0-9]{0,18}", series_provider_id) is None:
+ raise ValueError("ComicVine provider IDs must be positive integers")
+ if type(page) is not int or not 1 <= page <= 10000:
+ raise ValueError("Invalid ComicVine page")
+ if type(newest_first) is not bool or (newest_first and page != 1):
+ raise ValueError("Recent ComicVine reads are limited to one page")
+ offset = (page - 1) * 100
+ data = await self._request(
+ "/issues/",
+ {
+ "filter": f"volume:{series_provider_id}",
+ "field_list": (
+ "id,volume,issue_number,name,cover_date,store_date,image,site_detail_url"
+ ),
+ "sort": "store_date:desc" if newest_first else "id:asc",
+ "limit": 100,
+ "offset": offset,
+ },
+ )
+ total, rows = data.get("number_of_total_results"), data.get("results")
+ if (
+ type(total) is not int
+ or not 0 <= total <= 1_000_000_000
+ or not isinstance(rows, list)
+ or len(rows) != min(100, max(0, total - offset))
+ ):
+ raise ValueError("Incomplete ComicVine issue page")
+ issues = [_strict_issue_metadata(row) for row in rows]
+ if any(issue.series_provider_id != series_provider_id for issue in issues) or len(
+ {issue.provider_id for issue in issues}
+ ) != len(issues):
+ raise ValueError("Invalid ComicVine issue membership")
+ return issues, total
+
async def get_issue_batch(self, provider_ids: Sequence[str]) -> dict[str, IssueMetadata]:
"""Fetch full issue metadata in bounded ID-filter batches."""
normalized = _normalize_bulk_provider_ids(provider_ids)
diff --git a/src/pullbox/providers/metadata/comicvine_normalization.py b/src/pullbox/providers/metadata/comicvine_normalization.py
new file mode 100644
index 00000000..f7ad1fc3
--- /dev/null
+++ b/src/pullbox/providers/metadata/comicvine_normalization.py
@@ -0,0 +1,97 @@
+"""Normalize legacy ComicVine reads without inventing source identities."""
+
+from datetime import date
+
+from pullbox.core.issue_numbers import normalize_issue_number_text
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.providers.base import IssueMetadata, SeriesMetadata
+from pullbox.schemas.metadata_credits import parse_credits
+from pullbox.schemas.metadata_sources import MetadataPage, ProviderIssueRead, ProviderSeriesRead
+from pullbox.services.catalog.reader import CatalogIssueMetadata, CatalogSeriesMetadata
+from pullbox.services.metadata_source_reads import source_id
+
+
+def _text(value: str | None, limit: int = 500) -> str | None:
+ if value is not None and (not isinstance(value, str) or len(value) > limit):
+ raise ValueError("Invalid ComicVine text")
+ return value
+
+
+def _date(value: str | None) -> date | None:
+ if not value:
+ return None
+ if not isinstance(value, str) or len(value) != 10:
+ raise ValueError("Invalid ComicVine date")
+ return date.fromisoformat(value)
+
+
+def series(source: MetadataSource, row: SeriesMetadata, identifier: str) -> ProviderSeriesRead:
+ from pullbox.providers.metadata.sources import _image_url
+
+ identity = source_id(source, MetadataEntityKind.SERIES, row.provider_id)
+ title = _text(row.title)
+ if identity != identifier or not title or not title.strip():
+ raise ValueError("Different ComicVine series")
+ return ProviderSeriesRead(
+ source=source,
+ identity_namespace=source.identity_namespace,
+ external_id=identity,
+ title=title,
+ sort_title=_text(row.sort_title),
+ year_start=row.year_start,
+ year_end=row.year_end,
+ publisher=_text(row.publisher),
+ issue_count=row.issue_count,
+ description=_text(row.description, 20000),
+ image_url=_image_url(row.cover_url),
+ resource_url=f"https://comicvine.gamespot.com/volume/4050-{identity}/",
+ status=row.status,
+ source_updated_at=row.source_cutoff_at if isinstance(row, CatalogSeriesMetadata) else None,
+ )
+
+
+def issue(source: MetadataSource, row: IssueMetadata) -> ProviderIssueRead:
+ from pullbox.providers.metadata.sources import _image_url
+
+ identity = source_id(source, MetadataEntityKind.ISSUE, row.provider_id)
+ parent = source_id(source, MetadataEntityKind.SERIES, row.series_provider_id)
+ number = _text(row.issue_number_text, 320)
+ if not number or not number.strip():
+ raise ValueError("Missing ComicVine issue designation")
+ try:
+ key = normalize_issue_number_text(number)
+ except ValueError:
+ key = None
+ return ProviderIssueRead(
+ credits=parse_credits(
+ [{"name": credit.get("name"), "role": credit.get("role")} for credit in row.creators]
+ )
+ if row.creators
+ else None,
+ source=source,
+ identity_namespace=source.identity_namespace,
+ external_id=identity,
+ series_external_id=parent,
+ issue_number_text=number,
+ issue_number_key=key,
+ title=_text(row.title),
+ description=_text(row.description, 20000),
+ cover_date=_date(row.release_date),
+ store_date=_date(row.store_date),
+ page_count=row.page_count,
+ image_url=_image_url(row.cover_url),
+ resource_url=f"https://comicvine.gamespot.com/issue/4000-{identity}/",
+ source_updated_at=row.source_cutoff_at if isinstance(row, CatalogIssueMetadata) else None,
+ )
+
+
+def issue_page(
+ source: MetadataSource, rows: list[IssueMetadata], total: int, page: int
+) -> MetadataPage[ProviderIssueRead]:
+ more = page * 100 < total
+ return MetadataPage(
+ results=[issue(source, row) for row in rows],
+ total=total,
+ next_page=page + 1 if more and page < 10000 else None,
+ truncated=more and page == 10000,
+ )
diff --git a/src/pullbox/providers/metadata/gcd_local.py b/src/pullbox/providers/metadata/gcd_local.py
new file mode 100644
index 00000000..2b1bcc30
--- /dev/null
+++ b/src/pullbox/providers/metadata/gcd_local.py
@@ -0,0 +1,267 @@
+"""GCD local series discovery and exact catalog reads, without cover or crosswalk claims."""
+
+from __future__ import annotations
+
+import html
+import re
+from datetime import date
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from sqlalchemy import case, func, not_, or_, select
+
+from pullbox.core.issue_numbers import normalize_issue_number_text
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.providers.metadata.gcd_local_database import (
+ ISSUE,
+ LANGUAGE,
+ PUBLISHER,
+ SERIES,
+ GcdDatabaseError,
+ GcdSnapshot,
+ assert_current,
+ disk_read,
+ open_readonly,
+ rows,
+)
+from pullbox.schemas.metadata_sources import (
+ MetadataFetch,
+ MetadataPage,
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ SeriesDiscoveryQuery,
+ SourceStatus,
+)
+from pullbox.services.metadata_discovery import MetadataSourceError, SourcePage
+from pullbox.services.metadata_source_reads import MAX_PAGE, PAGE_SIZE, page_number, source_id
+
+if TYPE_CHECKING:
+ import sqlite3
+ import threading
+ from collections.abc import Callable
+
+ from sqlalchemy.sql import Select
+
+SOURCE = MetadataSource.GCD_LOCAL
+BASE = ISSUE.alias("base_issue")
+ISSUE_FROM = ISSUE.outerjoin(BASE, ISSUE.c.variant_of_id == BASE.c.id)
+CANONICAL = or_(ISSUE.c.variant_of_id.is_(None), BASE.c.series_id != ISSUE.c.series_id)
+# Official dumps lack planner statistics. Boolean NOT avoids the low-selectivity
+# deleted index, letting these per-series reads use the existing series index.
+ISSUE_PUBLIC = not_(ISSUE.c.deleted)
+PUBLIC = (SERIES.c.deleted == 0) & (SERIES.c.is_comics_publication == 1)
+SERIES_FROM = SERIES.outerjoin(
+ PUBLISHER, (PUBLISHER.c.id == SERIES.c.publisher_id) & (PUBLISHER.c.deleted == 0)
+).outerjoin(LANGUAGE, LANGUAGE.c.id == SERIES.c.language_id)
+
+
+def issue_query() -> Select[Any]:
+ return select(ISSUE).select_from(ISSUE_FROM).where(ISSUE_PUBLIC, CANONICAL)
+
+
+def series_query() -> Select[Any]:
+ count = (
+ select(func.count())
+ .select_from(ISSUE_FROM)
+ .where(
+ ISSUE.c.series_id == SERIES.c.id,
+ ISSUE_PUBLIC,
+ CANONICAL,
+ )
+ .correlate(SERIES)
+ .scalar_subquery()
+ )
+ return (
+ select(
+ SERIES,
+ PUBLISHER.c.name.label("publisher"),
+ LANGUAGE.c.code.label("language"),
+ count.label("issue_count"),
+ )
+ .select_from(SERIES_FROM)
+ .where(PUBLIC)
+ )
+
+
+def _date(value: str | None) -> date | None:
+ if value:
+ try:
+ return date.fromisoformat(value)
+ except ValueError:
+ pass # GCD legitimately stores partial dates, including YYYY-MM-00.
+ return None
+
+
+def _series(row: sqlite3.Row, snapshot: GcdSnapshot) -> ProviderSeriesRead:
+ return ProviderSeriesRead(
+ source=SOURCE,
+ identity_namespace=SOURCE.identity_namespace,
+ external_id=str(row["id"]),
+ title=row["name"],
+ sort_title=row["sort_name"],
+ year_start=row["year_began"] or None,
+ year_end=row["year_ended"] or None,
+ publisher=row["publisher"],
+ issue_count=row["issue_count"],
+ language=row["language"],
+ status="continuing" if row["is_current"] else "ended",
+ description=html.escape(row["notes"][:20000]) if row["notes"] else None,
+ resource_url=f"https://www.comics.org/series/{row['id']}/",
+ source_updated_at=snapshot.validated_at,
+ )
+
+
+def _issue(row: sqlite3.Row, snapshot: GcdSnapshot) -> ProviderIssueRead:
+ number = row["number"]
+ try:
+ key = normalize_issue_number_text(number)
+ except ValueError:
+ key = None
+ pages = row["page_count"]
+ return ProviderIssueRead(
+ source=SOURCE,
+ identity_namespace=SOURCE.identity_namespace,
+ external_id=str(row["id"]),
+ series_external_id=str(row["series_id"]),
+ issue_number_text=number,
+ issue_number_key=key,
+ title=row["title"] or None,
+ description=html.escape(row["notes"][:20000]) if row["notes"] else None,
+ cover_date=_date(row["key_date"]),
+ store_date=_date(row["on_sale_date"]),
+ page_count=int(pages) if pages is not None and pages >= 0 and int(pages) == pages else None,
+ resource_url=f"https://www.comics.org/issue/{row['id']}/",
+ source_updated_at=snapshot.validated_at,
+ )
+
+
+class GcdLocalSource:
+ def __init__(self, snapshot: GcdSnapshot | None) -> None:
+ if snapshot is None:
+ raise MetadataSourceError(SourceStatus.UNCONFIGURED)
+ self.snapshot = snapshot
+
+ async def _read[T](self, operation: Callable[[sqlite3.Connection], T]) -> T:
+ def read(stop: threading.Event, deadline: float) -> T:
+ assert_current(self.snapshot)
+ with open_readonly(Path(self.snapshot.path), stop, deadline) as db:
+ result = operation(db)
+ assert_current(self.snapshot)
+ return result
+
+ try:
+ return await disk_read(read)
+ except GcdDatabaseError as exc:
+ raise MetadataSourceError(exc.status) from exc
+
+ async def check(self) -> None:
+ await self._read(lambda db: rows(db, series_query().limit(1)))
+
+ async def cache_token(self) -> str:
+ return await self._read(
+ lambda db: self.snapshot.sha256 + ":" + self.snapshot.validated_at.isoformat()
+ )
+
+ async def close(self) -> None:
+ return None
+
+ async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage:
+ terms = re.findall(r"\w+", query.query, flags=re.UNICODE)[:16]
+ if not terms:
+ return SourcePage([], 0)
+ conditions = [SERIES.c.name.icontains(term, autoescape=True) for term in terms]
+ if query.year is not None:
+ conditions.append(SERIES.c.year_began == query.year)
+
+ def read(db: sqlite3.Connection) -> SourcePage:
+ total = rows(db, select(func.count()).select_from(SERIES).where(PUBLIC, *conditions))[
+ 0
+ ][0]
+ found = rows(
+ db,
+ series_query()
+ .where(*conditions)
+ .order_by(
+ case((func.lower(SERIES.c.name) == query.query.lower(), 0), else_=1),
+ SERIES.c.sort_name,
+ SERIES.c.year_began,
+ SERIES.c.id,
+ )
+ .limit(query.limit_per_source)
+ .offset(offset),
+ )
+ more = offset + len(found) < total
+ next_offset = (
+ offset + query.limit_per_source
+ if more and offset + query.limit_per_source <= 10000
+ else None
+ )
+ return SourcePage(
+ [_series(row, self.snapshot) for row in found],
+ total,
+ next_offset,
+ truncated=more and next_offset is None,
+ )
+
+ return await self._read(read)
+
+ async def series(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderSeriesRead]:
+ identifier = int(source_id(SOURCE, MetadataEntityKind.SERIES, external_id))
+ found = await self._read(
+ lambda db: rows(db, series_query().where(SERIES.c.id == identifier))
+ )
+ return MetadataFetch(
+ status=SourceStatus.OK if found else SourceStatus.NOT_FOUND,
+ data=_series(found[0], self.snapshot) if found else None,
+ )
+
+ async def issues(
+ self, external_id: str, *, page: int = 1, validator: str | None = None
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ identifier = int(source_id(SOURCE, MetadataEntityKind.SERIES, external_id))
+ page_number(page)
+
+ def read(db: sqlite3.Connection) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ profile = rows(db, series_query().where(SERIES.c.id == identifier))
+ if not profile:
+ return MetadataFetch(status=SourceStatus.NOT_FOUND)
+ total = profile[0]["issue_count"]
+ found = rows(
+ db,
+ issue_query()
+ .where(ISSUE.c.series_id == identifier)
+ .order_by(ISSUE.c.sort_code, ISSUE.c.id)
+ .limit(PAGE_SIZE)
+ .offset((page - 1) * PAGE_SIZE),
+ )
+ more = page * PAGE_SIZE < total
+ return MetadataFetch(
+ status=SourceStatus.OK,
+ data=MetadataPage(
+ results=[_issue(row, self.snapshot) for row in found],
+ total=total,
+ next_page=page + 1 if more and page < MAX_PAGE else None,
+ truncated=more and page == MAX_PAGE,
+ ),
+ )
+
+ return await self._read(read)
+
+ async def issue(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderIssueRead]:
+ identifier = int(source_id(SOURCE, MetadataEntityKind.ISSUE, external_id))
+ found = await self._read(
+ lambda db: rows(
+ db,
+ issue_query()
+ .join(SERIES, SERIES.c.id == ISSUE.c.series_id)
+ .where(ISSUE.c.id == identifier, PUBLIC),
+ )
+ )
+ return MetadataFetch(
+ status=SourceStatus.OK if found else SourceStatus.NOT_FOUND,
+ data=_issue(found[0], self.snapshot) if found else None,
+ )
diff --git a/src/pullbox/providers/metadata/gcd_local_database.py b/src/pullbox/providers/metadata/gcd_local_database.py
new file mode 100644
index 00000000..1601e685
--- /dev/null
+++ b/src/pullbox/providers/metadata/gcd_local_database.py
@@ -0,0 +1,227 @@
+"""Read-only GCD dump boundary; no migrations, copies or application DB attachment."""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import sqlite3
+import stat
+import threading
+import time
+from contextlib import contextmanager, suppress
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy import column, table
+from sqlalchemy.dialects.sqlite import dialect
+
+from pullbox.schemas.metadata_sources import SourceStatus
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator
+
+ from sqlalchemy.sql import Select
+
+
+class GcdDatabaseError(ValueError):
+ """Safe operator-facing failure; never embeds a path or SQLite error text."""
+
+ def __init__(self, message: str, status: SourceStatus = SourceStatus.INVALID_CONFIG) -> None:
+ self.status = status
+ super().__init__(message)
+
+
+class GcdSnapshot(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+ profile: Literal["gcd-sqlite-2026-09"] = "gcd-sqlite-2026-09"
+ path: str
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
+ signature: tuple[int, int, int, int, int]
+ validated_at: datetime
+
+
+# Minimal semantic profile, derived from the official 2026-09-29 dump and
+# GCD's Series.active_base_issues(). Additive dump columns are deliberately ignored.
+SERIES = table(
+ "gcd_series",
+ *map(
+ column,
+ (
+ "id",
+ "name",
+ "sort_name",
+ "year_began",
+ "year_ended",
+ "publisher_id",
+ "language_id",
+ "is_current",
+ "deleted",
+ "is_comics_publication",
+ "notes",
+ ),
+ ),
+)
+ISSUE = table(
+ "gcd_issue",
+ *map(
+ column,
+ (
+ "id",
+ "series_id",
+ "number",
+ "title",
+ "sort_code",
+ "deleted",
+ "variant_of_id",
+ "notes",
+ "key_date",
+ "on_sale_date",
+ "page_count",
+ ),
+ ),
+)
+PUBLISHER = table("gcd_publisher", *map(column, ("id", "name", "deleted")))
+LANGUAGE = table("stddata_language", *map(column, ("id", "code")))
+PROFILE_TABLES = (SERIES, ISSUE, PUBLISHER, LANGUAGE)
+_READ_SLOTS = threading.BoundedSemaphore(2)
+_VALIDATION_SLOT = threading.BoundedSemaphore(1)
+
+
+def signature(path: Path) -> tuple[int, int, int, int, int]:
+ if not path.is_absolute() or ".." in path.parts or any(ord(c) < 32 for c in str(path)):
+ raise GcdDatabaseError("Enter an absolute path to a GCD SQLite dump.")
+ if any(parent.is_symlink() for parent in (path, *path.parents)):
+ raise GcdDatabaseError("Use the real GCD database path, not a symbolic link.")
+ info = path.stat()
+ if not stat.S_ISREG(info.st_mode) or info.st_size < 100:
+ raise GcdDatabaseError("Choose a complete, regular GCD SQLite database file.")
+ if any(Path(str(path) + suffix).exists() for suffix in ("-wal", "-journal")):
+ raise GcdDatabaseError(
+ "Use a closed SQLite dump, not a database being written by another application."
+ )
+ return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns
+
+
+def assert_current(snapshot: GcdSnapshot) -> None:
+ if signature(Path(snapshot.path)) != snapshot.signature:
+ raise GcdDatabaseError(
+ "The GCD database changed. Validate and enable it again in Metadata settings."
+ )
+
+
+@contextmanager
+def open_readonly(
+ path: Path, stop: threading.Event, deadline: float
+) -> Iterator[sqlite3.Connection]:
+ db = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=0.25)
+ try:
+ db.execute("PRAGMA query_only=ON")
+ db.execute("PRAGMA trusted_schema=OFF")
+ db.set_progress_handler(lambda: int(stop.is_set() or time.monotonic() >= deadline), 1000)
+ db.row_factory = sqlite3.Row
+ yield db
+ finally:
+ db.close()
+
+
+def rows(db: sqlite3.Connection, query: Select[Any]) -> list[sqlite3.Row]:
+ # SQLAlchemy Core owns the query and binds; sqlite3 is confined to the
+ # off-loop, read-only external-file boundary rather than the app database.
+ compiled = query.compile(dialect=dialect(paramstyle="named"))
+ return db.execute(str(compiled), compiled.params).fetchall()
+
+
+async def disk_read[T](
+ operation: Callable[[threading.Event, float], T], *, validation: bool = False
+) -> T:
+ stop = threading.Event()
+ slots = _VALIDATION_SLOT if validation else _READ_SLOTS
+ if not slots.acquire(blocking=False):
+ raise GcdDatabaseError(
+ "GCD is busy. Wait for the current operation, then retry.", SourceStatus.UNAVAILABLE
+ )
+ deadline = time.monotonic() + (300 if validation else 5)
+
+ def run() -> T:
+ try:
+ return operation(stop, deadline)
+ finally:
+ slots.release()
+
+ task = asyncio.create_task(asyncio.to_thread(run))
+ try:
+ return await asyncio.shield(task)
+ except asyncio.CancelledError:
+ stop.set()
+ with suppress(sqlite3.Error, GcdDatabaseError, OSError):
+ await task
+ raise
+ except (sqlite3.Error, OSError) as exc:
+ if stop.is_set() or time.monotonic() >= deadline:
+ raise GcdDatabaseError(
+ "GCD validation or reading timed out. Check the storage and retry.",
+ SourceStatus.TIMEOUT,
+ ) from exc
+ raise GcdDatabaseError(
+ "The GCD database cannot be read. Check the mount, permissions and dump, then retry.",
+ SourceStatus.UNAVAILABLE,
+ ) from exc
+
+
+async def validate_candidate(value: str | None) -> GcdSnapshot:
+ if not value:
+ raise GcdDatabaseError("Enter the GCD SQLite database path before enabling it.")
+ path = Path(value)
+
+ def validate(stop: threading.Event, deadline: float) -> GcdSnapshot:
+ initial = signature(path)
+ with path.open("rb") as stream:
+ if stream.read(16) != b"SQLite format 3\x00":
+ raise GcdDatabaseError(
+ "This is not a SQLite database. Obtain the official GCD SQLite dump."
+ )
+ with open_readonly(path, stop, deadline) as db:
+ for expected in PROFILE_TABLES:
+ kind = db.execute(
+ "SELECT type,sql FROM sqlite_schema WHERE name=?", (expected.name,)
+ ).fetchone()
+ if kind is None or kind[0] != "table" or "VIRTUAL TABLE" in kind[1].upper():
+ raise GcdDatabaseError(
+ "This SQLite dump does not have the supported GCD schema."
+ )
+ actual = {
+ row[0]
+ for row in db.execute("SELECT name FROM pragma_table_info(?)", (expected.name,))
+ }
+ if not set(expected.c.keys()) <= actual:
+ raise GcdDatabaseError(
+ "This GCD dump is missing required columns. "
+ "Obtain a current official SQLite dump."
+ )
+ if [row[0] for row in db.execute("PRAGMA quick_check(1)")] != ["ok"]:
+ raise GcdDatabaseError(
+ "The GCD database failed its integrity check. Replace the dump and retry."
+ )
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ while chunk := stream.read(4 * 1024 * 1024):
+ if stop.is_set() or time.monotonic() >= deadline:
+ raise GcdDatabaseError(
+ "GCD validation was stopped. The previous database is unchanged."
+ )
+ digest.update(chunk)
+ if signature(path) != initial:
+ raise GcdDatabaseError(
+ "The GCD database changed during validation. "
+ "Wait for the file transfer to finish, then retry."
+ )
+ return GcdSnapshot(
+ path=str(path),
+ sha256=digest.hexdigest(),
+ signature=initial,
+ validated_at=datetime.now(UTC),
+ )
+
+ return await disk_read(validate, validation=True)
diff --git a/src/pullbox/providers/metadata/metron.py b/src/pullbox/providers/metadata/metron.py
new file mode 100644
index 00000000..ffcc202c
--- /dev/null
+++ b/src/pullbox/providers/metadata/metron.py
@@ -0,0 +1,433 @@
+"""Bounded, read-only Metron HTTP adapter; no implicit identity attachment."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import math
+import time
+from dataclasses import dataclass
+from email.utils import parsedate_to_datetime
+from typing import TYPE_CHECKING
+from urllib.parse import parse_qsl, urlsplit
+
+import httpx
+
+from pullbox import __version__
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.core.provider_cooldown import ProviderCooldown, provider_cooldown, retry_after_seconds
+from pullbox.providers.metadata import metron_normalization as normalize
+from pullbox.schemas.metadata_sources import (
+ MetadataFetch,
+ MetadataPage,
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ RecentIssueWindow,
+ SourceStatus,
+)
+from pullbox.services.metadata_discovery import MetadataSourceError, SourcePage
+from pullbox.services.metadata_source_reads import issue_checkpoint, validate_recent_issue_window
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from datetime import datetime
+
+ from pydantic import SecretStr
+
+ from pullbox.schemas.metadata_sources import SeriesDiscoveryQuery
+
+_MAX_BYTES = 2 * 1024 * 1024
+_PAGE_SIZE = 100
+_REQUEST_TIMEOUT = 8.0
+
+
+@dataclass(frozen=True)
+class _Response:
+ status: SourceStatus
+ payload: object = None
+ validator: str | None = None
+
+
+def _json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
+ result = dict(pairs)
+ if len(result) != len(pairs):
+ raise ValueError("Duplicate JSON field")
+ return result
+
+
+def _json_constant(value: str) -> None:
+ raise ValueError("Nonstandard JSON constant")
+
+
+def _validator(value: str | None) -> str | None:
+ if value is not None:
+ if len(value) > 128 or "\r" in value or "\n" in value:
+ raise ValueError("Invalid refresh validator")
+ when = parsedate_to_datetime(value)
+ if when.tzinfo is None:
+ raise ValueError("Refresh validator must include a timezone")
+ return value
+
+
+def _page_number(page: int) -> None:
+ if type(page) is not int or not 1 <= page <= 10000:
+ raise ValueError("Metron page must be between one and ten thousand")
+
+
+def _metadata_page[T](rows: list[T], total: int, next_page: int | None) -> MetadataPage[T]:
+ truncated = next_page is not None and next_page > 10000
+ return MetadataPage(
+ results=rows, total=total, next_page=None if truncated else next_page, truncated=truncated
+ )
+
+
+def _envelope(
+ payload: object, path: str, params: dict[str, str]
+) -> tuple[list[object], int, int | None]:
+ row = normalize.object_row(payload)
+ total, rows = row.get("count"), row.get("results")
+ if type(total) is not int or not 0 <= total <= 1_000_000_000 or not isinstance(rows, list):
+ raise ValueError("Invalid pagination envelope")
+ page = int(params["page"])
+ remaining = max(0, total - (page - 1) * _PAGE_SIZE)
+ if len(rows) != min(_PAGE_SIZE, remaining):
+ raise ValueError("Incomplete source page")
+ next_page = page + 1 if remaining > _PAGE_SIZE else None
+ link = row.get("next")
+ if next_page is None:
+ if link is not None:
+ raise ValueError("Unexpected continuation")
+ else:
+ if not isinstance(link, str) or len(link) > 4096:
+ raise ValueError("Missing continuation")
+ url = urlsplit(link)
+ query = parse_qsl(url.query, keep_blank_values=True, max_num_fields=10)
+ if (
+ url.scheme != "https"
+ or url.hostname != "metron.cloud"
+ or url.port not in {None, 443}
+ or url.username is not None
+ or url.password is not None
+ or url.fragment
+ or url.path != f"/api/{path}"
+ or len(query) != len(dict(query))
+ or dict(query) != {**params, "page": str(next_page)}
+ or any(char.isspace() or ord(char) < 32 for char in link)
+ ):
+ raise ValueError("Invalid continuation")
+ return rows, total, next_page
+
+
+class MetronSource:
+ def __init__(
+ self,
+ token: SecretStr,
+ *,
+ transport: httpx.AsyncBaseTransport | None = None,
+ cooldown: ProviderCooldown | None = None,
+ minimum_interval: float = 3.0,
+ ) -> None:
+ credential = token.get_secret_value()
+ if not credential:
+ raise MetadataSourceError(SourceStatus.UNCONFIGURED)
+ if (
+ len(credential) > 4096
+ or not credential.isascii()
+ or credential.startswith("enc:")
+ or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in credential)
+ ):
+ raise MetadataSourceError(SourceStatus.INVALID_CONFIG)
+ if not math.isfinite(minimum_interval) or not 0 <= minimum_interval <= 3:
+ raise ValueError("Invalid request spacing")
+ self.cooldown = cooldown or provider_cooldown("metron", credential)
+ self.minimum_interval = minimum_interval
+ self.client = httpx.AsyncClient(
+ base_url="https://metron.cloud/api/",
+ headers={
+ "Authorization": f"Bearer {credential}",
+ "User-Agent": f"Pullbox/{__version__}",
+ "Accept": "application/json",
+ },
+ timeout=httpx.Timeout(_REQUEST_TIMEOUT, connect=4),
+ limits=httpx.Limits(max_connections=2, max_keepalive_connections=1),
+ follow_redirects=False,
+ transport=transport,
+ )
+
+ def _rate_headers(self, headers: httpx.Headers) -> None:
+ for scope in ("Burst", "Sustained"):
+ try:
+ if headers.get(f"X-RateLimit-{scope}-Remaining") == "0":
+ reset = float(headers[f"X-RateLimit-{scope}-Reset"]) - time.time()
+ if math.isfinite(reset) and reset > 0:
+ self.cooldown.defer(min(reset, 7 * 86400))
+ except (ValueError, KeyError):
+ pass
+
+ async def _exchange(
+ self, path: str, params: dict[str, str], validator: str | None
+ ) -> _Response:
+ headers = {"If-Modified-Since": validator} if validator else {}
+ async with self.client.stream("GET", path, params=params, headers=headers) as response:
+ self._rate_headers(response.headers)
+ status = response.status_code
+ if status in {401, 403}:
+ raise MetadataSourceError(SourceStatus.AUTHENTICATION_FAILED)
+ if status == 429:
+ self.cooldown.defer(
+ retry_after_seconds(response.headers.get("Retry-After"), default=60)
+ )
+ raise MetadataSourceError(
+ SourceStatus.RATE_LIMITED, self.cooldown.remaining_seconds
+ )
+ if status in {500, 502, 503, 504}:
+ if "Retry-After" in response.headers:
+ self.cooldown.defer(
+ retry_after_seconds(response.headers["Retry-After"], default=1)
+ )
+ raise MetadataSourceError(
+ SourceStatus.UNAVAILABLE, self.cooldown.remaining_seconds or None
+ )
+ if status == 404:
+ return _Response(SourceStatus.NOT_FOUND)
+ if status == 304 and validator:
+ return _Response(SourceStatus.NOT_MODIFIED, validator=validator)
+ if (
+ status != 200
+ or response.headers.get("content-type", "").split(";")[0] != "application/json"
+ ):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE)
+ body = bytearray()
+ async for chunk in response.aiter_bytes():
+ if len(body) + len(chunk) > _MAX_BYTES:
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE)
+ body.extend(chunk)
+ try:
+ payload = json.loads(
+ body, object_pairs_hook=_json_object, parse_constant=_json_constant
+ )
+ modified = _validator(response.headers.get("Last-Modified"))
+ except (ValueError, TypeError, OverflowError, RecursionError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+ return _Response(SourceStatus.OK, payload, modified)
+
+ async def _get(
+ self, path: str, params: dict[str, str] | None = None, validator: str | None = None
+ ) -> _Response:
+ _validator(validator)
+ try:
+ async with asyncio.timeout(_REQUEST_TIMEOUT):
+ for attempt in range(2):
+ try:
+ async with self.cooldown.request_lock:
+ if self.cooldown.remaining_seconds:
+ raise MetadataSourceError(
+ SourceStatus.RATE_LIMITED, self.cooldown.remaining_seconds
+ )
+ delay = self.minimum_interval - (
+ time.monotonic() - self.cooldown.last_request_time
+ )
+ if delay > 0:
+ await asyncio.sleep(delay)
+ self.cooldown.last_request_time = time.monotonic()
+ return await self._exchange(path, params or {}, validator)
+ except MetadataSourceError as exc:
+ if (
+ exc.status is not SourceStatus.UNAVAILABLE
+ or exc.retry_after_seconds
+ or attempt
+ ):
+ raise
+ except httpx.TimeoutException:
+ raise MetadataSourceError(SourceStatus.TIMEOUT) from None
+ except (httpx.NetworkError, httpx.RemoteProtocolError):
+ if attempt:
+ raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None
+ await asyncio.sleep(0.1)
+ except TimeoutError:
+ raise MetadataSourceError(SourceStatus.TIMEOUT) from None
+ except httpx.HTTPError:
+ raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None
+ raise MetadataSourceError(SourceStatus.UNAVAILABLE)
+
+ async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage:
+ limit = query.limit_per_source
+ if type(offset) is not int or not 0 <= offset <= 10000 or offset % limit:
+ raise ValueError("Invalid source offset")
+ params = {"name": query.query, "page": str(offset // _PAGE_SIZE + 1)}
+ if query.year is not None:
+ params["year_began"] = str(query.year)
+ results = []
+ rejected = 0
+ consumed = 0
+ seen = set()
+ total = None
+ try:
+ for _ in range(2):
+ response = await self._get("series/", params)
+ if response.status is not SourceStatus.OK:
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE)
+ rows, current_total, next_page = _envelope(response.payload, "series/", params)
+ if total is not None and total != current_total:
+ raise ValueError("Source changed between pages")
+ total = current_total
+ start = offset % _PAGE_SIZE if not consumed else 0
+ selected = rows[start : start + limit - consumed]
+ consumed += len(selected)
+ for row in selected:
+ try:
+ result = normalize.series(row)
+ if result.external_id in seen:
+ raise ValueError("Repeated source identity")
+ seen.add(result.external_id)
+ results.append(result)
+ except (ValueError, TypeError):
+ rejected += 1
+ if consumed >= limit or next_page is None:
+ break
+ params["page"] = str(next_page)
+ more = total is not None and offset + consumed < total
+ next_offset = offset + limit if more and offset + limit <= 10000 else None
+ return SourcePage(results, total, next_offset, rejected, more and next_offset is None)
+ except (ValueError, TypeError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+
+ async def check(self) -> None:
+ from pullbox.schemas.metadata_sources import SeriesDiscoveryQuery
+
+ page = await self.search(SeriesDiscoveryQuery(query="test", limit_per_source=1), 0)
+ if page.rejected_results:
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE)
+
+ async def close(self) -> None:
+ await self.client.aclose()
+
+ async def _detail[T: (ProviderSeriesRead, ProviderIssueRead, ProviderStoryArcRead)](
+ self,
+ path: str,
+ identifier: str,
+ normalize_row: Callable[[object], T],
+ validator: str | None,
+ ) -> MetadataFetch[T]:
+ identifier = normalize.external_id(identifier)
+ response = await self._get(f"{path}/{identifier}/", validator=validator)
+ if response.status is not SourceStatus.OK:
+ return MetadataFetch(status=response.status, validator=response.validator)
+ try:
+ row = normalize_row(response.payload)
+ if row.external_id != identifier:
+ raise ValueError("Different detail identity")
+ return MetadataFetch(status=SourceStatus.OK, data=row, validator=response.validator)
+ except (ValueError, TypeError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+
+ async def recent_issues(
+ self, external_id: str, *, since: datetime
+ ) -> MetadataFetch[RecentIssueWindow]:
+ identifier = normalize.external_id(external_id)
+ checkpoint = issue_checkpoint(since)
+ params = {"series_id": identifier, "modified_gt": checkpoint.isoformat(), "page": "1"}
+ response = await self._get("issue/", params)
+ if response.status is not SourceStatus.OK:
+ return MetadataFetch(status=response.status)
+ try:
+ rows, total, next_page = _envelope(response.payload, "issue/", params)
+ result = RecentIssueWindow(
+ results=[normalize.issue(row) for row in rows],
+ matched_total=total,
+ scope="modified_since",
+ since=checkpoint,
+ truncated=next_page is not None,
+ )
+ validate_recent_issue_window(result, MetadataSource.METRON_API, identifier, checkpoint)
+ return MetadataFetch(status=SourceStatus.OK, data=result)
+ except (ValueError, TypeError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+
+ async def series(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderSeriesRead]:
+ return await self._detail(
+ "series", external_id, lambda row: normalize.series(row, detail=True), validator
+ )
+
+ async def issue(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderIssueRead]:
+ return await self._detail("issue", external_id, normalize.issue, validator)
+
+ async def story_arc(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderStoryArcRead]:
+ return await self._detail("arc", external_id, normalize.story_arc, validator)
+
+ async def issues(
+ self,
+ external_id: str,
+ *,
+ kind: MetadataEntityKind = MetadataEntityKind.SERIES,
+ page: int = 1,
+ validator: str | None = None,
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ if not isinstance(kind, MetadataEntityKind) or kind not in {
+ MetadataEntityKind.SERIES,
+ MetadataEntityKind.STORY_ARC,
+ }:
+ raise ValueError("Unsupported issue-list parent")
+ identifier = normalize.external_id(external_id)
+ _page_number(page)
+ path = (
+ f"{'series' if kind is MetadataEntityKind.SERIES else 'arc'}/{identifier}/issue_list/"
+ )
+ params = {"page": str(page)}
+ response = await self._get(path, params, validator)
+ if response.status is not SourceStatus.OK:
+ return MetadataFetch(status=response.status, validator=response.validator)
+ try:
+ rows, total, next_page = _envelope(response.payload, path, params)
+ issues = [normalize.issue(row) for row in rows]
+ if len({issue.external_id for issue in issues}) != len(issues) or (
+ kind is MetadataEntityKind.SERIES
+ and any(issue.series_external_id != identifier for issue in issues)
+ ):
+ raise ValueError("Inconsistent issue membership")
+ return MetadataFetch(
+ status=SourceStatus.OK,
+ validator=response.validator,
+ data=_metadata_page(issues, total, next_page),
+ )
+ except (ValueError, TypeError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+
+ async def story_arcs(self, query: str, *, page: int = 1) -> MetadataPage[ProviderStoryArcRead]:
+ if not isinstance(query, str) or not query.strip() or len(query) > 200:
+ raise ValueError("Expected a bounded arc query")
+ _page_number(page)
+ if page > 100:
+ raise ValueError("Story arc search is bounded to 100 source pages")
+ params = {"name": query.strip(), "page": str(page)}
+ response = await self._get("arc/", params)
+ if response.status is not SourceStatus.OK:
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE)
+ try:
+ rows, total, next_page = _envelope(response.payload, "arc/", params)
+ results = [normalize.story_arc(row) for row in rows]
+ if len({row.external_id for row in results}) != len(results):
+ raise ValueError("Repeated source identity")
+ result = _metadata_page(results, total, next_page)
+ if page == 100 and result.next_page is not None:
+ result.next_page, result.truncated = None, True
+ return result
+ except (ValueError, TypeError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+
+ async def story_arc_issues(
+ self, external_id: str, *, page: int = 1, validator: str | None = None
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ _page_number(page)
+ if page > 50:
+ raise ValueError("Story arc membership is bounded to 5000 issues")
+ return await self.issues(
+ external_id, kind=MetadataEntityKind.STORY_ARC, page=page, validator=validator
+ )
diff --git a/src/pullbox/providers/metadata/metron_normalization.py b/src/pullbox/providers/metadata/metron_normalization.py
new file mode 100644
index 00000000..d8e180f0
--- /dev/null
+++ b/src/pullbox/providers/metadata/metron_normalization.py
@@ -0,0 +1,250 @@
+"""Validate Metron serializer shapes before exposing shared metadata DTOs."""
+
+from __future__ import annotations
+
+from datetime import UTC, date, datetime
+from typing import cast
+from urllib.parse import unquote, urlsplit
+
+from pullbox.core.html_sanitizer import sanitize_rich_html
+from pullbox.core.issue_numbers import normalize_issue_number_text
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.schemas.metadata_credits import MAX_CREDITS, MetadataCredit, parse_credits
+from pullbox.schemas.metadata_sources import (
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+)
+
+
+def object_row(value: object) -> dict[str, object]:
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
+ raise ValueError("Expected a metadata object")
+ return cast("dict[str, object]", value)
+
+
+def external_id(value: object) -> str:
+ if type(value) not in {int, str}:
+ raise ValueError("Expected a positive provider identity")
+ return ExternalIdentityRef(
+ IdentityNamespace.METRON, MetadataEntityKind.SERIES, str(value)
+ ).external_id
+
+
+def _text(value: object, *, required: bool = False, limit: int = 500) -> str | None:
+ if value is None and not required:
+ return None
+ if not isinstance(value, str) or len(value) > limit or (required and not value.strip()):
+ raise ValueError("Expected bounded metadata text")
+ return value.strip() or None
+
+
+def _integer(value: object) -> int | None:
+ if value is None:
+ return None
+ if type(value) is not int or not 0 <= value <= 1_000_000_000:
+ raise ValueError("Expected a nonnegative metadata integer")
+ return value
+
+
+def _date(value: object) -> date | None:
+ if value is None:
+ return None
+ if not isinstance(value, str) or len(value) != 10:
+ raise ValueError("Expected an ISO calendar date")
+ return date.fromisoformat(value)
+
+
+def _updated(value: object) -> datetime | None:
+ if value is None:
+ return None
+ if not isinstance(value, str) or len(value) > 48:
+ raise ValueError("Expected a source timestamp")
+ result = datetime.fromisoformat(value)
+ if result.tzinfo is None:
+ raise ValueError("Source timestamp must include its timezone")
+ return result.astimezone(UTC)
+
+
+def _url(value: object, path: str, *, image: bool = False) -> str | None:
+ if not isinstance(value, str) or len(value) > 4096:
+ return None
+ try:
+ url = urlsplit(value)
+ decoded = unquote(url.path)
+ if (
+ url.scheme == "https"
+ and url.hostname
+ in ({"metron.cloud", "static.metron.cloud"} if image else {"metron.cloud"})
+ and url.port in {None, 443}
+ and url.username is None
+ and url.password is None
+ and not url.query
+ and not url.fragment
+ and decoded.startswith(path)
+ and ".." not in decoded.split("/")
+ and "\\" not in decoded
+ and not any(char.isspace() or ord(char) < 32 for char in value + decoded)
+ ):
+ return value
+ except ValueError:
+ pass
+ return None
+
+
+def _crosswalks(row: dict[str, object], kind: MetadataEntityKind) -> list[ExternalIdentityRef]:
+ identities = []
+ for field, namespace in (
+ ("cv_id", IdentityNamespace.COMICVINE),
+ ("gcd_id", IdentityNamespace.GCD),
+ ):
+ if row.get(field) is not None:
+ identities.append(ExternalIdentityRef(namespace, kind, external_id(row[field])))
+ return identities
+
+
+def _description(row: dict[str, object]) -> str | None:
+ value = _text(row.get("desc"), limit=20000)
+ return sanitize_rich_html(value) if value else None
+
+
+def _named(value: object) -> str | None:
+ return _text(object_row(value).get("name"), required=True) if value is not None else None
+
+
+def _series_type(value: object) -> str | None:
+ if value is None:
+ return None
+ row = object_row(value)
+ _named(value)
+ return {
+ 5: "one_shot",
+ 6: "annual",
+ 8: "hardcover",
+ 9: "graphic_novel",
+ 10: "tpb",
+ 11: "standard",
+ 12: "standard",
+ 13: "standard",
+ 14: "omnibus",
+ }.get(_integer(row.get("id")) or 0)
+
+
+def series(value: object, *, detail: bool = False) -> ProviderSeriesRead:
+ row = object_row(value)
+ title = _text(row.get("name" if detail else "series"), required=True)
+ assert title is not None
+ year = _integer(row.get("year_began"))
+ if not detail and year is not None:
+ kind = object_row(row["series_type"]).get("id") if row.get("series_type") else None
+ # These suffixes come from Series.__str__, not from the stored name.
+ suffix = {
+ 12: f" ({year}) Digital",
+ 10: f" TPB ({year})",
+ 8: f" HC ({year})",
+ 9: f" GN ({year})",
+ }.get(_integer(kind) or 0, f" ({year})")
+ title = title.removesuffix(suffix)
+ if not title.strip():
+ raise ValueError("Expected a series title")
+ volume = _integer(row.get("volume"))
+ status = _text(row.get("status"))
+ return ProviderSeriesRead(
+ source=MetadataSource.METRON_API,
+ identity_namespace=IdentityNamespace.METRON,
+ external_id=external_id(row.get("id")),
+ title=title,
+ year_start=year,
+ year_end=_integer(row.get("year_end")),
+ publisher=_named(row.get("publisher")),
+ issue_count=_integer(row.get("issue_count")),
+ description=_description(row),
+ resource_url=_url(row.get("resource_url"), "/series/"),
+ cross_identities=_crosswalks(row, MetadataEntityKind.SERIES),
+ source_updated_at=_updated(row.get("modified")),
+ sort_title=_text(row.get("sort_name")),
+ volume=str(volume) if volume is not None else None,
+ series_type=_series_type(row.get("series_type")),
+ status={
+ "ongoing": "continuing",
+ "completed": "ended",
+ "cancelled": "ended",
+ "hiatus": "unknown",
+ }.get(status.casefold(), "unknown")
+ if status
+ else None,
+ language=_text(row.get("language")),
+ )
+
+
+def _credits(value: object) -> tuple[MetadataCredit, ...] | None:
+ if value is None:
+ return None
+ if not isinstance(value, list) or len(value) > MAX_CREDITS:
+ raise ValueError("Expected bounded creator credits")
+ credits = []
+ for item in value:
+ row = object_row(item)
+ roles = row.get("role")
+ if not isinstance(roles, list) or len(roles) > 32:
+ raise ValueError("Expected bounded creator roles")
+ credits.append(
+ {
+ "name": _text(row.get("creator"), required=True, limit=255),
+ "role": ", ".join(
+ _text(object_row(role).get("name"), required=True, limit=100) or ""
+ for role in roles
+ ),
+ }
+ )
+ return parse_credits(credits)
+
+
+def issue(value: object) -> ProviderIssueRead:
+ row = object_row(value)
+ number = _text(row.get("number"), required=True, limit=100)
+ assert number is not None
+ try:
+ key = normalize_issue_number_text(number)
+ except ValueError:
+ key = None
+ return ProviderIssueRead(
+ credits=_credits(row.get("credits")),
+ source=MetadataSource.METRON_API,
+ identity_namespace=IdentityNamespace.METRON,
+ external_id=external_id(row.get("id")),
+ series_external_id=external_id(object_row(row.get("series")).get("id")),
+ issue_number_text=number,
+ issue_number_key=key,
+ title=_text(row.get("title")),
+ description=_description(row),
+ cover_date=_date(row.get("cover_date")),
+ store_date=_date(row.get("store_date")),
+ page_count=_integer(row.get("page")),
+ resource_url=_url(row.get("resource_url"), "/issue/"),
+ image_url=_url(row.get("image"), "/media/", image=True),
+ cross_identities=_crosswalks(row, MetadataEntityKind.ISSUE),
+ source_updated_at=_updated(row.get("modified")),
+ )
+
+
+def story_arc(value: object) -> ProviderStoryArcRead:
+ row = object_row(value)
+ title = _text(row.get("name"), required=True)
+ assert title is not None
+ return ProviderStoryArcRead(
+ source=MetadataSource.METRON_API,
+ identity_namespace=IdentityNamespace.METRON,
+ external_id=external_id(row.get("id")),
+ title=title,
+ description=_description(row),
+ resource_url=_url(row.get("resource_url"), "/arc/"),
+ image_url=_url(row.get("image"), "/media/", image=True),
+ cross_identities=_crosswalks(row, MetadataEntityKind.STORY_ARC),
+ source_updated_at=_updated(row.get("modified")),
+ )
diff --git a/src/pullbox/providers/metadata/sources.py b/src/pullbox/providers/metadata/sources.py
new file mode 100644
index 00000000..5438c7de
--- /dev/null
+++ b/src/pullbox/providers/metadata/sources.py
@@ -0,0 +1,534 @@
+"""Executable source registrations; keep legacy import/cache consumers unchanged."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, cast
+from urllib.parse import urlsplit
+
+from pullbox.config import get_settings
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource
+from pullbox.providers.metadata import comicvine_normalization as normalize
+from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider
+from pullbox.providers.metadata.gcd_local import GcdLocalSource
+from pullbox.providers.metadata.metron import MetronSource
+from pullbox.schemas.metadata_sources import (
+ MetadataFetch,
+ MetadataPage,
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ RecentIssueWindow,
+ SeriesDiscoveryQuery,
+ SourceCapability,
+ SourceStatus,
+)
+from pullbox.services.catalog.contract import CatalogError
+from pullbox.services.catalog.reader import get_catalog_reader
+from pullbox.services.catalog.storage import disk_work
+from pullbox.services.metadata_discovery import MetadataSourceError, SourcePage, SourceRegistration
+from pullbox.services.metadata_source_reads import (
+ issue_checkpoint,
+ page_number,
+ source_id,
+ validate_recent_issue_window,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable
+ from datetime import datetime
+
+ from pullbox.providers.base import SeriesSearchResult
+ from pullbox.providers.story_arcs import StoryArcMetadata, StoryArcSearchResult
+ from pullbox.services.catalog.reader import CatalogReader
+ from pullbox.services.metadata_sources import SourceRuntime
+
+
+_catalog_reads: dict[asyncio.AbstractEventLoop, set[asyncio.Task[object]]] = {}
+
+
+async def _catalog_read[T](operation: Callable[[], Awaitable[T]]) -> T:
+ """Return promptly on cancellation without spawning unbounded disk workers."""
+ loop = asyncio.get_running_loop()
+ reads = _catalog_reads.setdefault(loop, set())
+ if len(reads) >= 2:
+ raise MetadataSourceError(SourceStatus.UNAVAILABLE, retry_after_seconds=1)
+
+ async def owned_read() -> object:
+ return await operation()
+
+ def finished(task: asyncio.Task[object]) -> None:
+ reads.discard(task)
+ if not task.cancelled():
+ task.exception()
+ if not reads:
+ _catalog_reads.pop(loop, None)
+
+ task = asyncio.create_task(owned_read(), name="metadata-catalog-read")
+ reads.add(task)
+ task.add_done_callback(finished)
+ try:
+ return cast("T", await asyncio.shield(task))
+ except asyncio.CancelledError:
+ # disk_work joins its thread on cancellation. Retain the slot and task
+ # until that owned read finishes; never abandon a database/file handle.
+ task.cancel()
+ raise
+
+
+async def catalog_search_cache_token() -> str | None:
+ """Use the same bounded disk admission as catalog discovery."""
+ return await _catalog_read(get_catalog_reader().cache_token)
+
+
+def _image_url(value: str | None) -> str | None:
+ if not value or len(value) > 4096 or any(char.isspace() for char in value):
+ return None
+ try:
+ parsed = urlsplit(value)
+ host = parsed.hostname or ""
+ if (
+ parsed.scheme == "https"
+ and parsed.port in {None, 443}
+ and not parsed.username
+ and not parsed.password
+ and not parsed.query
+ and not parsed.fragment
+ and (
+ host in {"comicvine.gamespot.com", "comicvine.com"}
+ or host.endswith(".cbsistatic.com")
+ )
+ ):
+ return value
+ except ValueError:
+ pass
+ return None
+
+
+def _page(
+ source: MetadataSource,
+ rows: list[SeriesSearchResult],
+ *,
+ total: int | None,
+ offset: int,
+ limit: int,
+ has_more: bool,
+) -> SourcePage:
+ results = []
+ rejected = 0
+ for row in rows[:limit]:
+ try:
+ identity = ExternalIdentityRef(
+ source.identity_namespace, MetadataEntityKind.SERIES, row.provider_id
+ )
+ if not row.title or not row.title.strip():
+ raise ValueError("A series title is required")
+ results.append(
+ ProviderSeriesRead(
+ source=source,
+ identity_namespace=source.identity_namespace,
+ external_id=identity.external_id,
+ title=row.title[:500],
+ year_start=row.year_start,
+ publisher=row.publisher[:500] if row.publisher else None,
+ issue_count=row.issue_count,
+ description=row.description[:20000] if row.description else None,
+ resource_url=f"https://comicvine.gamespot.com/volume/4050-{identity.external_id}/",
+ image_url=_image_url(row.cover_url),
+ )
+ )
+ except (ValueError, TypeError, AttributeError):
+ rejected += 1
+ next_offset = offset + limit if has_more and offset + limit <= 10000 else None
+ return SourcePage(results, total, next_offset, rejected, has_more and next_offset is None)
+
+
+def _api_error(exc: ComicVineError) -> MetadataSourceError:
+ if exc.timed_out or exc.status_code in {408, 504}:
+ status = SourceStatus.TIMEOUT
+ elif exc.status_code in {100, 401, 403}:
+ status = SourceStatus.AUTHENTICATION_FAILED
+ elif exc.status_code in {107, 420, 429}:
+ status = SourceStatus.RATE_LIMITED
+ else:
+ status = SourceStatus.UNAVAILABLE
+ return MetadataSourceError(status, exc.retry_after_seconds)
+
+
+def _cv_id(value: str, kind: MetadataEntityKind) -> str:
+ identifier = source_id(MetadataSource.COMICVINE_API, kind, value)
+ if int(identifier) >= 2**63:
+ raise ValueError("ComicVine identity exceeds the supported range")
+ return identifier
+
+
+async def _fetch[T](operation: Callable[[], Awaitable[T | None]]) -> MetadataFetch[T]:
+ try:
+ data = await operation()
+ return MetadataFetch(
+ status=SourceStatus.OK if data is not None else SourceStatus.NOT_FOUND, data=data
+ )
+ except ComicVineError as exc:
+ if exc.status_code == 101:
+ return MetadataFetch(status=SourceStatus.NOT_FOUND)
+ raise _api_error(exc) from None
+ except (CatalogError, OSError):
+ raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None
+ except (ValueError, TypeError, AttributeError, KeyError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+
+
+class ComicVineApiSource:
+ def __init__(self, provider: ComicVineProvider) -> None:
+ self.provider = provider
+
+ async def recent_issues(
+ self, external_id: str, *, since: datetime
+ ) -> MetadataFetch[RecentIssueWindow]:
+ identifier = _cv_id(external_id, MetadataEntityKind.SERIES)
+ checkpoint = issue_checkpoint(since)
+
+ async def read() -> RecentIssueWindow:
+ rows, total = await self.provider.get_issues_page(identifier, newest_first=True)
+ result = RecentIssueWindow(
+ results=[normalize.issue(MetadataSource.COMICVINE_API, row) for row in rows],
+ matched_total=total,
+ scope="recent_publication",
+ truncated=total > 100,
+ )
+ validate_recent_issue_window(
+ result, MetadataSource.COMICVINE_API, identifier, checkpoint
+ )
+ return result
+
+ return await _fetch(read)
+
+ async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage:
+ try:
+ if query.search_mode == "full":
+ rows, total = await self.provider.search_series_candidates_page(
+ query.query, limit=query.limit_per_source, offset=offset
+ )
+ else:
+ rows, total = await self.provider.search_series_page(
+ query.query,
+ query.year,
+ limit=query.limit_per_source,
+ offset=offset,
+ suppress_errors=False,
+ strict_response=True,
+ )
+ if total < len(rows) or len(rows) > query.limit_per_source:
+ raise ValueError("Inconsistent source page")
+ return _page(
+ MetadataSource.COMICVINE_API,
+ rows,
+ total=total,
+ offset=offset,
+ limit=query.limit_per_source,
+ has_more=offset + query.limit_per_source < total,
+ )
+ except ComicVineError as exc:
+ raise _api_error(exc) from None
+ except (ValueError, TypeError, AttributeError, KeyError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+
+ async def check(self) -> None:
+ page = await self.search(SeriesDiscoveryQuery(query="test", limit_per_source=1), 0)
+ if page.rejected_results:
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE)
+
+ async def close(self) -> None:
+ await self.provider.close()
+
+ async def series(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderSeriesRead]:
+ identifier = _cv_id(external_id, MetadataEntityKind.SERIES)
+
+ async def read() -> ProviderSeriesRead:
+ row = await self.provider.get_series(identifier, strict_response=True)
+ return normalize.series(MetadataSource.COMICVINE_API, row, identifier)
+
+ return await _fetch(read)
+
+ async def issue(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderIssueRead]:
+ identifier = _cv_id(external_id, MetadataEntityKind.ISSUE)
+
+ async def read() -> ProviderIssueRead:
+ return normalize.issue(
+ MetadataSource.COMICVINE_API,
+ await self.provider.get_issue(identifier, strict_response=True),
+ )
+
+ return await _fetch(read)
+
+ async def issues(
+ self, external_id: str, *, page: int = 1, validator: str | None = None
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ identifier = _cv_id(external_id, MetadataEntityKind.SERIES)
+ page_number(page)
+
+ async def read() -> MetadataPage[ProviderIssueRead]:
+ rows, total = await self.provider.get_issues_page(identifier, page=page)
+ return normalize.issue_page(MetadataSource.COMICVINE_API, rows, total, page)
+
+ return await _fetch(read)
+
+ @staticmethod
+ def _arc(row: StoryArcSearchResult | StoryArcMetadata) -> ProviderStoryArcRead:
+ identifier = _cv_id(row.provider_id, MetadataEntityKind.STORY_ARC)
+ return ProviderStoryArcRead(
+ source=MetadataSource.COMICVINE_API,
+ identity_namespace=MetadataSource.COMICVINE_API.identity_namespace,
+ external_id=identifier,
+ title=row.title,
+ description=row.description,
+ publisher=row.publisher,
+ declared_issue_count=row.declared_issue_count,
+ image_url=_image_url(row.cover_url),
+ resource_url=f"https://comicvine.gamespot.com/story-arc/4045-{identifier}/",
+ )
+
+ async def story_arcs(self, query: str, *, page: int = 1) -> MetadataPage[ProviderStoryArcRead]:
+ page_number(page)
+ if page > 100:
+ raise ValueError("Story arc search is bounded to 100 source pages")
+ try:
+ rows, total = await self.provider.search_story_arcs_page(
+ query, limit=100, offset=(page - 1) * 100
+ )
+ more = page * 100 < total
+ return MetadataPage(
+ results=[self._arc(row) for row in rows],
+ total=total,
+ next_page=page + 1 if more and page < 100 else None,
+ truncated=more and page == 100,
+ )
+ except ComicVineError as exc:
+ raise _api_error(exc) from None
+ except (ValueError, TypeError, AttributeError):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE) from None
+
+ async def story_arc(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderStoryArcRead]:
+ identifier = _cv_id(external_id, MetadataEntityKind.STORY_ARC)
+
+ async def read() -> ProviderStoryArcRead:
+ row = await self.provider.get_story_arc(identifier)
+ result = self._arc(row)
+ result.issue_external_ids = list(row.issue_provider_ids)
+ result.membership_complete = row.membership_complete
+ result.warnings = list(row.warnings)
+ return result
+
+ return await _fetch(read)
+
+ async def story_arc_issues(
+ self, external_id: str, *, page: int = 1, validator: str | None = None
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ identifier = _cv_id(external_id, MetadataEntityKind.STORY_ARC)
+ page_number(page)
+ if page > 50:
+ raise ValueError("Story arc membership is bounded to 5000 issues")
+
+ async def read() -> MetadataPage[ProviderIssueRead]:
+ arc = await self.provider.get_story_arc(identifier)
+ if arc.provider_id != identifier or not arc.membership_complete:
+ raise ValueError("Incomplete arc membership")
+ ids = arc.issue_provider_ids[(page - 1) * 100 : page * 100]
+ rows = await self.provider.get_story_arc_issues(ids) if ids else []
+ if len(rows) != len(ids) or [row.provider_id for row in rows] != list(ids):
+ raise ValueError("Different arc membership")
+ return normalize.issue_page(
+ MetadataSource.COMICVINE_API, rows, len(arc.issue_provider_ids), page
+ )
+
+ return await _fetch(read)
+
+
+class ComicVineLocalSource:
+ def __init__(self, reader: CatalogReader) -> None:
+ self.reader = reader
+
+ async def recent_issues(
+ self, external_id: str, *, since: datetime
+ ) -> MetadataFetch[RecentIssueWindow]:
+ identifier = _cv_id(external_id, MetadataEntityKind.SERIES)
+ checkpoint = issue_checkpoint(since)
+
+ async def read() -> RecentIssueWindow:
+ await self._available()
+ rows, total, cutoff = await self.reader.recent_issues(int(identifier))
+ result = RecentIssueWindow(
+ results=[normalize.issue(MetadataSource.COMICVINE_LOCAL, row) for row in rows],
+ matched_total=total,
+ scope="recent_publication",
+ truncated=total > 100,
+ source_updated_at=cutoff,
+ )
+ validate_recent_issue_window(
+ result, MetadataSource.COMICVINE_LOCAL, identifier, checkpoint
+ )
+ return result
+
+ return await _fetch(lambda: _catalog_read(read))
+
+ async def _available(self) -> None:
+ if not await disk_work(lambda: self.reader.available):
+ raise MetadataSourceError(SourceStatus.UNCONFIGURED)
+
+ async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage:
+ return await _catalog_read(lambda: self._search(query, offset))
+
+ async def _search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage:
+ await self._available()
+ try:
+ rows = await self.reader.search(
+ query.query, query.year, query.limit_per_source + 1, offset
+ )
+ except (CatalogError, OSError, ValueError):
+ raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None
+ return _page(
+ MetadataSource.COMICVINE_LOCAL,
+ rows,
+ total=None,
+ offset=offset,
+ limit=query.limit_per_source,
+ has_more=len(rows) > query.limit_per_source,
+ )
+
+ async def check(self) -> None:
+ await _catalog_read(self._check)
+
+ async def _check(self) -> None:
+ await self._available()
+ try:
+ # Validate/open the active generation, even if it has no matching series.
+ await self.reader.series(1)
+ except (CatalogError, OSError, ValueError):
+ raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None
+
+ async def close(self) -> None:
+ return None
+
+ async def series(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderSeriesRead]:
+ identifier = _cv_id(external_id, MetadataEntityKind.SERIES)
+
+ async def read() -> ProviderSeriesRead | None:
+ await self._available()
+ row = await self.reader.series(int(identifier))
+ return (
+ normalize.series(MetadataSource.COMICVINE_LOCAL, row, identifier) if row else None
+ )
+
+ return await _fetch(lambda: _catalog_read(read))
+
+ async def issue(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderIssueRead]:
+ identifier = _cv_id(external_id, MetadataEntityKind.ISSUE)
+
+ async def read() -> ProviderIssueRead | None:
+ await self._available()
+ row = await self.reader.issue(int(identifier), preserve_number_text=True)
+ return normalize.issue(MetadataSource.COMICVINE_LOCAL, row) if row else None
+
+ return await _fetch(lambda: _catalog_read(read))
+
+ async def issues(
+ self, external_id: str, *, page: int = 1, validator: str | None = None
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ identifier = _cv_id(external_id, MetadataEntityKind.SERIES)
+ page_number(page)
+
+ async def read() -> MetadataPage[ProviderIssueRead]:
+ await self._available()
+ rows, total = await self.reader.issue_page(int(identifier), page=page)
+ return normalize.issue_page(MetadataSource.COMICVINE_LOCAL, rows, total, page)
+
+ return await _fetch(lambda: _catalog_read(read))
+
+
+def _api(runtime: SourceRuntime) -> ComicVineApiSource:
+ if runtime.credential is None or not runtime.credential.get_secret_value():
+ raise MetadataSourceError(SourceStatus.UNCONFIGURED)
+ return ComicVineApiSource(
+ ComicVineProvider(
+ runtime.credential.get_secret_value(), rate_limit=get_settings().comicvine_rate_limit
+ )
+ )
+
+
+def comicvine_sources() -> dict[MetadataSource, SourceRegistration]:
+ capabilities = {
+ SourceCapability.SERIES_SEARCH,
+ SourceCapability.SERIES_DETAILS,
+ SourceCapability.ISSUE_LIST,
+ SourceCapability.RECENT_ISSUES,
+ SourceCapability.ISSUE_DETAILS,
+ }
+ return {
+ MetadataSource.COMICVINE_API: SourceRegistration(
+ frozenset(
+ capabilities
+ | {
+ SourceCapability.STORY_ARC_SEARCH,
+ SourceCapability.STORY_ARC_DETAILS,
+ SourceCapability.STORY_ARC_ISSUES,
+ }
+ ),
+ _api,
+ ),
+ MetadataSource.COMICVINE_LOCAL: SourceRegistration(
+ frozenset(capabilities | {SourceCapability.OFFLINE}),
+ lambda runtime: ComicVineLocalSource(get_catalog_reader()),
+ ),
+ }
+
+
+def _metron(runtime: SourceRuntime) -> MetronSource:
+ if runtime.credential is None:
+ raise MetadataSourceError(SourceStatus.UNCONFIGURED)
+ return MetronSource(runtime.credential)
+
+
+def metadata_sources() -> dict[MetadataSource, SourceRegistration]:
+ return {
+ **comicvine_sources(),
+ MetadataSource.GCD_LOCAL: SourceRegistration(
+ frozenset(
+ {
+ SourceCapability.SERIES_SEARCH,
+ SourceCapability.SERIES_DETAILS,
+ SourceCapability.ISSUE_LIST,
+ SourceCapability.ISSUE_DETAILS,
+ SourceCapability.OFFLINE,
+ }
+ ),
+ lambda runtime: GcdLocalSource(runtime.gcd_snapshot),
+ ),
+ MetadataSource.METRON_API: SourceRegistration(
+ frozenset(
+ {
+ SourceCapability.SERIES_SEARCH,
+ SourceCapability.SERIES_DETAILS,
+ SourceCapability.ISSUE_LIST,
+ SourceCapability.RECENT_ISSUES,
+ SourceCapability.ISSUE_DETAILS,
+ SourceCapability.STORY_ARC_SEARCH,
+ SourceCapability.STORY_ARC_DETAILS,
+ SourceCapability.STORY_ARC_ISSUES,
+ SourceCapability.CROSS_IDENTITIES,
+ SourceCapability.CONDITIONAL_REFRESH,
+ SourceCapability.COVER_REFERENCE,
+ }
+ ),
+ _metron,
+ ),
+ }
diff --git a/src/pullbox/schemas/archive_publication_owner.py b/src/pullbox/schemas/archive_publication_owner.py
new file mode 100644
index 00000000..3e0b0d6d
--- /dev/null
+++ b/src/pullbox/schemas/archive_publication_owner.py
@@ -0,0 +1,16 @@
+"""Immutable invoking-owner evidence retained with archive publication intent."""
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class ImportArchiveOwner(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ kind: Literal["import_enrichment"] = "import_enrichment"
+ job_id: int = Field(gt=0, strict=True)
+ imported_file_id: int = Field(gt=0, strict=True)
+ action_id: int = Field(gt=0, strict=True)
+ action_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
+ pending_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
diff --git a/src/pullbox/schemas/issue_file_metadata.py b/src/pullbox/schemas/issue_file_metadata.py
new file mode 100644
index 00000000..3d41a857
--- /dev/null
+++ b/src/pullbox/schemas/issue_file_metadata.py
@@ -0,0 +1,65 @@
+"""Approval for writing the current canonical pair to one managed comic."""
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+type FileMetadataSource = Literal["library", "ComicInfo.xml", "MetronInfo.xml"]
+type FileMetadataField = Literal[
+ "series.title",
+ "series.sort_title",
+ "series.publisher",
+ "series.language",
+ "series.year_start",
+ "series.volume",
+ "series.series_type",
+ "series.issue_count",
+ "issue.title",
+ "issue.description",
+ "issue.cover_date",
+ "issue.store_date",
+ "issue.page_count",
+ "issue.credits",
+]
+type FileMetadataChoices = dict[FileMetadataField, FileMetadataSource]
+
+
+class FileMetadataReview(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ choices: FileMetadataChoices = Field(default_factory=dict, max_length=14)
+
+
+class FileMetadataOption(BaseModel):
+ source: FileMetadataSource
+ value: str
+ selectable: bool = True
+
+
+class FileMetadataConflict(BaseModel):
+ key: FileMetadataField
+ label: str
+ options: list[FileMetadataOption]
+ selected: FileMetadataSource | None = None
+ note: str = ""
+
+
+class FileMetadataChange(BaseModel):
+ document: str
+ field: str
+ before: str | None
+ after: str | None
+
+
+class FileMetadataPreview(BaseModel):
+ file_id: int
+ file_name: str
+ documents: tuple[str, str] = ("ComicInfo.xml", "MetronInfo.xml")
+ changes: list[FileMetadataChange]
+ review_key: str
+ unchanged: bool
+ ready: bool = True
+ conflicts: list[FileMetadataConflict] = Field(default_factory=list)
+
+
+class FileMetadataApproval(FileMetadataReview):
+ review_key: str = Field(pattern=r"^[a-f0-9]{64}$")
diff --git a/src/pullbox/schemas/issue_metadata_links.py b/src/pullbox/schemas/issue_metadata_links.py
new file mode 100644
index 00000000..20dd38be
--- /dev/null
+++ b/src/pullbox/schemas/issue_metadata_links.py
@@ -0,0 +1,56 @@
+"""Bounded operator commands for an existing issue's metadata providers."""
+
+from datetime import date
+from typing import ClassVar
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.schemas.metadata_identity_review import IdentityReviewRead
+from pullbox.schemas.metadata_snapshot import FieldOrigin
+from pullbox.schemas.metadata_sources import ProviderIssueRead, SeriesPreviewQuery, SourceOutcome
+from pullbox.schemas.series_metadata_links import SeriesLinkIdentity, SeriesLinkSource
+
+
+class IssueLinkCurrent(BaseModel):
+ series_id: int
+ series_title: str
+ issue_number_text: str
+ title: str | None
+ cover_date: date | None
+ page_count: int | None
+
+
+class IssueLinkSource(SeriesLinkSource):
+ series_external_id: str
+
+
+class IssueLinksRead(BaseModel):
+ current: IssueLinkCurrent
+ identities: list[SeriesLinkIdentity]
+ sources: list[IssueLinkSource]
+ origins: list[FieldOrigin]
+ can_refresh: bool
+ syncing: bool
+
+
+class IssueLinkQuery(SeriesPreviewQuery):
+ entity_kind: ClassVar[MetadataEntityKind] = MetadataEntityKind.ISSUE
+
+
+class IssueCandidatesQuery(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ source: MetadataSource
+ page: int = Field(default=1, ge=1, le=10000, strict=True)
+
+
+class IssueLinkPreview(BaseModel):
+ current: IssueLinkCurrent
+ candidate: ProviderIssueRead
+ source_revision: int
+ review: IdentityReviewRead
+
+
+class IssueRefreshRead(BaseModel):
+ issue_id: int
+ outcomes: list[SourceOutcome]
diff --git a/src/pullbox/schemas/metadata_arc_catalog.py b/src/pullbox/schemas/metadata_arc_catalog.py
new file mode 100644
index 00000000..e58f4c45
--- /dev/null
+++ b/src/pullbox/schemas/metadata_arc_catalog.py
@@ -0,0 +1,51 @@
+"""Source-bound catalog decisions, never client-supplied provider metadata."""
+
+from typing import Annotated
+
+from pydantic import BaseModel, Field
+
+from pullbox.schemas.metadata_sources import (
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ StoryArcPreviewQuery,
+)
+
+_Fingerprint = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$", strict=True)]
+_MemberId = Annotated[str, Field(pattern=r"^[1-9][0-9]{0,254}$", strict=True)]
+
+
+class ArcCatalogSelection(StoryArcPreviewQuery):
+ source_revision: int = Field(ge=1, lt=2**63, strict=True)
+
+
+class ArcCatalogAdd(ArcCatalogSelection):
+ fingerprint: _Fingerprint
+ file_defaults_fingerprint: _Fingerprint
+ ordered_issue_ids: list[_MemberId] = Field(min_length=1, max_length=2000)
+ skipped_issue_ids: list[_MemberId] = Field(default_factory=list, max_length=2000)
+ library_root_id: int = Field(gt=0, lt=2**63, strict=True)
+ monitored: bool = Field(default=False, strict=True)
+
+
+class ArcCatalogRefresh(ArcCatalogSelection):
+ fingerprint: _Fingerprint
+ expected_revision: int = Field(ge=1, lt=2**63, strict=True)
+ library_root_id: int | None = Field(default=None, gt=0, lt=2**63, strict=True)
+
+
+class ArcCatalogChanges(BaseModel):
+ revision: int
+ added_issue_ids: list[str]
+ removed_issue_ids: list[str]
+
+
+class ArcCatalogPreviewRead(ArcCatalogSelection):
+ fingerprint: str
+ arc: ProviderStoryArcRead
+ issues: list[ProviderIssueRead]
+ series: list[ProviderSeriesRead]
+ order_basis: str
+ file_defaults_fingerprint: str | None = None
+ file_summary: str | None = None
+ changes: ArcCatalogChanges | None = None
diff --git a/src/pullbox/schemas/metadata_credits.py b/src/pullbox/schemas/metadata_credits.py
new file mode 100644
index 00000000..a93792ca
--- /dev/null
+++ b/src/pullbox/schemas/metadata_credits.py
@@ -0,0 +1,57 @@
+"""Bounded descriptive credits shared by providers, snapshots and library relations."""
+
+from typing import Annotated
+
+from pydantic import AfterValidator, BaseModel, ConfigDict, Field, TypeAdapter, field_validator
+
+MAX_CREDITS = 128
+
+
+class MetadataCredit(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ name: str = Field(min_length=1, max_length=255)
+ role: str = Field(max_length=100)
+
+ @field_validator("name", mode="after")
+ @classmethod
+ def normalize(cls, value: str) -> str:
+ result = value.strip()
+ if not result or any(ord(char) < 32 for char in result):
+ raise ValueError("Credits require nonempty printable text")
+ return result
+
+ @field_validator("role", mode="after")
+ @classmethod
+ def normalize_roles(cls, value: str) -> str:
+ if any(ord(char) < 32 for char in value):
+ raise ValueError("Creator roles require printable text")
+ if not value.strip():
+ return ""
+ roles = [role.strip().casefold() for role in value.split(",")]
+ if any(not role for role in roles):
+ raise ValueError("Credits require nonempty roles")
+ # ComicVine's spelling and the XML schema describe the same pencils role.
+ roles = ["penciller" if role == "penciler" else role for role in roles]
+ return ", ".join(sorted(set(roles)))
+
+
+def _normalize_credits(values: tuple[MetadataCredit, ...]) -> tuple[MetadataCredit, ...]:
+ roles: dict[str, set[str]] = {}
+ for credit in values:
+ roles.setdefault(credit.name, set()).update(credit.role.split(", ") if credit.role else ())
+ # Descriptive grouping only: names never prove a foreign creator identity.
+ return tuple(
+ MetadataCredit(name=name, role=", ".join(sorted(roles[name]))) for name in sorted(roles)
+ )
+
+
+type MetadataCredits = Annotated[
+ tuple[MetadataCredit, ...], Field(max_length=MAX_CREDITS), AfterValidator(_normalize_credits)
+]
+
+_CREDITS: TypeAdapter[tuple[MetadataCredit, ...]] = TypeAdapter(MetadataCredits)
+
+
+def parse_credits(value: object) -> tuple[MetadataCredit, ...]:
+ return _CREDITS.validate_python(value)
diff --git a/src/pullbox/schemas/metadata_identity_review.py b/src/pullbox/schemas/metadata_identity_review.py
new file mode 100644
index 00000000..70b161cd
--- /dev/null
+++ b/src/pullbox/schemas/metadata_identity_review.py
@@ -0,0 +1,37 @@
+"""Sanitized saved-claim review contracts."""
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+
+
+class IdentityClaimRead(BaseModel):
+ event_id: int
+ identity_namespace: IdentityNamespace
+ external_id: str
+ verification_state: IdentityVerificationState
+
+
+class IdentityReviewRead(IdentityClaimRead):
+ entity_kind: MetadataEntityKind
+ local_id: int
+ current_external_id: str | None
+ owner_local_id: int | None
+ review_revision: int
+ fingerprint: str
+
+
+class IdentityReviewWrite(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ action: Literal["confirm", "reject"]
+ fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$")
+ review_revision: int = Field(gt=0, lt=2**63, strict=True)
+
+
+class IdentityReviewReceiptRead(BaseModel):
+ event_id: int
+ replayed: bool
diff --git a/src/pullbox/schemas/metadata_snapshot.py b/src/pullbox/schemas/metadata_snapshot.py
new file mode 100644
index 00000000..2b903d7f
--- /dev/null
+++ b/src/pullbox/schemas/metadata_snapshot.py
@@ -0,0 +1,111 @@
+"""Canonical descriptive values and their provenance, independent of output format."""
+
+from datetime import date
+from typing import Literal, Self
+
+from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
+
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource
+from pullbox.schemas.metadata_credits import MetadataCredits
+from pullbox.schemas.metadata_sources import MetadataDomain
+
+type EmbeddedDocument = Literal["ComicInfo.xml", "MetronInfo.xml"]
+
+
+def field_domain(kind: MetadataEntityKind, field: str) -> MetadataDomain:
+ if field == "image_url":
+ return MetadataDomain.ARTWORK
+ if kind is MetadataEntityKind.STORY_ARC:
+ return MetadataDomain.STORY_ARCS
+ if field == "issue_count":
+ return MetadataDomain.ISSUES
+ return MetadataDomain.CORE
+
+
+class MetadataValues(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ title: str | None = Field(default=None, max_length=500)
+ sort_title: str | None = Field(default=None, max_length=500)
+ publisher: str | None = Field(default=None, max_length=255)
+ description: str | None = Field(default=None, max_length=200000)
+ year_start: int | None = Field(default=None, ge=1, le=9999, strict=True)
+ year_end: int | None = Field(default=None, ge=1, le=9999, strict=True)
+ volume: str | None = Field(default=None, max_length=100)
+ series_type: str | None = Field(default=None, max_length=50)
+ status: str | None = Field(default=None, max_length=50)
+ language: str | None = Field(default=None, max_length=100)
+ issue_count: int | None = Field(default=None, ge=0, le=1000000, strict=True)
+ issue_number_text: str | None = Field(default=None, max_length=320)
+ cover_date: date | None = None
+ store_date: date | None = None
+ page_count: int | None = Field(default=None, ge=0, le=1000000, strict=True)
+ image_url: str | None = Field(default=None, max_length=500)
+ credits: MetadataCredits | None = None
+
+
+class FieldOrigin(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ field: str
+ domain: MetadataDomain
+ source: MetadataSource | None = None
+ source_updated_at: AwareDatetime | None = None
+ observed_at: AwareDatetime
+ user_override: bool = False
+ derivation: Literal["classification", "lifecycle", "catalog", "normalization"] | None = None
+ embedded_documents: tuple[EmbeddedDocument, ...] = ()
+
+
+class MetadataSnapshot(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ schema_version: Literal[1] = 1
+ entity_kind: MetadataEntityKind
+ identities: tuple[ExternalIdentityRef, ...]
+ values: MetadataValues
+ origins: tuple[FieldOrigin, ...] = ()
+ observed_identities: tuple[ExternalIdentityRef, ...] = ()
+ diagnostics: tuple[str, ...] = ()
+
+ @model_validator(mode="after")
+ def consistent_provenance(self) -> Self:
+ identities: dict[str, ExternalIdentityRef] = {}
+ for identity in (*self.identities, *self.observed_identities):
+ if identity.entity_kind is not self.entity_kind or (
+ identity.namespace in identities and identities[identity.namespace] != identity
+ ):
+ raise ValueError("Snapshot identities disagree")
+ identities[identity.namespace] = identity
+ fields = set()
+ for origin in self.origins:
+ if (
+ origin.field not in MetadataValues.model_fields
+ or origin.field in fields
+ or origin.domain is not field_domain(self.entity_kind, origin.field)
+ or (origin.user_override and origin.source is not None)
+ or (
+ origin.embedded_documents
+ and (
+ len(set(origin.embedded_documents)) != len(origin.embedded_documents)
+ or origin.source is not None
+ or origin.source_updated_at is not None
+ or origin.user_override
+ or origin.derivation is not None
+ )
+ )
+ or (
+ origin.derivation is not None
+ and (origin.source is not None or origin.user_override)
+ )
+ or (
+ origin.source is not None
+ and not any(
+ identity.namespace is origin.source.identity_namespace
+ for identity in self.identities
+ )
+ )
+ ):
+ raise ValueError("Snapshot field provenance is inconsistent")
+ fields.add(origin.field)
+ return self
diff --git a/src/pullbox/schemas/metadata_sources.py b/src/pullbox/schemas/metadata_sources.py
new file mode 100644
index 00000000..b06e82ff
--- /dev/null
+++ b/src/pullbox/schemas/metadata_sources.py
@@ -0,0 +1,380 @@
+"""Versioned source policy and provider-aware discovery responses."""
+
+import enum
+from datetime import date, datetime
+from typing import Annotated, ClassVar, Literal, Self
+
+from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
+
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.schemas.metadata_credits import MetadataCredits
+
+
+class MetadataDomain(enum.StrEnum):
+ CORE = "core"
+ ISSUES = "issues"
+ ARTWORK = "artwork"
+ STORY_ARCS = "story_arcs"
+
+
+class SourceCapability(enum.StrEnum):
+ SERIES_SEARCH = "series_search"
+ SERIES_DETAILS = "series_details"
+ ISSUE_LIST = "issue_list"
+ RECENT_ISSUES = "recent_issues"
+ ISSUE_DETAILS = "issue_details"
+ CROSS_IDENTITIES = "cross_identities"
+ CONDITIONAL_REFRESH = "conditional_refresh"
+ OFFLINE = "offline"
+ COVER_REFERENCE = "cover_reference"
+ STORY_ARC_SEARCH = "story_arc_search"
+ STORY_ARC_DETAILS = "story_arc_details"
+ STORY_ARC_ISSUES = "story_arc_issues"
+
+
+class SourceStatus(enum.StrEnum):
+ OK = "ok"
+ EMPTY = "empty"
+ DISABLED = "disabled"
+ FEATURE_DISABLED = "feature_disabled"
+ NOT_IMPLEMENTED = "not_implemented"
+ UNCONFIGURED = "unconfigured"
+ INVALID_CONFIG = "invalid_configuration"
+ AUTHENTICATION_FAILED = "authentication_failed"
+ RATE_LIMITED = "rate_limited"
+ TIMEOUT = "timeout"
+ UNAVAILABLE = "unavailable"
+ INCOMPATIBLE_RESPONSE = "incompatible_response"
+ UNSUPPORTED = "unsupported"
+ NOT_QUERIED = "not_queried"
+ NOT_FOUND = "not_found"
+ NOT_MODIFIED = "not_modified"
+
+
+class SourceSettings(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+ database_path: str | None = Field(default=None, min_length=1, max_length=4096)
+
+
+class SourcePolicyWrite(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ revision: int = Field(ge=0, lt=2**63, strict=True)
+ enabled: bool
+ priority: int = Field(ge=0, le=1000, strict=True)
+ domain_priorities: dict[MetadataDomain, int] = Field(default_factory=dict, max_length=4)
+ settings: SourceSettings = Field(default_factory=SourceSettings)
+ credential: SecretStr | None = Field(default=None, exclude=True)
+ clear_credential: bool = False
+
+
+class SourcePolicyRead(BaseModel):
+ source: MetadataSource
+ identity_namespace: IdentityNamespace
+ enabled: bool
+ priority: int
+ domain_priorities: dict[MetadataDomain, int]
+ settings: SourceSettings
+ credential_configured: bool
+ revision: int
+ last_tested_at: datetime | None = None
+ last_success_at: datetime | None = None
+ last_status: SourceStatus | None = None
+ configuration_status: SourceStatus | None = None
+
+
+class SourcePriorityWrite(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ order: list[MetadataSource] = Field(min_length=5, max_length=5)
+ revisions: dict[MetadataSource, Annotated[int, Field(ge=0, lt=2**63, strict=True)]]
+ domain_orders: dict[MetadataDomain, list[MetadataSource]] = Field(default_factory=dict)
+
+ @model_validator(mode="after")
+ def complete_orders(self) -> Self:
+ sources = set(MetadataSource)
+ if set(self.order) != sources or set(self.revisions) != sources:
+ raise ValueError("Include every metadata source exactly once with its saved revision")
+ for domain, order in self.domain_orders.items():
+ allowed = sources - (
+ {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2}
+ if domain is MetadataDomain.ARTWORK
+ else set()
+ )
+ if len(order) != len(allowed) or set(order) != allowed:
+ raise ValueError("Include every eligible source exactly once in each domain order")
+ return self
+
+
+class SeriesDiscoveryQuery(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ query: str = Field(min_length=1, max_length=300)
+ year: int | None = Field(default=None, ge=1, le=9999)
+ sources: list[MetadataSource] | None = Field(default=None, min_length=1, max_length=5)
+ mode: Literal["interactive", "automatic"] = "interactive"
+ search_mode: Literal["preview", "full"] = "preview"
+ limit_per_source: int = Field(default=20, ge=1, le=100)
+ offsets: dict[MetadataSource, Annotated[int, Field(ge=0, le=10000, strict=True)]] = Field(
+ default_factory=dict, max_length=5
+ )
+
+ @model_validator(mode="after")
+ def validate_selection(self) -> Self:
+ self.query = self.query.strip()
+ if not self.query:
+ raise ValueError("Enter a series title")
+ if self.sources is not None and len(set(self.sources)) != len(self.sources):
+ raise ValueError("Choose each source only once")
+ if self.sources is not None and self.offsets.keys() - set(self.sources):
+ raise ValueError("Pagination must refer to a selected source")
+ if any(offset % self.limit_per_source for offset in self.offsets.values()):
+ raise ValueError("Offsets must start at a source page boundary")
+ return self
+
+
+class ProviderSeriesRead(BaseModel):
+ source: MetadataSource
+ identity_namespace: IdentityNamespace
+ external_id: str
+ title: str
+ year_start: int | None = None
+ publisher: str | None = None
+ issue_count: int | None = None
+ description: str | None = None
+ resource_url: str | None = None
+ image_url: str | None = None
+ also_from: list[MetadataSource] = Field(default_factory=list)
+ cross_identities: list[ExternalIdentityRef] = Field(default_factory=list)
+ source_updated_at: datetime | None = None
+ sort_title: str | None = None
+ year_end: int | None = None
+ volume: str | None = None
+ series_type: str | None = None
+ status: str | None = None
+ language: str | None = None
+
+
+class ProviderIssueRead(BaseModel):
+ credits: MetadataCredits | None = None
+ source: MetadataSource
+ identity_namespace: IdentityNamespace
+ external_id: str
+ series_external_id: str
+ issue_number_text: str
+ issue_number_key: str | None = None
+ title: str | None = None
+ description: str | None = None
+ cover_date: date | None = None
+ store_date: date | None = None
+ page_count: int | None = None
+ resource_url: str | None = None
+ image_url: str | None = None
+ cross_identities: list[ExternalIdentityRef] = Field(default_factory=list)
+ source_updated_at: datetime | None = None
+
+
+class ProviderStoryArcRead(BaseModel):
+ source: MetadataSource
+ identity_namespace: IdentityNamespace
+ external_id: str
+ title: str
+ description: str | None = None
+ resource_url: str | None = None
+ image_url: str | None = None
+ cross_identities: list[ExternalIdentityRef] = Field(default_factory=list)
+ source_updated_at: datetime | None = None
+ publisher: str | None = None
+ declared_issue_count: int | None = None
+ issue_external_ids: list[str] | None = None
+ membership_complete: bool = False
+ order_basis: Literal["response_order"] = "response_order"
+ warnings: list[str] = Field(default_factory=list)
+ also_from: list[MetadataSource] = Field(default_factory=list)
+
+
+class StoryArcDiscoveryQuery(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ query: str = Field(min_length=1, max_length=200)
+ sources: list[MetadataSource] | None = Field(default=None, min_length=1, max_length=5)
+ mode: Literal["interactive", "automatic"] = "interactive"
+ pages: dict[MetadataSource, Annotated[int, Field(ge=1, le=100, strict=True)]] = Field(
+ default_factory=dict, max_length=5
+ )
+
+ @model_validator(mode="after")
+ def validate_selection(self) -> Self:
+ self.query = self.query.strip()
+ if not self.query:
+ raise ValueError("Enter a story arc title")
+ if self.sources is not None and len(set(self.sources)) != len(self.sources):
+ raise ValueError("Choose each source only once")
+ if self.sources is not None and self.pages.keys() - set(self.sources):
+ raise ValueError("Pagination must refer to a selected source")
+ return self
+
+
+class MetadataPage[T](BaseModel):
+ results: list[T]
+ total: int
+ next_page: int | None = None
+ truncated: bool = False
+ # Provider publication order is not a reviewed story-arc reading order.
+ order_is_reading_order: bool = False
+
+
+class MetadataFetch[T](BaseModel):
+ status: SourceStatus
+ data: T | None = None
+ validator: str | None = None
+ retry_after_seconds: int | None = None
+
+
+class RecentIssueWindow(BaseModel):
+ """One bounded slice, never evidence of complete series membership."""
+
+ model_config = ConfigDict(extra="forbid")
+ results: list[ProviderIssueRead] = Field(max_length=100)
+ matched_total: int = Field(
+ ge=0,
+ le=1_000_000_000,
+ strict=True,
+ description="Count matching this query, not proof of complete series membership.",
+ )
+ scope: Literal["recent_publication", "modified_since"]
+ since: datetime | None = None
+ truncated: bool = Field(strict=True)
+ full_catalog: Literal[False] = False
+ source_updated_at: datetime | None = None
+
+
+class SeriesPreviewQuery(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ entity_kind: ClassVar[MetadataEntityKind] = MetadataEntityKind.SERIES
+ source: MetadataSource
+ external_id: str = Field(min_length=1, max_length=255, strict=True)
+
+ @model_validator(mode="after")
+ def canonical_identity(self) -> Self:
+ self.external_id = ExternalIdentityRef(
+ self.source.identity_namespace, self.entity_kind, self.external_id
+ ).external_id
+ if (
+ self.source.identity_namespace is IdentityNamespace.COMICVINE
+ and int(self.external_id) >= 2**63
+ ):
+ raise ValueError("ComicVine identity exceeds the supported range")
+ return self
+
+
+class SeriesAddPreviewQuery(SeriesPreviewQuery):
+ library_root_id: int | None = Field(default=None, gt=0, lt=2**63, strict=True)
+
+
+class SeriesIssuePageQuery(SeriesPreviewQuery):
+ page: int = Field(default=1, ge=1, le=10000, strict=True)
+ source_revision: int = Field(ge=0, lt=2**63, strict=True)
+
+
+class SeriesIssuePageRead(MetadataFetch[MetadataPage[ProviderIssueRead]]):
+ series_cover_url: str | None = None
+
+
+class CatalogExcludedIssue(BaseModel):
+ """An exact provider entry explicitly left out, never an issue identity claim."""
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ source: Literal[MetadataSource.GCD_LOCAL]
+ series_external_id: str = Field(min_length=1, max_length=255)
+ external_id: str = Field(min_length=1, max_length=255)
+ issue_number_text: str = Field(max_length=100)
+
+
+class CatalogReviewRead(BaseModel):
+ token: str
+ total: int
+ supported_count: int
+ excluded: tuple[CatalogExcludedIssue, ...]
+
+
+class SeriesPreviewRead(BaseModel):
+ source: MetadataSource
+ external_id: str
+ source_revision: int
+ series: MetadataFetch[ProviderSeriesRead]
+ issues: MetadataFetch[MetadataPage[ProviderIssueRead]]
+ folder_preview: str | None = None
+ catalog_review: CatalogReviewRead | None = None
+
+
+class StoryArcPreviewQuery(SeriesPreviewQuery):
+ entity_kind: ClassVar[MetadataEntityKind] = MetadataEntityKind.STORY_ARC
+
+
+class StoryArcIssuePageQuery(StoryArcPreviewQuery):
+ page: int = Field(default=1, ge=1, le=50, strict=True)
+ source_revision: int = Field(ge=0, lt=2**63, strict=True)
+
+
+class StoryArcPreviewRead(BaseModel):
+ source: MetadataSource
+ external_id: str
+ source_revision: int
+ arc: MetadataFetch[ProviderStoryArcRead]
+ issues: MetadataFetch[MetadataPage[ProviderIssueRead]]
+
+
+class SourceOutcome(BaseModel):
+ source: MetadataSource
+ status: SourceStatus
+ total: int | None = None
+ next_offset: int | None = None
+ retry_after_seconds: int | None = None
+ rejected_results: int = 0
+ truncated: bool = False
+
+
+class SeriesDiscoveryRead(BaseModel):
+ results: list[ProviderSeriesRead]
+ sources: list[SourceOutcome]
+
+
+class StoryArcSourceOutcome(SourceOutcome):
+ next_page: int | None = None
+
+
+class StoryArcDiscoveryRead(BaseModel):
+ results: list[ProviderStoryArcRead]
+ sources: list[StoryArcSourceOutcome]
+
+
+class SourceAccountRead(BaseModel):
+ status: SourceStatus | None
+ retry_at: datetime | None
+ probe_until: datetime | None
+
+
+class DeferredMetadataRead(BaseModel):
+ id: int
+ series_id: int
+ series_title: str
+ source: str
+ task_id: Literal["refresh_metadata", "sync_new_issues"]
+ state: Literal["ready", "waiting", "authentication_required", "source_disabled"]
+ retry_at: datetime | None
+
+
+class SourceDescriptor(SourcePolicyRead):
+ capabilities: list[SourceCapability]
+ availability: SourceStatus | None = None
+ account: SourceAccountRead | None = None
+ deferred_series: int = 0
+ deferred_work: int = 0
+
+
+class SourceTestRead(BaseModel):
+ outcome: SourceOutcome
+ recorded: bool
diff --git a/src/pullbox/schemas/series.py b/src/pullbox/schemas/series.py
index c2196aa8..4329bd58 100644
--- a/src/pullbox/schemas/series.py
+++ b/src/pullbox/schemas/series.py
@@ -2,14 +2,16 @@
from datetime import datetime
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource
from pullbox.models.series import (
IssueCatalogState,
SeriesStatus,
SeriesStatusOverride,
SeriesType,
)
+from pullbox.schemas.metadata_sources import CatalogExcludedIssue
class SeriesCreate(BaseModel):
@@ -27,6 +29,35 @@ class SeriesCreate(BaseModel):
),
)
+ @model_validator(mode="before")
+ @classmethod
+ def reject_mixed_source_identity(cls, value: object) -> object:
+ if isinstance(value, dict) and {"source", "external_id", "source_revision"}.intersection(
+ value
+ ):
+ raise ValueError("Choose a source identity or a legacy ComicVine ID, not both.")
+ return value
+
+
+class SourceSeriesCreate(BaseModel):
+ """Select a source identity; metadata is always fetched by the server."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ source: MetadataSource
+ external_id: str = Field(..., min_length=1, max_length=320)
+ source_revision: int = Field(..., ge=0, strict=True)
+ library_root_id: int | None = Field(None, gt=0)
+ search_on_add: bool | None = None
+ catalog_review_token: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
+
+ @model_validator(mode="after")
+ def canonical_identity(self) -> "SourceSeriesCreate":
+ self.external_id = ExternalIdentityRef(
+ self.source.identity_namespace, MetadataEntityKind.SERIES, self.external_id
+ ).external_id
+ return self
+
class SeriesUpdate(BaseModel):
"""Request body for updating a series."""
@@ -60,6 +91,7 @@ class SeriesResponse(BaseModel):
issue_catalog_last_synced_at: datetime | None = None
issue_catalog_last_checked_at: datetime | None = None
issue_catalog_error: str | None = None
+ catalog_exclusions: list[CatalogExcludedIssue] = Field(default_factory=list)
comicvine_url: str | None = None
monitored: bool
metadata_last_refreshed: datetime | None = None
diff --git a/src/pullbox/schemas/series_metadata_links.py b/src/pullbox/schemas/series_metadata_links.py
new file mode 100644
index 00000000..e1a00074
--- /dev/null
+++ b/src/pullbox/schemas/series_metadata_links.py
@@ -0,0 +1,48 @@
+"""Interactive, series-only provider linking contracts."""
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from pullbox.core.metadata_identity import IdentityNamespace, MetadataSource
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.schemas.metadata_identity_review import IdentityReviewRead
+from pullbox.schemas.metadata_sources import ProviderSeriesRead
+
+
+class SeriesLinkCurrent(BaseModel):
+ title: str
+ year_start: int | None
+ publisher: str | None
+ issue_count: int
+
+
+class SeriesLinkIdentity(BaseModel):
+ namespace: IdentityNamespace
+ external_id: str
+ state: IdentityVerificationState
+
+
+class SeriesLinkSource(BaseModel):
+ source: MetadataSource
+ label: str
+
+
+class SeriesLinksRead(BaseModel):
+ current: SeriesLinkCurrent
+ identities: list[SeriesLinkIdentity]
+ sources: list[SeriesLinkSource]
+
+
+class SeriesLinkPreview(BaseModel):
+ current: SeriesLinkCurrent
+ candidate: ProviderSeriesRead
+ source_revision: int
+ review: IdentityReviewRead
+
+
+class SeriesLinkConfirm(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ event_id: int = Field(gt=0, lt=2**63, strict=True)
+ fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$")
+ review_revision: int = Field(gt=0, lt=2**63, strict=True)
+ source_revision: int = Field(ge=0, lt=2**63, strict=True)
diff --git a/src/pullbox/schemas/series_sidecar.py b/src/pullbox/schemas/series_sidecar.py
new file mode 100644
index 00000000..f3ff2585
--- /dev/null
+++ b/src/pullbox/schemas/series_sidecar.py
@@ -0,0 +1,35 @@
+"""Server-owned preview and explicit approval for one series' sidecars."""
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot
+
+
+class SeriesSidecarApproval(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ review_key: str = Field(pattern=r"^[0-9a-f]{64}$")
+
+
+class SeriesSidecarTargetRead(BaseModel):
+ directory: str
+ action: Literal["create", "update", "unchanged", "blocked"]
+ reason: str | None = None
+ changes: list[str] = Field(default_factory=list)
+
+
+class SeriesSidecarPreview(BaseModel):
+ series_id: int
+ snapshot: MetadataSnapshot
+ aliases: list[str]
+ targets: list[SeriesSidecarTargetRead]
+ ready: bool
+ review_key: str
+
+
+class SeriesSidecarWriteRead(BaseModel):
+ written: int
+ unchanged: int
+ targets: list[SeriesSidecarTargetRead]
diff --git a/src/pullbox/services/archive_metadata_binding.py b/src/pullbox/services/archive_metadata_binding.py
new file mode 100644
index 00000000..0418a71c
--- /dev/null
+++ b/src/pullbox/services/archive_metadata_binding.py
@@ -0,0 +1,317 @@
+"""Independent database and filesystem binding for coordinated archive writes."""
+
+import asyncio
+import os
+import stat
+from collections.abc import Sequence
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import NotFoundError
+from pullbox.core.library_file_ownership import (
+ ReferencedFileMutationError,
+ require_mutable_library_target,
+)
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.models import Issue, LibraryFile, LibraryRoot
+from pullbox.models.library import FileFormat, LibraryFileStorageMode
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot
+from pullbox.schemas.metadata_sources import MetadataDomain, ProviderIssueRead
+from pullbox.services.archive_metadata_reconciliation import ArchiveMetadataReconciliation
+from pullbox.services.metadata_assembly import assemble_metadata
+from pullbox.services.metadata_series_refresh_state import (
+ RefreshEntityState,
+ SeriesRefreshState,
+ read_series_refresh_state,
+)
+
+type FileFingerprint = tuple[int, int, int, int, int, int]
+type DirectoryFingerprint = tuple[Path, int, int, int]
+
+
+class ArchiveMetadataBindingError(ValueError):
+ """A file cannot safely use the captured canonical metadata."""
+
+ def __init__(self, code: str) -> None:
+ self.code = code
+ super().__init__(code)
+
+
+@dataclass(frozen=True)
+class ArchiveMetadataBinding:
+ """Immutable read set, not a lease, publication journal or filesystem lock."""
+
+ library_file_id: int
+ library_root_id: int
+ root_path: str
+ file_path: str
+ file_size: int
+ file_modified_at: datetime
+ metadata: SeriesRefreshState
+
+
+def archive_primary_identity(
+ binding: ArchiveMetadataBinding, archive: ArchiveMetadataReconciliation
+) -> ExternalIdentityRef | None:
+ """Retain a verified embedded primary, otherwise use saved core-field priority."""
+ identities = binding.metadata.issues[0].identities
+ if archive.metroninfo.metron is not None:
+ for item in archive.metroninfo.metron.identities:
+ if item.primary and item.evidence.identity in identities:
+ return item.evidence.identity
+ ranks: dict[IdentityNamespace, int] = {}
+ for policy in binding.metadata.policies:
+ rank = policy.domain_priorities.get(MetadataDomain.CORE, policy.priority)
+ namespace = policy.identity_namespace
+ ranks[namespace] = min(rank, ranks.get(namespace, rank))
+ return min(
+ identities,
+ key=lambda ref: (ranks.get(ref.namespace, 1001), ref.namespace.value, ref.external_id),
+ default=None,
+ )
+
+
+async def read_archive_metadata_binding(
+ session: AsyncSession,
+ library_file_id: int,
+ *,
+ expected_issue_id: int | None = None,
+) -> ArchiveMetadataBinding:
+ """Read one managed CBZ and its exact parent without provider or file I/O.
+
+ Use a short clean reader session, then release it before inspecting/staging
+ archives. The invoking workflow must revalidate this read set within its
+ journal/serialization boundary; a successful read does not authorize a later
+ blind replacement. Reference-only and unverified legacy bindings fail closed.
+ """
+ if session.new or session.dirty or session.deleted:
+ raise ArchiveMetadataBindingError("pending_session_changes")
+ if any(
+ isinstance(value, bool) or not isinstance(value, int) or value <= 0
+ for value in (
+ library_file_id,
+ *((expected_issue_id,) if expected_issue_id is not None else ()),
+ )
+ ):
+ raise ArchiveMetadataBindingError("invalid_target")
+ row = (
+ await session.execute(
+ select(LibraryFile, LibraryRoot, Issue.series_id)
+ .join(LibraryRoot, LibraryRoot.id == LibraryFile.library_root_id)
+ .join(Issue, Issue.id == LibraryFile.issue_id)
+ .where(LibraryFile.id == library_file_id)
+ .execution_options(populate_existing=True)
+ )
+ ).one_or_none()
+ if row is None:
+ raise ArchiveMetadataBindingError("target_missing")
+ file, root, series_id = row
+ if file.storage_mode is not LibraryFileStorageMode.MANAGED:
+ raise ArchiveMetadataBindingError("reference_only")
+ if not root.enabled or not root.allow_managed_writes:
+ raise ArchiveMetadataBindingError("root_not_managed")
+ if file.file_format is not FileFormat.CBZ:
+ raise ArchiveMetadataBindingError("unsupported_format")
+ if file.issue_id is None or (
+ expected_issue_id is not None and expected_issue_id != file.issue_id
+ ):
+ raise ArchiveMetadataBindingError("issue_changed")
+ try:
+ # Writing one verified issue does not require the entire catalog to be complete.
+ metadata = await read_series_refresh_state(
+ session, series_id, issue_ids=(file.issue_id,), allow_partial_catalog=True
+ )
+ for entity in (metadata.series, metadata.issues[0]):
+ _require_verified(entity)
+ if not {ref.namespace for ref in metadata.issues[0].identities} <= {
+ ref.namespace for ref in metadata.series.identities
+ }:
+ raise ArchiveMetadataBindingError("parent_identity_missing")
+ # Validate baseline ownership even before reading untrusted archive input.
+ _assemble(metadata, None, datetime.now(UTC))
+ except (ValueError, NotFoundError) as exc:
+ if isinstance(exc, ArchiveMetadataBindingError):
+ raise
+ raise ArchiveMetadataBindingError("metadata_requires_review") from None
+ return ArchiveMetadataBinding(
+ file.id,
+ root.id,
+ root.path,
+ file.file_path,
+ file.file_size,
+ file.file_modified_at,
+ metadata,
+ )
+
+
+def _require_verified(entity: RefreshEntityState) -> None:
+ if not entity.claims or any(
+ state is not IdentityVerificationState.VERIFIED for _, state, _ in entity.claims
+ ):
+ raise ArchiveMetadataBindingError("identity_requires_review")
+ comicvine = next(
+ (ref for ref in entity.identities if ref.namespace is IdentityNamespace.COMICVINE), None
+ )
+ if (comicvine is not None and entity.comicvine_id != int(comicvine.external_id)) or (
+ entity.comicvine_id is not None and comicvine is None
+ ):
+ raise ArchiveMetadataBindingError("identity_requires_review")
+
+
+async def revalidate_archive_metadata_binding(
+ session: AsyncSession, binding: ArchiveMetadataBinding
+) -> None:
+ """Reject stale targets, ownership, policies, baselines and user values."""
+ current = await read_archive_metadata_binding(
+ session, binding.library_file_id, expected_issue_id=binding.metadata.issues[0].local_id
+ )
+ if current != binding:
+ raise ArchiveMetadataBindingError("binding_changed")
+
+
+@dataclass(frozen=True)
+class ArchiveMetadataTarget:
+ binding: ArchiveMetadataBinding
+ path: Path
+ fingerprint: FileFingerprint
+ directories: tuple[DirectoryFingerprint, ...]
+
+ def check_unchanged(self) -> None:
+ """Synchronous stat boundary; offload on async paths and check before publish."""
+ if _inspect_target(self.binding) != self:
+ raise ArchiveMetadataBindingError("source_changed")
+
+
+async def inspect_archive_metadata_target(binding: ArchiveMetadataBinding) -> ArchiveMetadataTarget:
+ """Inspect outside the DB session; never open archives or perform write probes."""
+ return await asyncio.to_thread(_inspect_target, binding)
+
+
+async def revalidate_archive_metadata_target(
+ session: AsyncSession, target: ArchiveMetadataTarget
+) -> None:
+ """Revalidate the DB binding and canonical-path ownership before publication."""
+ await revalidate_archive_metadata_binding(session, target.binding)
+ try:
+ await require_mutable_library_target(
+ session, target.path, include_descendants=False, operation="updated"
+ )
+ except ReferencedFileMutationError:
+ raise ArchiveMetadataBindingError("reference_only") from None
+
+
+def _inspect_target(binding: ArchiveMetadataBinding) -> ArchiveMetadataTarget:
+ try:
+ raw_root, raw_file = Path(binding.root_path), Path(binding.file_path)
+ if any(
+ not path.is_absolute()
+ or ".." in path.parts
+ or any(ord(c) < 32 or ord(c) == 127 for c in str(path))
+ for path in (raw_root, raw_file)
+ ):
+ raise ArchiveMetadataBindingError("unsafe_path")
+ if not raw_file.is_relative_to(raw_root) or raw_file == raw_root:
+ raise ArchiveMetadataBindingError("outside_root")
+ if raw_file.suffix.casefold() not in {".cbz", ".zip"}:
+ raise ArchiveMetadataBindingError("unsupported_format")
+ root = raw_root.resolve(strict=True)
+ path = root / raw_file.relative_to(raw_root)
+ if path.resolve(strict=True) != path:
+ raise ArchiveMetadataBindingError("unsafe_path")
+ directories = []
+ parent = root
+ for part in ("", *path.parent.relative_to(root).parts):
+ parent = parent / part
+ info = parent.lstat()
+ if not stat.S_ISDIR(info.st_mode):
+ raise ArchiveMetadataBindingError("unsafe_path")
+ directories.append((parent, info.st_dev, info.st_ino, info.st_mode))
+ info = path.lstat()
+ if not stat.S_ISREG(info.st_mode):
+ raise ArchiveMetadataBindingError("unsafe_path")
+ if (
+ not info.st_mode & 0o222
+ or not directories[-1][3] & 0o222
+ or not os.access(path, os.R_OK | os.W_OK)
+ or not os.access(path.parent, os.W_OK | os.X_OK)
+ ):
+ raise ArchiveMetadataBindingError("readonly_source")
+ if (
+ info.st_size != binding.file_size
+ or datetime.fromtimestamp(info.st_mtime, UTC) != binding.file_modified_at
+ ):
+ raise ArchiveMetadataBindingError("source_changed")
+ return ArchiveMetadataTarget(
+ binding,
+ path,
+ (
+ info.st_dev,
+ info.st_ino,
+ info.st_size,
+ info.st_mtime_ns,
+ info.st_ctime_ns,
+ info.st_mode,
+ ),
+ tuple(directories),
+ )
+ except (OSError, RuntimeError, ValueError) as exc:
+ if isinstance(exc, ArchiveMetadataBindingError):
+ raise
+ raise ArchiveMetadataBindingError("source_unavailable") from None
+
+
+def assemble_bound_archive_metadata(
+ binding: ArchiveMetadataBinding,
+ archive: ArchiveMetadataReconciliation,
+ *,
+ now: datetime,
+ issue_candidates: Sequence[ProviderIssueRead] = (),
+) -> tuple[MetadataSnapshot, MetadataSnapshot]:
+ """Combine DB values and local evidence without granting embedded IDs ownership."""
+ try:
+ return _assemble(binding.metadata, archive, now, issue_candidates)
+ except ValueError:
+ raise ArchiveMetadataBindingError("metadata_requires_review") from None
+
+
+def _assemble(
+ metadata: SeriesRefreshState,
+ archive: ArchiveMetadataReconciliation | None,
+ now: datetime,
+ issue_candidates: Sequence[ProviderIssueRead] = (),
+) -> tuple[MetadataSnapshot, MetadataSnapshot]:
+ series, issue = metadata.series, metadata.issues[0]
+ return (
+ assemble_metadata(
+ MetadataEntityKind.SERIES,
+ series.identities,
+ (),
+ metadata.policies,
+ now=now,
+ current=series.values,
+ previous=series.baseline,
+ overrides=series.overrides,
+ archive=archive,
+ ),
+ assemble_metadata(
+ MetadataEntityKind.ISSUE,
+ issue.identities,
+ issue_candidates,
+ metadata.policies,
+ now=now,
+ current=issue.values,
+ previous=issue.baseline,
+ overrides=issue.overrides,
+ parent_identities=series.identities,
+ archive=archive,
+ ),
+ )
diff --git a/src/pullbox/services/archive_metadata_finalization.py b/src/pullbox/services/archive_metadata_finalization.py
new file mode 100644
index 00000000..dd49efb8
--- /dev/null
+++ b/src/pullbox/services/archive_metadata_finalization.py
@@ -0,0 +1,131 @@
+"""Finalize proven paired publication in the caller's database transaction."""
+
+from datetime import UTC, datetime
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.models import Issue, LibraryFile, Series
+from pullbox.models.archive_metadata_publication import PublicationState
+from pullbox.services.archive_metadata_binding import revalidate_archive_metadata_target
+from pullbox.services.archive_metadata_publication import (
+ ArchivePublicationError,
+ ArchivePublicationInspection,
+ ArchivePublicationReceipt,
+ _clean_session,
+ _directories_unchanged,
+ _file_work,
+ _lock_binding,
+ _locked_row,
+ _published,
+ _receipt,
+ _stat_fingerprint,
+ load_archive_publication,
+)
+from pullbox.services.import_archive_publication import (
+ acknowledge_import_archive_publication,
+ require_import_archive_owner,
+)
+from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines
+from pullbox.services.metadata_credits import write_issue_credits
+from pullbox.services.metadata_entity_values import (
+ apply_issue_metadata_values,
+ apply_series_metadata_values,
+)
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+
+def _check_output(
+ receipt: ArchivePublicationReceipt, inspection: ArchivePublicationInspection
+) -> datetime:
+ try:
+ _directories_unchanged(receipt.plan)
+ info = receipt.plan.target.path.lstat()
+ if _stat_fingerprint(info) != inspection.fingerprint or not _published(
+ receipt.plan, inspection
+ ):
+ raise ArchivePublicationError("inspection_changed")
+ # Match existing registration/binding conversion, not ns-to-float division
+ # which can round a submicrosecond boundary differently from stat.st_mtime.
+ return datetime.fromtimestamp(info.st_mtime, UTC)
+ except OSError:
+ raise ArchivePublicationError("inspection_changed") from None
+
+
+async def finalize_archive_publication(
+ session: AsyncSession,
+ receipt: ArchivePublicationReceipt,
+ inspection: ArchivePublicationInspection,
+) -> ArchivePublicationReceipt:
+ """Adopt only an independently inspected, still-current publication.
+
+ Hashing belongs to inspection outside this transaction. Short file rechecks
+ are offloaded under the journal lock. A completed replay is historical proof,
+ not permission to reapply metadata or proof of current filesystem ownership.
+ Workflow rollback journals and legacy writers still need owner integration.
+ """
+ _clean_session(session)
+ if (inspection.operation_id, inspection.revision) != (receipt.operation_id, receipt.revision):
+ raise ArchivePublicationError("publication_changed")
+ async with metadata_write_scope(session):
+ known = await load_archive_publication(session, receipt.operation_id)
+ if known is None:
+ raise ArchivePublicationError("publication_missing")
+ if known.plan != receipt.plan:
+ raise ArchivePublicationError("publication_changed")
+ await _lock_binding(session, known.plan)
+ row = await _locked_row(session, receipt.operation_id)
+ current = _receipt(row)
+ if current.state is PublicationState.FINALIZED and (
+ current == receipt
+ or (
+ receipt.state is PublicationState.PUBLISHED
+ and current.revision == receipt.revision + 1
+ and current.plan == receipt.plan
+ )
+ ):
+ return current
+ if current != receipt:
+ raise ArchivePublicationError("publication_changed")
+ if current.state is not PublicationState.PUBLISHED:
+ raise ArchivePublicationError("publication_not_published")
+ plan = receipt.plan
+ await require_import_archive_owner(session, plan)
+ await revalidate_archive_metadata_target(session, plan.target)
+ modified_at = await _file_work(lambda stop: _check_output(receipt, inspection))
+ bound = plan.target.binding.metadata
+ if (
+ not plan.series.values.title
+ or not plan.series.values.sort_title
+ or plan.series.values.issue_count != bound.series.values.issue_count
+ or plan.issue.values.issue_number_text != bound.issues[0].values.issue_number_text
+ ):
+ raise ArchivePublicationError("invalid_plan")
+ series = await session.get(Series, bound.series.local_id)
+ issue = await session.get(Issue, bound.issues[0].local_id)
+ file = await session.get(LibraryFile, plan.target.binding.library_file_id)
+ assert series is not None and issue is not None and file is not None
+ await apply_series_metadata_values(session, series, plan.series.values)
+ apply_issue_metadata_values(issue, plan.issue.values)
+ await write_issue_credits(session, {issue.id: plan.issue.values.credits})
+ await save_metadata_baselines(
+ session,
+ [
+ MetadataBaselineWrite(entity.local_id, snapshot, entity.baseline_revision)
+ for entity, snapshot in ((bound.series, plan.series), (bound.issues[0], plan.issue))
+ if entity.baseline != snapshot
+ ],
+ )
+ assert inspection.fingerprint is not None
+ file.file_size = inspection.fingerprint[2]
+ file.file_modified_at = modified_at
+ file.file_hash = plan.output_digest
+ file.has_comicinfo = True
+ await acknowledge_import_archive_publication(session, receipt)
+ await session.flush()
+ await _file_work(lambda stop: _check_output(receipt, inspection))
+ row.state = PublicationState.FINALIZED
+ row.revision += 1
+ row.active_file_id = None
+ row.active_path_key = None
+ await session.flush()
+ return _receipt(row)
diff --git a/src/pullbox/services/archive_metadata_preservation.py b/src/pullbox/services/archive_metadata_preservation.py
new file mode 100644
index 00000000..53c2ee28
--- /dev/null
+++ b/src/pullbox/services/archive_metadata_preservation.py
@@ -0,0 +1,250 @@
+"""Preserve schema-specific values while coordinating shared archive metadata."""
+
+from xml.etree import ElementTree as ET
+
+from pullbox.core.archive_metadata import ArchiveMetadataFiles
+from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml
+from pullbox.core.metroninfo_schema import validate_metroninfo_xml
+
+_SCHEMA_HINT = "{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation"
+_CI_FIELDS = frozenset(
+ [
+ "Title",
+ "Series",
+ "Number",
+ "Count",
+ "Volume",
+ "AlternateSeries",
+ "AlternateNumber",
+ "AlternateCount",
+ "Summary",
+ "Notes",
+ "Year",
+ "Month",
+ "Day",
+ "Writer",
+ "Penciller",
+ "Inker",
+ "Colorist",
+ "Letterer",
+ "CoverArtist",
+ "Editor",
+ "Translator",
+ "Publisher",
+ "Imprint",
+ "Genre",
+ "Tags",
+ "Web",
+ "PageCount",
+ "LanguageISO",
+ "Format",
+ "BlackAndWhite",
+ "Manga",
+ "Characters",
+ "Teams",
+ "Locations",
+ "ScanInformation",
+ "StoryArc",
+ "StoryArcNumber",
+ "SeriesGroup",
+ "AgeRating",
+ "CommunityRating",
+ "MainCharacterOrTeam",
+ "Review",
+ "GTIN",
+ ]
+)
+_PAGE_ATTRS = frozenset(
+ ["Image", "Type", "DoublePage", "ImageSize", "Key", "Bookmark", "ImageWidth", "ImageHeight"]
+)
+
+
+class ArchiveMetadataRenderError(ValueError):
+ """Fixed error code and field locator, not untrusted metadata content."""
+
+ def __init__(self, code: str, field: str = "") -> None:
+ self.code = code
+ self.field = field
+ super().__init__(code)
+
+
+def preserved_roots(files: ArchiveMetadataFiles) -> tuple[ET.Element, ET.Element]:
+ roots = []
+ for member, name in ((files.comicinfo, "ComicInfo"), (files.metroninfo, "MetronInfo")):
+ if (
+ member.entry_count > 1
+ or member.diagnostics
+ or (member.entry_count and member.payload is None)
+ ):
+ raise ArchiveMetadataRenderError("invalid_input", name)
+ try:
+ root = (
+ parse_metadata_xml(member.payload, root_name=name, preserve_misc=True)
+ if member.payload is not None
+ else ET.Element(name)
+ )
+ if name == "ComicInfo":
+ _validate_comicinfo(root)
+ elif member.payload is not None:
+ if root.get("version") not in {None, "1.0", "1.1"}:
+ raise ArchiveMetadataRenderError("unsupported_content", name)
+ root.attrib.pop("version", None)
+ root.attrib.pop(_SCHEMA_HINT, None)
+ for node in root.findall("IDS/ID"):
+ source = node.get("source", "").strip().casefold()
+ label = {
+ "comicvine": "Comic Vine",
+ "comic vine": "Comic Vine",
+ "metron": "Metron",
+ "gcd": "Grand Comics Database",
+ "grand comics database": "Grand Comics Database",
+ "locg": "League of Comic Geeks",
+ "league of comic geeks": "League of Comic Geeks",
+ }.get(source)
+ if label is not None:
+ node.set("source", label)
+ for node in (*root.findall("GTIN/ISBN"), *root.findall("GTIN/UPC")):
+ if node.attrib or len(node):
+ raise ArchiveMetadataRenderError("unsupported_content", "GTIN")
+ validate_metroninfo_xml(ET.tostring(root, encoding="utf-8"))
+ except MetadataXmlError as exc:
+ raise ArchiveMetadataRenderError("invalid_input", name) from exc
+ roots.append(root)
+ return roots[0], roots[1]
+
+
+def _validate_comicinfo(root: ET.Element) -> None:
+ if set(root.attrib) - {_SCHEMA_HINT} or (root.text or "").strip():
+ raise ArchiveMetadataRenderError("unsupported_content", "ComicInfo")
+ seen = set()
+ for node in root:
+ if not isinstance(node.tag, str):
+ continue
+ if node.tag in seen or (node.tail or "").strip():
+ raise ArchiveMetadataRenderError("unsupported_content", "ComicInfo")
+ seen.add(node.tag)
+ if node.tag == "Pages":
+ if node.attrib or (node.text or "").strip():
+ raise ArchiveMetadataRenderError("unsupported_content", "Pages")
+ for page in node:
+ if not isinstance(page.tag, str):
+ continue
+ if (
+ page.tag != "Page"
+ or set(page.attrib) - _PAGE_ATTRS
+ or len(page)
+ or (page.text or "").strip()
+ or (page.tail or "").strip()
+ ):
+ raise ArchiveMetadataRenderError("unsupported_content", "Pages")
+ elif node.tag not in _CI_FIELDS or node.attrib or len(node):
+ raise ArchiveMetadataRenderError("unsupported_content", "ComicInfo")
+
+
+def set_text(root: ET.Element, path: str, value: object) -> None:
+ node = root.find(path)
+ if node is not None and "id" in node.attrib and node.text != str(value):
+ raise ArchiveMetadataRenderError("resource_metadata_change", path)
+ if value is None or value == "":
+ if node is not None:
+ if node.attrib or len(node):
+ raise ArchiveMetadataRenderError("unsupported_content", path)
+ root.remove(node)
+ return
+ if node is None:
+ node = ET.SubElement(root, path)
+ node.text = str(value)
+
+
+def _parts(value: str | None) -> tuple[str, ...]:
+ return tuple(part.strip() for part in value.split(",") if part.strip()) if value else ()
+
+
+def coordinate_preserved_fields(ci: ET.Element, mi: ET.Element) -> None:
+ """Build shared values from agreeing existing evidence, never attach identities."""
+ for ci_tag, mi_path in (
+ ("Genre", "Genres/Genre"),
+ ("Tags", "Tags/Tag"),
+ ("Characters", "Characters/Character"),
+ ("Teams", "Teams/Team"),
+ ("Locations", "Locations/Location"),
+ ):
+ left = _parts(ci.findtext(ci_tag))
+ right = tuple((node.text or "").strip() for node in mi.findall(mi_path))
+ if any(not value or "," in value for value in right):
+ raise ArchiveMetadataRenderError("unsupported_content", ci_tag)
+ if left and right and left != right:
+ raise ArchiveMetadataRenderError("unreconciled_field", ci_tag)
+ values = right or left
+ if values:
+ set_text(ci, ci_tag, ", ".join(values))
+ if not right:
+ parent_tag, child_tag = mi_path.split("/")
+ parent = mi.find(parent_tag)
+ if parent is None:
+ parent = ET.SubElement(mi, parent_tag)
+ for value in values:
+ ET.SubElement(parent, child_tag).text = value
+ for ci_tag, mi_path in (("Imprint", "Publisher/Imprint"), ("AgeRating", "AgeRating")):
+ left_text, right_text = ci.findtext(ci_tag), mi.findtext(mi_path)
+ if left_text and right_text and left_text != right_text:
+ raise ArchiveMetadataRenderError("unreconciled_field", ci_tag)
+ scalar = right_text or left_text
+ if scalar:
+ set_text(ci, ci_tag, scalar)
+ if not right_text:
+ if "/" in mi_path:
+ parent_tag, child_tag = mi_path.split("/")
+ parent = mi.find(parent_tag)
+ if parent is None:
+ raise ArchiveMetadataRenderError("unreconciled_field", ci_tag)
+ set_text(parent, child_tag, scalar)
+ else:
+ set_text(mi, mi_path, scalar)
+ _arcs(ci, mi)
+
+
+def _arcs(ci: ET.Element, mi: ET.Element) -> None:
+ names = _parts(ci.findtext("StoryArc"))
+ numbers = _parts(ci.findtext("StoryArcNumber"))
+ if numbers and (
+ len(numbers) != len(names)
+ or any(len(n) > 10 or not n.isascii() or not n.isdecimal() or int(n) < 1 for n in numbers)
+ ):
+ raise ArchiveMetadataRenderError("unsupported_content", "StoryArcNumber")
+ arcs = mi.findall("Arcs/Arc")
+ right_names = tuple((arc.findtext("Name") or "").strip() for arc in arcs)
+ right_numbers = tuple(arc.findtext("Number") for arc in arcs)
+ if any(not name or "," in name for name in right_names):
+ raise ArchiveMetadataRenderError("unsupported_content", "StoryArc")
+ if names and right_names and names != right_names:
+ raise ArchiveMetadataRenderError("unreconciled_field", "StoryArc")
+ if (
+ numbers
+ and right_numbers
+ and any(
+ right is not None and int(left) != int(right)
+ for left, right in zip(numbers, right_numbers, strict=True)
+ )
+ ):
+ raise ArchiveMetadataRenderError("unreconciled_field", "StoryArcNumber")
+ if right_names:
+ set_text(ci, "StoryArc", ", ".join(right_names))
+ if numbers:
+ for arc, number in zip(arcs, numbers, strict=True):
+ set_text(arc, "Number", int(number))
+ elif all(right_numbers):
+ set_text(
+ ci,
+ "StoryArcNumber",
+ ", ".join(str(int(number)) for number in right_numbers if number is not None),
+ )
+ elif names:
+ parent = mi.find("Arcs")
+ if parent is None:
+ parent = ET.SubElement(mi, "Arcs")
+ for index, name in enumerate(names):
+ arc = ET.SubElement(parent, "Arc")
+ ET.SubElement(arc, "Name").text = name
+ if numbers:
+ ET.SubElement(arc, "Number").text = str(int(numbers[index]))
diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py
new file mode 100644
index 00000000..cf8485c5
--- /dev/null
+++ b/src/pullbox/services/archive_metadata_publication.py
@@ -0,0 +1,473 @@
+"""Durable intent and serialized publication; canonical finalization stays separate."""
+
+import asyncio
+import hashlib
+import os
+import stat
+from collections.abc import Callable
+from contextlib import suppress
+from dataclasses import dataclass
+from pathlib import Path
+from threading import Event
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy import or_, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.archive_metadata import read_archive_metadata
+from pullbox.core.metadata_identity import MetadataEntityKind
+from pullbox.models import Issue, LibraryFile, LibraryRoot, Series
+from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.schemas.archive_publication_owner import ImportArchiveOwner
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot
+from pullbox.services.archive_metadata_binding import (
+ ArchiveMetadataTarget,
+ FileFingerprint,
+ revalidate_archive_metadata_target,
+)
+from pullbox.services.archive_metadata_rendering import (
+ ArchiveMetadataRenderError,
+ render_archive_metadata,
+)
+from pullbox.services.library_mutation_coordination import lock_file_mutation_admission
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+from pullbox.utilities.executors.archive_metadata_staging import StagedArchiveMetadata
+
+_MAX_PLAN_BYTES = 4 * 1024 * 1024
+
+
+class ArchivePublicationError(ValueError):
+ """Fixed publication diagnostic without paths or embedded payloads."""
+
+ def __init__(self, code: str) -> None:
+ self.code = code
+ super().__init__(code)
+
+
+class ArchivePublicationPlan(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ schema_version: Literal[1] = 1
+ target: ArchiveMetadataTarget
+ stage_path: Path
+ stage_fingerprint: FileFingerprint
+ source_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
+ output_digest: str = Field(pattern=r"^[a-f0-9]{64}$")
+ series: MetadataSnapshot
+ issue: MetadataSnapshot
+ import_owner: ImportArchiveOwner | None = None
+ metadata_job_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
+
+
+@dataclass(frozen=True)
+class ArchivePublicationReceipt:
+ operation_id: UUID
+ revision: int
+ state: PublicationState
+ plan: ArchivePublicationPlan
+
+
+@dataclass(frozen=True)
+class ArchivePublicationInspection:
+ operation_id: UUID
+ revision: int
+ fingerprint: FileFingerprint | None
+ digest: str | None
+
+
+def _fingerprint(path: Path) -> FileFingerprint | None:
+ try:
+ return _stat_fingerprint(path.lstat())
+ except FileNotFoundError:
+ return None
+
+
+def _stat_fingerprint(info: os.stat_result) -> FileFingerprint:
+ return (
+ info.st_dev,
+ info.st_ino,
+ info.st_size,
+ info.st_mtime_ns,
+ info.st_ctime_ns,
+ info.st_mode,
+ )
+
+
+def _check_cancelled(stop: Event) -> None:
+ if stop.is_set():
+ raise ArchivePublicationError("cancelled")
+
+
+async def _file_work[T](work: Callable[[Event], T]) -> T:
+ stop = Event()
+ task = asyncio.create_task(asyncio.to_thread(work, stop))
+ try:
+ return await asyncio.shield(task)
+ except asyncio.CancelledError:
+ stop.set()
+ while not task.done():
+ with suppress(asyncio.CancelledError, OSError, ValueError):
+ await asyncio.shield(task)
+ if not task.cancelled():
+ task.exception()
+ raise
+
+
+def _digest(path: Path, expected: FileFingerprint, stop: Event) -> str:
+ _check_cancelled(stop)
+ if not stat.S_ISREG(expected[5]):
+ raise ArchivePublicationError("file_changed")
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
+ with os.fdopen(fd, "rb") as stream:
+ if _stat_fingerprint(os.fstat(stream.fileno())) != expected:
+ raise ArchivePublicationError("file_changed")
+ digest = hashlib.sha256()
+ remaining = expected[2]
+ while remaining:
+ _check_cancelled(stop)
+ chunk = stream.read(min(1024 * 1024, remaining))
+ if not chunk:
+ raise ArchivePublicationError("file_changed")
+ remaining -= len(chunk)
+ digest.update(chunk)
+ if (
+ stream.read(1)
+ or _stat_fingerprint(os.fstat(stream.fileno())) != expected
+ or _fingerprint(path) != expected
+ ):
+ raise ArchivePublicationError("file_changed")
+ return digest.hexdigest()
+
+
+async def prepare_archive_publication(
+ target: ArchiveMetadataTarget,
+ staged: StagedArchiveMetadata,
+ series: MetadataSnapshot,
+ issue: MetadataSnapshot,
+) -> ArchivePublicationPlan:
+ """Hash verified evidence outside DB sessions; change only the private stage mode."""
+ return await _file_work(lambda stop: _prepare(target, staged, series, issue, stop))
+
+
+def _prepare(
+ target: ArchiveMetadataTarget,
+ staged: StagedArchiveMetadata,
+ series: MetadataSnapshot,
+ issue: MetadataSnapshot,
+ stop: Event,
+) -> ArchivePublicationPlan:
+ _check_cancelled(stop)
+ bound = target.binding.metadata
+ if (
+ series.entity_kind is not MetadataEntityKind.SERIES
+ or issue.entity_kind is not MetadataEntityKind.ISSUE
+ or series.identities != bound.series.identities
+ or issue.identities != bound.issues[0].identities
+ or staged.source_path != target.path
+ or staged.source_fingerprint != target.fingerprint
+ or staged.path.parent.parent != target.path.parent
+ or not staged.path.parent.name.startswith(".pullbox-metadata-stage-")
+ or staged.path.name != "metadata.cbz"
+ or staged.path.resolve(strict=True) != staged.path
+ or staged.output_fingerprint[0] != target.fingerprint[0]
+ ):
+ raise ArchivePublicationError("invalid_plan")
+ target.check_unchanged()
+ staged.check_unchanged()
+ files = read_archive_metadata(staged.path, "cbz", max_solid_scan_bytes=_MAX_PLAN_BYTES)
+ try:
+ pair = render_archive_metadata(series, issue, files)
+ if pair.comicinfo != files.comicinfo.payload or pair.metroninfo != files.metroninfo.payload:
+ raise ArchivePublicationError("snapshot_disagrees")
+ except ArchiveMetadataRenderError:
+ raise ArchivePublicationError("snapshot_disagrees") from None
+ _check_cancelled(stop)
+ fd = os.open(staged.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
+ with os.fdopen(fd, "rb") as stream:
+ if _stat_fingerprint(os.fstat(stream.fileno())) != staged.output_fingerprint:
+ raise ArchivePublicationError("file_changed")
+ os.fchmod(stream.fileno(), stat.S_IMODE(target.fingerprint[5]) & 0o777)
+ output = _stat_fingerprint(os.fstat(stream.fileno()))
+ os.fsync(stream.fileno())
+ plan = ArchivePublicationPlan(
+ target=target,
+ stage_path=staged.path,
+ stage_fingerprint=output,
+ source_digest=_digest(target.path, target.fingerprint, stop),
+ output_digest=_digest(staged.path, output, stop),
+ series=series,
+ issue=issue,
+ )
+ _encode(plan)
+ return plan
+
+
+def _encode(plan: ArchivePublicationPlan) -> str:
+ encoded = plan.model_dump_json()
+ if len(encoded.encode("utf-8")) > _MAX_PLAN_BYTES:
+ raise ArchivePublicationError("plan_too_large")
+ if ArchivePublicationPlan.model_validate_json(encoded) != plan:
+ raise ArchivePublicationError("invalid_plan")
+ return encoded
+
+
+def _receipt(row: ArchiveMetadataPublication) -> ArchivePublicationReceipt:
+ try:
+ if len(row.plan_json.encode("utf-8")) > _MAX_PLAN_BYTES:
+ raise ValueError("Too large")
+ plan = ArchivePublicationPlan.model_validate_json(row.plan_json)
+ return ArchivePublicationReceipt(UUID(row.operation_id), row.revision, row.state, plan)
+ except ValueError:
+ raise ArchivePublicationError("invalid_journal") from None
+
+
+def _clean_session(session: AsyncSession) -> None:
+ if session.new or session.dirty or session.deleted:
+ raise ArchivePublicationError("pending_session_changes")
+
+
+async def load_archive_publication(
+ session: AsyncSession, operation_id: UUID
+) -> ArchivePublicationReceipt | None:
+ _clean_session(session)
+ row = await session.scalar(
+ select(ArchiveMetadataPublication)
+ .where(ArchiveMetadataPublication.operation_id == str(operation_id))
+ .execution_options(populate_existing=True)
+ )
+ return _receipt(row) if row else None
+
+
+async def _lock_binding(session: AsyncSession, plan: ArchivePublicationPlan) -> None:
+ from pullbox.services.import_archive_publication import lock_import_archive_owner
+
+ await lock_import_archive_owner(session, plan.import_owner)
+ bound = plan.target.binding
+ await session.execute(
+ select(MetadataSourceConfig.id)
+ .order_by(MetadataSourceConfig.source)
+ .with_for_update(read=True)
+ )
+ for model, local_id in (
+ (Series, bound.metadata.series.local_id),
+ (Issue, bound.metadata.issues[0].local_id),
+ (LibraryRoot, bound.library_root_id),
+ (LibraryFile, bound.library_file_id),
+ ):
+ await session.execute(select(model.id).where(model.id == local_id).with_for_update())
+
+
+async def record_archive_publication(
+ session: AsyncSession, plan: ArchivePublicationPlan, operation_id: UUID
+) -> ArchivePublicationReceipt:
+ """Caller must commit this intent before any filesystem publication.
+
+ Reservations do not expire. Recovery needs the same row lock as publication;
+ elapsed time is never permission to let another writer take over.
+ """
+ from pullbox.services.import_archive_publication import require_import_archive_owner
+ from pullbox.services.library_conversion_recovery import require_no_library_conversion
+ from pullbox.services.library_removal import require_no_library_removal
+
+ _clean_session(session)
+ encoded = _encode(plan)
+ path_key = hashlib.sha256(os.fsencode(plan.target.path)).hexdigest()
+ try:
+ async with metadata_write_scope(session):
+ await lock_file_mutation_admission(session)
+ await require_no_library_removal(
+ session, plan.target.path, plan.stage_path, include_descendants=False
+ )
+ await require_no_library_conversion(
+ session, plan.target.path, plan.stage_path, include_descendants=False
+ )
+ await _lock_binding(session, plan)
+ existing = await load_archive_publication(session, operation_id)
+ if existing:
+ if existing.plan != plan:
+ raise ArchivePublicationError("operation_reused")
+ return existing
+ busy = await session.scalar(
+ select(ArchiveMetadataPublication.id)
+ .where(
+ or_(
+ ArchiveMetadataPublication.active_file_id
+ == plan.target.binding.library_file_id,
+ ArchiveMetadataPublication.active_path_key == path_key,
+ )
+ )
+ .limit(1)
+ )
+ if busy:
+ raise ArchivePublicationError("publication_busy")
+ await require_import_archive_owner(session, plan)
+ await revalidate_archive_metadata_target(session, plan.target)
+ row = ArchiveMetadataPublication(
+ operation_id=str(operation_id),
+ library_file_id=plan.target.binding.library_file_id,
+ active_file_id=plan.target.binding.library_file_id,
+ active_path_key=path_key,
+ plan_json=encoded,
+ )
+ session.add(row)
+ await session.flush()
+ session.info[f"archive_intent:{operation_id}"] = session.sync_session.get_transaction()
+ return _receipt(row)
+ except IntegrityError:
+ raise ArchivePublicationError("publication_busy") from None
+
+
+async def _locked_row(session: AsyncSession, operation_id: UUID) -> ArchiveMetadataPublication:
+ row = await session.scalar(
+ select(ArchiveMetadataPublication)
+ .where(ArchiveMetadataPublication.operation_id == str(operation_id))
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if row is None:
+ raise ArchivePublicationError("publication_missing")
+ return row
+
+
+def _directories_unchanged(plan: ArchivePublicationPlan) -> None:
+ for path, device, inode, mode in plan.target.directories:
+ info = path.lstat()
+ if (info.st_dev, info.st_ino, info.st_mode) != (device, inode, mode):
+ raise ArchivePublicationError("directories_changed")
+
+
+async def publish_archive_publication(
+ session: AsyncSession, operation_id: UUID
+) -> ArchivePublicationReceipt:
+ """Replace under a short DB write lock, using an already committed intent.
+
+ Only stat/rename/directory-sync operations occur in the short offloaded file
+ boundary, never archive reads or hashes. Cancellation joins it before releasing
+ the DB lock. Commit failure leaves intent for evidence-based recovery. The
+ reservation remains until separate canonical DB finalization commits. Existing
+ writers still require workflow ownership and rollback integration.
+ """
+ from pullbox.services.import_archive_publication import require_import_archive_owner
+
+ _clean_session(session)
+ if (
+ session.info.get(f"archive_intent:{operation_id}") is session.sync_session.get_transaction()
+ and session.in_transaction()
+ ):
+ raise ArchivePublicationError("intent_not_committed")
+ async with metadata_write_scope(session):
+ receipt = await load_archive_publication(session, operation_id)
+ if receipt is None:
+ raise ArchivePublicationError("publication_missing")
+ await _lock_binding(session, receipt.plan)
+ row = await _locked_row(session, operation_id)
+ if row.state is not PublicationState.INTENDED:
+ raise ArchivePublicationError("publication_not_intended")
+ plan = _receipt(row).plan
+ await require_import_archive_owner(session, plan)
+ await revalidate_archive_metadata_target(session, plan.target)
+ await _file_work(lambda stop: _publish_files(plan, stop))
+ row.state = PublicationState.PUBLISHED
+ row.revision += 1
+ await session.flush()
+ return _receipt(row)
+
+
+def _publish_files(plan: ArchivePublicationPlan, stop: Event) -> None:
+ plan.target.check_unchanged()
+ if (
+ plan.stage_path.resolve(strict=True) != plan.stage_path
+ or _fingerprint(plan.stage_path) != plan.stage_fingerprint
+ ):
+ raise ArchivePublicationError("file_changed")
+ _check_cancelled(stop)
+ os.replace(plan.stage_path, plan.target.path)
+ # Persist the destination directory entry before reporting publication. A
+ # failed sync leaves intent for inspection, never permission to publish again.
+ fd = os.open(plan.target.path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+
+
+async def inspect_archive_publication(
+ receipt: ArchivePublicationReceipt,
+) -> ArchivePublicationInspection:
+ """Read content outside the DB write boundary; this never repairs or deletes files."""
+ return await _file_work(lambda stop: _inspect(receipt, stop))
+
+
+def _inspect(receipt: ArchivePublicationReceipt, stop: Event) -> ArchivePublicationInspection:
+ _check_cancelled(stop)
+ plan = receipt.plan
+ fingerprint = _fingerprint(plan.target.path)
+ digest = None
+ if (
+ fingerprint is not None
+ and stat.S_ISREG(fingerprint[5])
+ and fingerprint[2] in {plan.target.fingerprint[2], plan.stage_fingerprint[2]}
+ ):
+ _directories_unchanged(plan)
+ digest = _digest(plan.target.path, fingerprint, stop)
+ return ArchivePublicationInspection(receipt.operation_id, receipt.revision, fingerprint, digest)
+
+
+def _published(plan: ArchivePublicationPlan, inspection: ArchivePublicationInspection) -> bool:
+ actual, expected = inspection.fingerprint, plan.stage_fingerprint
+ # Rename may change ctime. Identity, mode, size, mtime and actual bytes must agree.
+ return bool(
+ actual
+ and actual[:4] == expected[:4]
+ and actual[5] == expected[5]
+ and inspection.digest == plan.output_digest
+ )
+
+
+async def reconcile_archive_publication(
+ session: AsyncSession,
+ receipt: ArchivePublicationReceipt,
+ inspection: ArchivePublicationInspection,
+) -> ArchivePublicationReceipt:
+ """Fence the original publisher, then classify the inspected durable result.
+
+ Published means only filesystem publication, not canonical DB finalization.
+ Unknown content remains reserved for review; only a proven untouched original
+ can release an intended reservation. No recovery path republishes a stage.
+ """
+ _clean_session(session)
+ async with metadata_write_scope(session):
+ row = await _locked_row(session, receipt.operation_id)
+ if _receipt(row) != receipt or (inspection.operation_id, inspection.revision) != (
+ receipt.operation_id,
+ receipt.revision,
+ ):
+ raise ArchivePublicationError("publication_changed")
+ if row.state in {PublicationState.FINALIZED, PublicationState.SETTLED}:
+ return receipt
+ _directories_unchanged(receipt.plan)
+ if _fingerprint(receipt.plan.target.path) != inspection.fingerprint:
+ raise ArchivePublicationError("inspection_changed")
+ if row.state in {PublicationState.ABANDONED, PublicationState.REVIEW}:
+ return receipt
+ state = PublicationState.REVIEW
+ if _published(receipt.plan, inspection):
+ state = PublicationState.PUBLISHED
+ elif (
+ row.state is PublicationState.INTENDED
+ and inspection.fingerprint == receipt.plan.target.fingerprint
+ and inspection.digest == receipt.plan.source_digest
+ ):
+ state = PublicationState.ABANDONED
+ if row.state is not state:
+ row.state = state
+ row.revision += 1
+ if state is PublicationState.ABANDONED:
+ row.active_file_id = None
+ row.active_path_key = None
+ await session.flush()
+ return _receipt(row)
diff --git a/src/pullbox/services/archive_metadata_reconciliation.py b/src/pullbox/services/archive_metadata_reconciliation.py
new file mode 100644
index 00000000..c5b0fa37
--- /dev/null
+++ b/src/pullbox/services/archive_metadata_reconciliation.py
@@ -0,0 +1,379 @@
+"""Compare embedded evidence without selecting identities or mutating archives."""
+
+import re
+from dataclasses import dataclass, field
+from datetime import date
+from urllib.parse import urlsplit
+from xml.etree import ElementTree
+
+from pydantic import ValidationError
+
+from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile
+from pullbox.core.archive_metadata_fields import (
+ COMICINFO_ROLES,
+ canonical_archive_format,
+ comicinfo_credits,
+)
+from pullbox.core.comicinfo_sanitizer import references_stale_retailer
+from pullbox.core.issue_numbers import normalize_issue_number_text
+from pullbox.core.metadata_identity import (
+ ExactIdentityConflict,
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+ find_exact_identity_conflicts,
+)
+from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml
+from pullbox.core.metroninfo import MetronInfoData, parse_metroninfo
+from pullbox.schemas.metadata_snapshot import MetadataValues
+
+_CI_ROLES = COMICINFO_ROLES
+_CV_HOSTS = frozenset(
+ {"comicvine.gamespot.com", "www.comicvine.gamespot.com", "comicvine.com", "www.comicvine.com"}
+)
+_CV_NOTES = (
+ (re.compile(r"\[(?:cv_vol_id|cvid):([^\]]*)\]", re.IGNORECASE), MetadataEntityKind.SERIES),
+ (re.compile(r"\[cv_issue_id:([^\]]*)\]", re.IGNORECASE), MetadataEntityKind.ISSUE),
+)
+
+
+@dataclass(frozen=True)
+class ArchiveMetadataDiagnostic:
+ document: str
+ code: str
+ locator: str
+
+
+@dataclass(frozen=True)
+class EmbeddedMetadataValues:
+ series: MetadataValues = field(default_factory=MetadataValues)
+ issue: MetadataValues = field(default_factory=MetadataValues)
+ evidence: tuple[ExactIdentityEvidence, ...] = ()
+ diagnostics: tuple[ArchiveMetadataDiagnostic, ...] = ()
+ publication_date_parts: tuple[int | None, int | None, int | None] = (None, None, None)
+ metron: MetronInfoData | None = None
+
+
+@dataclass(frozen=True)
+class ArchiveMetadataDifference:
+ entity: str
+ field: str
+ comicinfo: object
+ metroninfo: object
+
+
+@dataclass(frozen=True)
+class ArchiveMetadataReconciliation:
+ files: ArchiveMetadataFiles
+ comicinfo: EmbeddedMetadataValues = field(default_factory=EmbeddedMetadataValues)
+ metroninfo: EmbeddedMetadataValues = field(default_factory=EmbeddedMetadataValues)
+ series: MetadataValues = field(default_factory=MetadataValues)
+ issue: MetadataValues = field(default_factory=MetadataValues)
+ evidence: tuple[ExactIdentityEvidence, ...] = ()
+ identity_conflicts: tuple[ExactIdentityConflict, ...] = ()
+ differences: tuple[ArchiveMetadataDifference, ...] = ()
+ diagnostics: tuple[ArchiveMetadataDiagnostic, ...] = ()
+
+ @property
+ def requires_review(self) -> bool:
+ return bool(self.identity_conflicts or self.differences or self.diagnostics)
+
+
+def reconcile_archive_metadata(files: ArchiveMetadataFiles) -> ArchiveMetadataReconciliation:
+ """Return shared values and explicit disagreements, never authority or write permission."""
+ comic = _read_document(files.comicinfo, "ComicInfo.xml")
+ metron = _read_document(files.metroninfo, "MetronInfo.xml")
+ differences: list[ArchiveMetadataDifference] = []
+ series = _shared_values(comic.series, metron.series, "series", differences)
+ issue = _shared_values(comic.issue, metron.issue, "issue", differences)
+ if metron.issue.cover_date is not None:
+ incoming = metron.issue.cover_date
+ parts = (incoming.year, incoming.month, incoming.day)
+ if any(
+ left is not None and left != right
+ for left, right in zip(comic.publication_date_parts, parts, strict=True)
+ ):
+ if not any(item.field == "cover_date" for item in differences):
+ differences.append(
+ ArchiveMetadataDifference(
+ "issue", "cover_date", comic.publication_date_parts, incoming
+ )
+ )
+ issue = issue.model_copy(update={"cover_date": None})
+ evidence = tuple(dict.fromkeys((*comic.evidence, *metron.evidence)))
+ return ArchiveMetadataReconciliation(
+ files,
+ comic,
+ metron,
+ series,
+ issue,
+ evidence,
+ find_exact_identity_conflicts(evidence),
+ tuple(differences),
+ (*comic.diagnostics, *metron.diagnostics),
+ )
+
+
+def _shared_values(
+ comic: MetadataValues,
+ metron: MetadataValues,
+ entity: str,
+ differences: list[ArchiveMetadataDifference],
+) -> MetadataValues:
+ values: dict[str, object] = {}
+ for name in MetadataValues.model_fields:
+ left, right = getattr(comic, name), getattr(metron, name)
+ if (
+ name == "credits"
+ and comic.credits is not None
+ and metron.credits is not None
+ and comic.credits == comicinfo_credits(metron.credits)
+ ):
+ values[name] = metron.credits
+ continue
+ if left is not None and right is not None and left != right:
+ differences.append(ArchiveMetadataDifference(entity, name, left, right))
+ else:
+ values[name] = left if left is not None else right
+ return MetadataValues.model_validate(values)
+
+
+def _values(
+ fields: dict[str, tuple[object, str]],
+ document: str,
+ diagnostics: list[ArchiveMetadataDiagnostic],
+) -> MetadataValues:
+ values: dict[str, object] = {}
+ for name, (value, locator) in fields.items():
+ if value is None:
+ continue
+ try:
+ if name == "issue_number_text" and isinstance(value, str):
+ value = normalize_issue_number_text(value)
+ elif name == "series_type" and isinstance(value, str):
+ value = canonical_archive_format(value)
+ normalized = MetadataValues.model_validate({name: value})
+ except (ValidationError, ValueError):
+ diagnostics.append(ArchiveMetadataDiagnostic(document, "invalid_field", locator))
+ else:
+ values[name] = getattr(normalized, name)
+ return MetadataValues.model_validate(values)
+
+
+def _read_document(member: MetadataFile, document: str) -> EmbeddedMetadataValues:
+ diagnostics = [
+ ArchiveMetadataDiagnostic(document, item.value, document) for item in member.diagnostics
+ ]
+ if member.entry_count > 1 and not any(item.code == "duplicate_entries" for item in diagnostics):
+ diagnostics.append(ArchiveMetadataDiagnostic(document, "duplicate_entries", document))
+ if member.payload is None:
+ if member.entry_count and not diagnostics:
+ diagnostics.append(ArchiveMetadataDiagnostic(document, "unreadable", document))
+ return EmbeddedMetadataValues(diagnostics=tuple(diagnostics))
+ if document == "MetronInfo.xml":
+ return _metron_values(parse_metroninfo(member.payload), diagnostics)
+ try:
+ root = parse_metadata_xml(member.payload, root_name="ComicInfo")
+ except MetadataXmlError as exc:
+ diagnostics.append(ArchiveMetadataDiagnostic(document, exc.code, "ComicInfo"))
+ return EmbeddedMetadataValues(diagnostics=tuple(diagnostics))
+ return _ComicInfoReader(root, diagnostics).read()
+
+
+def _metron_values(
+ data: MetronInfoData, diagnostics: list[ArchiveMetadataDiagnostic]
+) -> EmbeddedMetadataValues:
+ document = "MetronInfo.xml"
+ diagnostics.extend(
+ ArchiveMetadataDiagnostic(document, item.code.value, item.locator)
+ for item in data.diagnostics
+ )
+ series = _values(
+ {
+ "title": (data.series, "Series/Name"),
+ "sort_title": (data.sort_name, "Series/SortName"),
+ "publisher": (data.publisher, "Publisher/Name"),
+ "language": (data.language, "Series/@lang"),
+ "year_start": (data.start_year, "Series/StartYear"),
+ "volume": (str(data.volume) if data.volume is not None else None, "Series/Volume"),
+ "series_type": (data.series_format, "Series/Format"),
+ "issue_count": (data.issue_count, "Series/IssueCount"),
+ },
+ document,
+ diagnostics,
+ )
+ credits = None
+ if data.credits is not None and not any(
+ item.locator.startswith("Credits") for item in data.diagnostics
+ ):
+ credits = tuple(
+ {"name": item.creator.value, "role": ", ".join(role.value for role in item.roles)}
+ for item in data.credits
+ )
+ issue = _values(
+ {
+ "issue_number_text": (data.number, "Number"),
+ "title": (data.stories[0] if len(data.stories) == 1 else None, "Stories"),
+ "description": (data.summary, "Summary"),
+ "page_count": (data.page_count, "PageCount"),
+ "cover_date": (data.cover_date, "CoverDate"),
+ "store_date": (data.store_date, "StoreDate"),
+ "credits": (credits, "Credits"),
+ },
+ document,
+ diagnostics,
+ )
+ return EmbeddedMetadataValues(series, issue, data.evidence, tuple(diagnostics), metron=data)
+
+
+class _ComicInfoReader:
+ def __init__(
+ self, root: ElementTree.Element, diagnostics: list[ArchiveMetadataDiagnostic]
+ ) -> None:
+ self.root = root
+ self.diagnostics = diagnostics
+ self.seen = {root}
+ self.evidence: list[ExactIdentityEvidence] = []
+
+ def diagnostic(self, code: str, locator: str) -> None:
+ item = ArchiveMetadataDiagnostic("ComicInfo.xml", code, locator)
+ if item not in self.diagnostics:
+ self.diagnostics.append(item)
+
+ def read_field(self, tag: str) -> str | None:
+ nodes = self.root.findall(tag)
+ self.seen.update(nodes)
+ if len(nodes) > 1:
+ self.diagnostic("ambiguous_field", tag)
+ return None
+ if not nodes:
+ return None
+ node = nodes[0]
+ value = (node.text or "").strip()
+ if len(node) or len(value) > (200000 if tag == "Summary" else 4096):
+ self.diagnostic("invalid_field", tag)
+ return None
+ return value or None
+
+ def integer(self, tag: str, minimum: int, maximum: int) -> int | None:
+ value = self.read_field(tag)
+ if value is None:
+ return None
+ if re.fullmatch(r"\+?[0-9]{1,10}", value) and minimum <= int(value) <= maximum:
+ return int(value)
+ self.diagnostic("invalid_field", tag)
+ return None
+
+ def identity(self, kind: MetadataEntityKind, value: str, locator: str) -> None:
+ try:
+ identity = ExternalIdentityRef(IdentityNamespace.COMICVINE, kind, value)
+ except ValueError:
+ self.diagnostic("invalid_id", locator)
+ else:
+ self.evidence.append(
+ ExactIdentityEvidence(identity, IdentityEvidenceKind.COMICINFO_XML)
+ )
+
+ def identities(self) -> None:
+ # Inspect every explicit assertion even when a duplicate field is ambiguous.
+ for tag in ("Notes", "Web"):
+ self.read_field(tag)
+ for node in self.root.findall(tag):
+ value = (node.text or "").strip()
+ if len(node) or len(value) > 4096 or references_stale_retailer(value):
+ continue
+ if tag == "Notes":
+ for pattern, kind in _CV_NOTES:
+ for match in pattern.finditer(value):
+ self.identity(kind, match[1], tag)
+ else:
+ for raw in value.split():
+ self.url_identity(raw)
+
+ def url_identity(self, value: str) -> None:
+ try:
+ url = urlsplit(value)
+ if (
+ url.scheme not in {"http", "https"}
+ or url.hostname not in _CV_HOSTS
+ or url.username is not None
+ or url.password is not None
+ or url.port not in {None, 80, 443}
+ ):
+ return
+ except ValueError:
+ return
+ match = re.fullmatch(
+ r"/(?:(?!(?:4050|4000)-[0-9]+/)[^/]+/)?(4050|4000)-([0-9]{1,255})/?", url.path
+ )
+ if match:
+ self.identity(
+ MetadataEntityKind.SERIES if match[1] == "4050" else MetadataEntityKind.ISSUE,
+ match[2],
+ "Web",
+ )
+
+ def read(self) -> EmbeddedMetadataValues:
+ document = "ComicInfo.xml"
+ series = _values(
+ {
+ "title": (self.read_field("Series"), "Series"),
+ "publisher": (self.read_field("Publisher"), "Publisher"),
+ "language": (self.read_field("LanguageISO"), "LanguageISO"),
+ "series_type": (self.read_field("Format"), "Format"),
+ "issue_count": (self.integer("Count", 0, 1000000), "Count"),
+ },
+ document,
+ self.diagnostics,
+ )
+ year = self.integer("Year", 1, 9999)
+ month = self.integer("Month", 1, 12)
+ day = self.integer("Day", 1, 31)
+ cover_date = None
+ if year is not None and month is not None and day is not None:
+ try:
+ cover_date = date(year, month, day)
+ except ValueError:
+ self.diagnostic("invalid_field", "CoverDate")
+ credits: list[dict[str, str]] = []
+ credits_present = False
+ for tag, role in _CI_ROLES.items():
+ credits_present = credits_present or bool(self.root.findall(tag))
+ value = self.read_field(tag)
+ if value:
+ credits.extend({"name": name.strip(), "role": role} for name in value.split(","))
+ invalid_credits = any(item.locator in _CI_ROLES for item in self.diagnostics)
+ issue = _values(
+ {
+ "issue_number_text": (self.read_field("Number"), "Number"),
+ "title": (self.read_field("Title"), "Title"),
+ "description": (self.read_field("Summary"), "Summary"),
+ "page_count": (self.integer("PageCount", 0, 1000000), "PageCount"),
+ "cover_date": (cover_date, "CoverDate"),
+ "credits": (
+ tuple(credits) if credits_present and not invalid_credits else None,
+ "Credits",
+ ),
+ },
+ document,
+ self.diagnostics,
+ )
+ self.identities()
+ schema_hint = "{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation"
+ if any(
+ node not in self.seen
+ or (set(node.attrib) - ({schema_hint} if node is self.root else set()))
+ or (node is self.root and (node.text or "").strip())
+ or (node.tail or "").strip()
+ for node in self.root.iter()
+ ):
+ self.diagnostic("unmapped_content", "ComicInfo")
+ return EmbeddedMetadataValues(
+ series,
+ issue,
+ tuple(dict.fromkeys(self.evidence)),
+ tuple(self.diagnostics),
+ (year, month, day),
+ )
diff --git a/src/pullbox/services/archive_metadata_rendering.py b/src/pullbox/services/archive_metadata_rendering.py
new file mode 100644
index 00000000..9630a9ee
--- /dev/null
+++ b/src/pullbox/services/archive_metadata_rendering.py
@@ -0,0 +1,488 @@
+"""Render coordinated metadata without authorizing filesystem or identity changes."""
+
+import re
+from dataclasses import dataclass
+from xml.etree import ElementTree as ET
+
+from pullbox.core.archive_metadata import ArchiveMetadataFiles, MetadataFile
+from pullbox.core.archive_metadata_fields import (
+ COMICINFO_ONLY_FORMATS as _COMICINFO_ONLY_FORMATS,
+)
+from pullbox.core.archive_metadata_fields import COMICINFO_ROLES, comicinfo_credits
+from pullbox.core.archive_metadata_fields import archive_format_label as _format_text
+from pullbox.core.issue_numbers import normalize_issue_number_text
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml
+from pullbox.core.metroninfo_schema import validate_metroninfo_xml
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues
+from pullbox.services.archive_metadata_preservation import (
+ ArchiveMetadataRenderError as ArchiveMetadataRenderError,
+)
+from pullbox.services.archive_metadata_preservation import (
+ coordinate_preserved_fields,
+ preserved_roots,
+ set_text,
+)
+from pullbox.services.archive_metadata_reconciliation import (
+ ArchiveMetadataReconciliation,
+ reconcile_archive_metadata,
+)
+
+_SOURCES = {
+ IdentityNamespace.COMICVINE: "Comic Vine",
+ IdentityNamespace.METRON: "Metron",
+ IdentityNamespace.GCD: "Grand Comics Database",
+}
+_SERIES_PATHS = {
+ "title": ("Series", "Name"),
+ "sort_title": (None, "SortName"),
+ "year_start": (None, "StartYear"),
+ "volume": (None, "Volume"),
+ "series_type": ("Format", "Format"),
+ "issue_count": ("Count", "IssueCount"),
+}
+_ISSUE_PATHS = {
+ "issue_number_text": ("Number", "Number"),
+ "description": ("Summary", "Summary"),
+ "page_count": ("PageCount", "PageCount"),
+ "store_date": (None, "StoreDate"),
+}
+
+
+@dataclass(frozen=True)
+class RenderedArchiveMetadata:
+ comicinfo: bytes
+ metroninfo: bytes
+
+
+def render_archive_metadata(
+ series: MetadataSnapshot,
+ issue: MetadataSnapshot,
+ files: ArchiveMetadataFiles,
+ *,
+ primary_identity: ExternalIdentityRef | None = None,
+ previous_series: MetadataSnapshot | None = None,
+ previous_issue: MetadataSnapshot | None = None,
+ replace_managed: bool = False,
+) -> RenderedArchiveMetadata:
+ """Render both documents from bound snapshots; this is not write permission.
+
+ The caller must independently bind the issue and its parent, assemble local
+ metadata into these snapshots, and authorize the eventual file operation.
+ Previous managed baselines authorize refresh only while XML is unchanged.
+ """
+ if (
+ series.entity_kind is not MetadataEntityKind.SERIES
+ or issue.entity_kind is not MetadataEntityKind.ISSUE
+ ):
+ raise ArchiveMetadataRenderError("invalid_snapshot")
+ for current, previous in ((series, previous_series), (issue, previous_issue)):
+ if previous is not None and (
+ previous.entity_kind != current.entity_kind
+ or not set(previous.identities) <= set(current.identities)
+ ):
+ raise ArchiveMetadataRenderError("invalid_baseline")
+ archive = reconcile_archive_metadata(files)
+ _identities(series, issue, archive)
+ if any(item.code != "unmapped_content" for item in archive.diagnostics):
+ raise ArchiveMetadataRenderError("invalid_input")
+ ci, mi = preserved_roots(files)
+ primary = _primary(issue, archive, primary_identity)
+ _namespace_guard(mi, archive, primary)
+ _guard_values(series, issue, archive, previous_series, previous_issue, replace_managed)
+ for field, tag in (("series_type", "Format"), ("issue_count", "Count")):
+ text = ci.findtext(tag)
+ old_value: str | int | None = text
+ if text and field == "issue_count":
+ if len(text) > 10 or not text.isascii() or not text.isdecimal():
+ raise ArchiveMetadataRenderError("invalid_input", tag)
+ old_value = int(text)
+ comparison = getattr(series.values, field)
+ if field == "series_type":
+ old_value, comparison = _format_text(text), _format_text(series.values.series_type)
+ if (
+ text
+ and old_value != comparison
+ and not _authorized(series, previous_series, field, old_value, replace_managed)
+ ):
+ raise ArchiveMetadataRenderError("unreconciled_field", field)
+ _render_core(series, issue, ci, mi)
+ _render_ids(series, issue, ci, mi, primary)
+ _render_credits(issue, archive, ci, mi)
+ coordinate_preserved_fields(ci, mi)
+ return _validated_pair(ci, mi)
+
+
+def _identities(
+ series: MetadataSnapshot, issue: MetadataSnapshot, archive: ArchiveMetadataReconciliation
+) -> None:
+ verified = {identity for snapshot in (series, issue) for identity in snapshot.identities}
+ if archive.identity_conflicts:
+ raise ArchiveMetadataRenderError("identity_conflict")
+ for item in archive.evidence:
+ identity = item.identity
+ if any(
+ (identity.namespace, identity.entity_kind) == (target.namespace, target.entity_kind)
+ and identity != target
+ for target in verified
+ ):
+ raise ArchiveMetadataRenderError("identity_conflict")
+ if identity not in verified:
+ raise ArchiveMetadataRenderError("unverified_identity")
+
+
+def _primary(
+ issue: MetadataSnapshot,
+ archive: ArchiveMetadataReconciliation,
+ requested: ExternalIdentityRef | None,
+) -> ExternalIdentityRef | None:
+ if any(identity.namespace not in _SOURCES for identity in issue.identities):
+ raise ArchiveMetadataRenderError("unsupported_identity")
+ if requested is not None:
+ if requested not in issue.identities:
+ raise ArchiveMetadataRenderError("invalid_primary_identity")
+ return requested
+ data = archive.metroninfo.metron
+ if data is not None:
+ for item in data.identities:
+ if item.primary and item.evidence.identity in issue.identities:
+ return item.evidence.identity
+ if len(issue.identities) > 1:
+ raise ArchiveMetadataRenderError("primary_identity_required")
+ return next(iter(issue.identities), None)
+
+
+def _namespace_guard(
+ mi: ET.Element, archive: ArchiveMetadataReconciliation, primary: ExternalIdentityRef | None
+) -> None:
+ old = archive.metroninfo.metron
+ namespace = primary.namespace if primary is not None else (old.primary_source if old else None)
+ if (
+ old is not None
+ and old.primary_source != namespace
+ and any("id" in node.attrib for node in mi.iter() if node.tag != "Series")
+ ):
+ raise ArchiveMetadataRenderError("resource_namespace_change")
+
+
+def _authorized(
+ snapshot: MetadataSnapshot,
+ previous: MetadataSnapshot | None,
+ field: str,
+ existing: object,
+ replace_managed: bool,
+ *,
+ project_credits: bool = False,
+) -> bool:
+ if any(origin.field == field and origin.user_override for origin in snapshot.origins):
+ return True
+ baseline = getattr(previous.values, field) if previous is not None else None
+ if field == "series_type" and previous is not None:
+ baseline = _format_text(previous.values.series_type)
+ if project_credits and previous is not None and previous.values.credits is not None:
+ baseline = comicinfo_credits(previous.values.credits)
+ return bool(
+ replace_managed
+ and previous is not None
+ and baseline == existing
+ and any(origin.field == field and origin.source is not None for origin in previous.origins)
+ and any(origin.field == field and origin.source is not None for origin in snapshot.origins)
+ )
+
+
+def _guard_values(
+ series: MetadataSnapshot,
+ issue: MetadataSnapshot,
+ archive: ArchiveMetadataReconciliation,
+ previous_series: MetadataSnapshot | None,
+ previous_issue: MetadataSnapshot | None,
+ replace_managed: bool,
+) -> None:
+ for document, values in (("ComicInfo", archive.comicinfo), ("MetronInfo", archive.metroninfo)):
+ for snapshot, existing, previous in (
+ (series, values.series, previous_series),
+ (issue, values.issue, previous_issue),
+ ):
+ for field in MetadataValues.model_fields:
+ old, new = getattr(existing, field), getattr(snapshot.values, field)
+ if old is None:
+ continue
+ if field == "issue_number_text":
+ try:
+ equal = new is not None and normalize_issue_number_text(
+ old
+ ) == normalize_issue_number_text(new)
+ except ValueError:
+ equal = False
+ if not equal:
+ raise ArchiveMetadataRenderError("issue_number_conflict")
+ continue
+ if (
+ field == "credits"
+ and document == "ComicInfo"
+ and snapshot.values.credits is not None
+ ):
+ new = comicinfo_credits(snapshot.values.credits)
+ if field == "series_type":
+ old, new = (
+ _format_text(existing.series_type),
+ _format_text(snapshot.values.series_type),
+ )
+ if (
+ field == "title"
+ and snapshot.entity_kind is MetadataEntityKind.SERIES
+ and isinstance(old, str)
+ and isinstance(new, str)
+ and _series_separator_key(old) == _series_separator_key(new)
+ and any(item.identity in issue.identities for item in archive.evidence)
+ ):
+ # Only a proven exact issue can reconcile filesystem-safe subtitle separators.
+ continue
+ if old != new and not _authorized(
+ snapshot,
+ previous,
+ field,
+ old,
+ replace_managed,
+ project_credits=field == "credits" and document == "ComicInfo",
+ ):
+ raise ArchiveMetadataRenderError("unreconciled_field", field)
+ parts = archive.comicinfo.publication_date_parts
+ current = issue.values.cover_date
+ if (
+ current is None
+ and archive.comicinfo.issue.cover_date is None
+ and not any(
+ origin.field == "cover_date" and origin.user_override for origin in issue.origins
+ )
+ ):
+ return
+ if any(part is not None for part in parts):
+ new_parts = (
+ (current.year, current.month, current.day)
+ if current is not None
+ else (None, None, None)
+ )
+ if any(old is not None and old != new for old, new in zip(parts, new_parts, strict=True)):
+ previous_date = previous_issue.values.cover_date if previous_issue is not None else None
+ old_parts = (
+ (previous_date.year, previous_date.month, previous_date.day)
+ if previous_date is not None
+ else (None, None, None)
+ )
+ if not _authorized(
+ issue, previous_issue, "cover_date", previous_date, replace_managed
+ ) or not (
+ any(
+ origin.field == "cover_date" and origin.user_override
+ for origin in issue.origins
+ )
+ or all(
+ part is None or part == old for part, old in zip(parts, old_parts, strict=True)
+ )
+ ):
+ raise ArchiveMetadataRenderError("unreconciled_field", "cover_date")
+
+
+def _series_separator_key(value: str) -> str:
+ value = re.sub(r"\s+[-\u2013\u2014]\s+", " : ", value)
+ return " ".join(value.replace(":", " : ").casefold().split())
+
+
+def _render_core(
+ series: MetadataSnapshot, issue: MetadataSnapshot, ci: ET.Element, mi: ET.Element
+) -> None:
+ if not series.values.title or not series.values.title.strip():
+ raise ArchiveMetadataRenderError("missing_series_title")
+ parent = mi.find("Series")
+ if parent is None:
+ parent = ET.SubElement(mi, "Series")
+ for field, (ci_tag, mi_tag) in _SERIES_PATHS.items():
+ value = getattr(series.values, field)
+ if field == "series_type":
+ value = _format_text(series.values.series_type)
+ if ci_tag is not None:
+ set_text(ci, ci_tag, value)
+ if field == "series_type" and series.values.series_type in _COMICINFO_ONLY_FORMATS:
+ value = None
+ if field == "year_start" and series.values.year_start is not None:
+ value = f"{series.values.year_start:04d}"
+ set_text(parent, mi_tag, None if field == "issue_count" and value == 0 else value)
+ language = series.values.language
+ set_text(ci, "LanguageISO", language)
+ if language:
+ parent.set("lang", language)
+ else:
+ parent.attrib.pop("lang", None)
+ publisher = mi.find("Publisher")
+ if (
+ publisher is not None
+ and "id" in publisher.attrib
+ and publisher.findtext("Name") != series.values.publisher
+ ):
+ raise ArchiveMetadataRenderError("resource_metadata_change", "publisher")
+ if series.values.publisher:
+ if publisher is None:
+ publisher = ET.SubElement(mi, "Publisher")
+ set_text(publisher, "Name", series.values.publisher)
+ elif publisher is not None:
+ if publisher.attrib or publisher.find("Imprint") is not None:
+ raise ArchiveMetadataRenderError("unsupported_content", "Publisher")
+ mi.remove(publisher)
+ set_text(ci, "Publisher", series.values.publisher)
+ for field, (ci_tag, mi_tag) in _ISSUE_PATHS.items():
+ value = getattr(issue.values, field)
+ if ci_tag is not None:
+ set_text(ci, ci_tag, value)
+ set_text(mi, mi_tag, value)
+ cover = issue.values.cover_date
+ set_text(mi, "CoverDate", cover)
+ if (
+ cover is not None
+ or all(ci.findtext(tag) for tag in ("Year", "Month", "Day"))
+ or any(origin.field == "cover_date" and origin.user_override for origin in issue.origins)
+ ):
+ for tag, value in zip(
+ ("Year", "Month", "Day"),
+ (cover.year, cover.month, cover.day) if cover else (None, None, None),
+ strict=True,
+ ):
+ set_text(ci, tag, value)
+ title = issue.values.title
+ stories = mi.find("Stories")
+ if stories is not None and len(stories.findall("Story")) > 1:
+ if title:
+ raise ArchiveMetadataRenderError("unreconciled_field", "title")
+ elif title:
+ if stories is None:
+ stories = ET.SubElement(mi, "Stories")
+ set_text(stories, "Story", title)
+ elif stories is not None:
+ _assert_container_replaceable(stories, "title")
+ mi.remove(stories)
+ set_text(ci, "Title", title)
+
+
+def _render_ids(
+ series: MetadataSnapshot,
+ issue: MetadataSnapshot,
+ ci: ET.Element,
+ mi: ET.Element,
+ primary: ExternalIdentityRef | None,
+) -> None:
+ ids = mi.find("IDS")
+ if ids is None and issue.identities:
+ ids = ET.SubElement(mi, "IDS")
+ if ids is not None:
+ for child in ids:
+ if child.get("source", "").casefold() in {"league of comic geeks", "locg"}:
+ child.set("source", "League of Comic Geeks")
+ if primary is not None:
+ child.attrib.pop("primary", None)
+ for identity in sorted(
+ issue.identities, key=lambda item: (item.namespace, item.external_id)
+ ):
+ nodes = [
+ node
+ for node in ids.findall("ID")
+ if node.get("source") == _SOURCES[identity.namespace]
+ ]
+ if not nodes:
+ nodes = [ET.SubElement(ids, "ID", {"source": _SOURCES[identity.namespace]})]
+ for child in nodes:
+ child.text = identity.external_id
+ child.attrib.pop("primary", None)
+ if identity == primary:
+ nodes[0].set("primary", "true")
+ parent = mi.find("Series")
+ assert parent is not None
+ if primary is not None:
+ parent.attrib.pop("id", None)
+ for identity in series.identities:
+ if identity.namespace == primary.namespace:
+ parent.set("id", identity.external_id)
+ notes = ci.findtext("Notes") or ""
+ for snapshot, marker in ((series, "cv_vol_id"), (issue, "cv_issue_id")):
+ for identity in snapshot.identities:
+ if identity.namespace is IdentityNamespace.COMICVINE:
+ token = f"[{marker}:{identity.external_id}]"
+ if token not in notes:
+ notes = f"{notes} {token}".strip()
+ if notes:
+ set_text(ci, "Notes", notes)
+
+
+def _render_credits(
+ issue: MetadataSnapshot, archive: ArchiveMetadataReconciliation, ci: ET.Element, mi: ET.Element
+) -> None:
+ credits = issue.values.credits
+ if credits is None:
+ if any(origin.field == "credits" and origin.user_override for origin in issue.origins):
+ existing = mi.find("Credits")
+ if existing is not None:
+ _assert_container_replaceable(existing, "credits")
+ mi.remove(existing)
+ for tag in COMICINFO_ROLES:
+ set_text(ci, tag, None)
+ return
+ if any(
+ "," in credit.name
+ for credit in credits
+ if any(role in COMICINFO_ROLES.values() for role in credit.role.split(", "))
+ ):
+ raise ArchiveMetadataRenderError("unsupported_content", "credits")
+ for tag, role in COMICINFO_ROLES.items():
+ names = [credit.name for credit in credits if role in credit.role.split(", ")]
+ set_text(ci, tag, ", ".join(names))
+ if not comicinfo_credits(credits):
+ ET.SubElement(ci, "Writer")
+ if archive.metroninfo.issue.credits == credits:
+ return
+ existing = mi.find("Credits")
+ if existing is not None:
+ _assert_container_replaceable(existing, "credits")
+ mi.remove(existing)
+ parent = ET.SubElement(mi, "Credits")
+ for credit in credits:
+ node = ET.SubElement(parent, "Credit")
+ ET.SubElement(node, "Creator").text = credit.name
+ if credit.role:
+ roles = ET.SubElement(node, "Roles")
+ for role in credit.role.split(", "):
+ ET.SubElement(roles, "Role").text = role.title()
+
+
+def _assert_container_replaceable(node: ET.Element, field: str) -> None:
+ if any("id" in child.attrib for child in node.iter()):
+ raise ArchiveMetadataRenderError("resource_metadata_change", field)
+ if any(not isinstance(child.tag, str) for child in node.iter()):
+ raise ArchiveMetadataRenderError("unsupported_content", field)
+
+
+def _validated_pair(ci: ET.Element, mi: ET.Element) -> RenderedArchiveMetadata:
+ result = RenderedArchiveMetadata(
+ ET.tostring(ci, encoding="utf-8", xml_declaration=True),
+ ET.tostring(mi, encoding="utf-8", xml_declaration=True),
+ )
+ try:
+ parse_metadata_xml(result.comicinfo, root_name="ComicInfo")
+ validate_metroninfo_xml(result.metroninfo)
+ except MetadataXmlError as exc:
+ raise ArchiveMetadataRenderError("invalid_output") from exc
+ check = reconcile_archive_metadata(
+ ArchiveMetadataFiles(
+ MetadataFile("ComicInfo.xml", 1, result.comicinfo),
+ MetadataFile("MetronInfo.xml", 1, result.metroninfo),
+ )
+ )
+ if (
+ check.differences
+ or check.identity_conflicts
+ or any(item.code != "unmapped_content" for item in check.diagnostics)
+ ):
+ raise ArchiveMetadataRenderError("output_disagreement")
+ return result
diff --git a/src/pullbox/services/archive_metadata_writing.py b/src/pullbox/services/archive_metadata_writing.py
new file mode 100644
index 00000000..2946ce95
--- /dev/null
+++ b/src/pullbox/services/archive_metadata_writing.py
@@ -0,0 +1,345 @@
+"""Single-pass CBZ metadata materialization for independently authorized callers."""
+
+import os
+import stat
+import tempfile
+import zipfile
+from collections.abc import Callable
+from copy import copy
+from pathlib import Path, PurePosixPath
+from typing import BinaryIO
+
+import structlog
+
+from pullbox.core.archive_metadata import read_open_metadata_members
+from pullbox.core.file_publication import publish_file_without_overwrite
+from pullbox.core.file_safety import (
+ DANGEROUS_EXTENSIONS,
+ FileSafetyError,
+ has_archive_member_path_traversal,
+)
+from pullbox.core.metadata_archive_source import MetadataArchiveSource, open_metadata_archive
+from pullbox.core.metadata_identity import ExternalIdentityRef
+from pullbox.core.metadata_pdf_source import PdfQuality
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot
+from pullbox.services.archive_metadata_rendering import render_archive_metadata
+
+ArchiveMetadataProgress = Callable[[str, int, int, str], None]
+_CHUNK_BYTES = 1024 * 1024
+_METADATA_NAMES = {"comicinfo.xml", "metroninfo.xml"}
+logger = structlog.get_logger(__name__)
+
+
+def write_cbz_metadata(
+ source_path: Path,
+ target_path: Path,
+ series: MetadataSnapshot,
+ issue: MetadataSnapshot,
+ *,
+ max_uncompressed_bytes: int,
+ replace_source: bool = False,
+ block_dangerous: bool = True,
+ temp_path: Path | None = None,
+ progress_callback: ArchiveMetadataProgress | None = None,
+ check_cancelled: Callable[[], None] | None = None,
+ primary_identity: ExternalIdentityRef | None = None,
+ previous_series: MetadataSnapshot | None = None,
+ previous_issue: MetadataSnapshot | None = None,
+ replace_managed: bool = False,
+ pdf_quality: PdfQuality = "medium",
+) -> bool:
+ """Write a verified pair in one CBZ construction, off the event loop.
+
+ Paths must already be authorized and file/issue/parent binding verified by
+ the workflow. This primitive grants no managed ownership and does not remove
+ a copied source. Callers retain journal/rollback and per-file serialization.
+ Only an explicit same-path refresh may replace a file; other destinations
+ use exclusive publication. Cooperative cancellation ends before publication.
+ """
+ if isinstance(max_uncompressed_bytes, bool) or max_uncompressed_bytes <= 0:
+ raise ValueError("Archive budget must be positive")
+ if pdf_quality not in {"high", "medium", "low"}:
+ raise ValueError("Invalid PDF quality preset")
+ source_path = source_path.absolute()
+ target_path = target_path.absolute()
+ refresh = source_path == target_path
+ if replace_source != refresh:
+ raise ValueError("Source replacement must explicitly target the same path")
+ if target_path.suffix.casefold() != ".cbz":
+ raise ValueError("Metadata output requires a CBZ target")
+ if temp_path is not None:
+ temp_path = temp_path.absolute()
+ if temp_path.parent.resolve() != target_path.parent.resolve():
+ raise ValueError("Temporary output must share the target directory")
+ if temp_path in {source_path, target_path}:
+ raise ValueError("Temporary output must not be the source or target")
+ if not refresh and os.path.lexists(target_path):
+ raise FileExistsError(target_path)
+ _check_cancelled(check_cancelled)
+ before = source_path.lstat()
+ if not stat.S_ISREG(before.st_mode):
+ raise FileSafetyError("Metadata source must be a regular file, not a link")
+ if refresh and not before.st_mode & 0o222:
+ raise FileSafetyError("Cannot replace a read-only source archive")
+ fd = os.open(
+ source_path,
+ os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
+ )
+ with os.fdopen(fd, "rb") as source_stream:
+ _check_source(source_path, source_stream, before)
+ with open_metadata_archive(
+ source_stream,
+ source_path,
+ limit=max_uncompressed_bytes,
+ scratch_parent=target_path.parent,
+ validate=lambda entries: _validate_members(
+ entries, max_uncompressed_bytes, block_dangerous
+ ),
+ check_cancelled=lambda: _check_cancelled(check_cancelled),
+ progress=lambda current, total: _progress(
+ progress_callback, check_cancelled, "extracting", current, total
+ ),
+ pdf_quality=pdf_quality,
+ pdf_progress=lambda current, total: _progress(
+ progress_callback, check_cancelled, "rendering", current, total, "pages"
+ ),
+ ) as source:
+ entries = source.entries
+ files = read_open_metadata_members(entries, source.open)
+ pair = render_archive_metadata(
+ series,
+ issue,
+ files,
+ primary_identity=primary_identity,
+ previous_series=previous_series,
+ previous_issue=previous_issue,
+ replace_managed=replace_managed,
+ )
+ outputs = {"ComicInfo.xml": pair.comicinfo, "MetronInfo.xml": pair.metroninfo}
+ members = [entry for entry in entries if not _metadata_member(entry)]
+ if (
+ refresh
+ and files.comicinfo.payload == pair.comicinfo
+ and files.metroninfo.payload == pair.metroninfo
+ and {entry.filename for entry in entries if _metadata_member(entry)} == set(outputs)
+ ):
+ _check_cancelled(check_cancelled)
+ _check_source(source_path, source_stream, before)
+ return False
+ total_bytes = sum(entry.file_size for entry in members) + sum(
+ map(len, outputs.values())
+ )
+ if total_bytes > max_uncompressed_bytes:
+ raise FileSafetyError(
+ "Archive decompressed size exceeds limit after metadata rendering"
+ )
+ _check_cancelled(check_cancelled)
+ stage, stage_fd = _create_stage(target_path, temp_path)
+ stage_identity = os.fstat(stage_fd)
+ try:
+ with os.fdopen(stage_fd, "w+b") as output_stream:
+ manifest = _construct(
+ source,
+ output_stream,
+ members,
+ outputs,
+ total_bytes,
+ progress_callback,
+ check_cancelled,
+ )
+ output_stream.flush()
+ os.fsync(output_stream.fileno())
+ _verify(
+ stage, manifest, source.comment, total_bytes, progress_callback, check_cancelled
+ )
+ current_stage = stage.lstat()
+ if (current_stage.st_dev, current_stage.st_ino) != (
+ stage_identity.st_dev,
+ stage_identity.st_ino,
+ ):
+ raise FileSafetyError("Temporary archive changed before publication")
+ if refresh:
+ # Do not copy ownership/xattrs/timestamps: NAS mounts may prohibit them.
+ stage.chmod(stat.S_IMODE(before.st_mode) & 0o777)
+ ready = stage.lstat()
+ _check_source(source_path, source_stream, before)
+ # Windows will not replace an open source. All input is consumed now.
+ source.close()
+ source_stream.close()
+ _progress(progress_callback, check_cancelled, "publishing", 0, 1, "files")
+ _check_source(source_path, None, before)
+ if _fingerprint(stage.lstat()) != _fingerprint(ready):
+ raise FileSafetyError("Temporary archive changed before publication")
+ if refresh:
+ os.replace(stage, target_path)
+ else:
+ try:
+ publish_file_without_overwrite(stage, target_path)
+ except OSError:
+ # The link fallback can publish successfully before unlink fails.
+ # Only our verified inode proves that the commit point was crossed.
+ if not _same_regular_file(target_path, ready):
+ raise
+ logger.warning("archive_metadata_published_with_staging_link")
+ # No fallible callback after the commit point: success must remain success.
+ return True
+ finally:
+ try:
+ if _same_regular_file(stage, stage_identity):
+ stage.unlink()
+ except OSError as exc:
+ logger.warning("archive_metadata_stage_cleanup_failed", errno=exc.errno)
+
+
+def _same_regular_file(path: Path, expected: os.stat_result) -> bool:
+ try:
+ current = path.lstat()
+ except FileNotFoundError:
+ return False
+ return stat.S_ISREG(current.st_mode) and (current.st_dev, current.st_ino) == (
+ expected.st_dev,
+ expected.st_ino,
+ )
+
+
+def _metadata_member(entry: zipfile.ZipInfo) -> bool:
+ return PurePosixPath(entry.filename.replace("\\", "/")).name.casefold() in _METADATA_NAMES
+
+
+def _validate_members(entries: list[zipfile.ZipInfo], limit: int, block_dangerous: bool) -> None:
+ seen: set[str] = set()
+ total = 0
+ for entry in entries:
+ name = entry.filename.replace("\\", "/")
+ mode = stat.S_IFMT(entry.external_attr >> 16)
+ expected_mode = stat.S_IFDIR if entry.is_dir() else stat.S_IFREG
+ if (
+ has_archive_member_path_traversal(entry.orig_filename)
+ or "\x00" in entry.orig_filename
+ or mode not in {0, expected_mode}
+ or entry.flag_bits & 1
+ or entry.file_size < 0
+ or entry.compress_size < 0
+ or (entry.is_dir() and entry.file_size != 0)
+ or (block_dangerous and PurePosixPath(name).suffix.casefold() in DANGEROUS_EXTENSIONS)
+ ):
+ raise FileSafetyError("Archive contains an unsafe or unsupported member")
+ key = name.casefold()
+ if key in seen:
+ raise FileSafetyError("Archive contains duplicate member paths")
+ seen.add(key)
+ total += entry.file_size
+ if total > limit:
+ raise FileSafetyError("Archive decompressed size exceeds limit")
+
+
+def _create_stage(target: Path, requested: Path | None) -> tuple[Path, int]:
+ if requested is not None:
+ return requested, os.open(requested, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
+ fd, name = tempfile.mkstemp(prefix=".pullbox-metadata-", suffix=".cbz", dir=target.parent)
+ return Path(name), fd
+
+
+def _fingerprint(info: os.stat_result) -> tuple[int, ...]:
+ return (
+ info.st_dev,
+ info.st_ino,
+ info.st_size,
+ info.st_mtime_ns,
+ info.st_ctime_ns,
+ info.st_mode,
+ )
+
+
+def _check_source(path: Path, stream: BinaryIO | None, before: os.stat_result) -> None:
+ try:
+ unchanged = _fingerprint(before) == _fingerprint(path.lstat())
+ if stream is not None:
+ unchanged = unchanged and _fingerprint(before) == _fingerprint(
+ os.fstat(stream.fileno())
+ )
+ except FileNotFoundError:
+ unchanged = False
+ if not unchanged:
+ raise FileSafetyError("Source archive changed during metadata writing")
+
+
+def _check_cancelled(check: Callable[[], None] | None) -> None:
+ if check is not None:
+ check()
+
+
+def _progress(
+ callback: ArchiveMetadataProgress | None,
+ check: Callable[[], None] | None,
+ stage: str,
+ current: int,
+ total: int,
+ unit: str = "bytes",
+) -> None:
+ _check_cancelled(check)
+ if callback is not None:
+ callback(stage, current, total, unit)
+ _check_cancelled(check)
+
+
+def _construct(
+ source: MetadataArchiveSource,
+ stream: BinaryIO,
+ members: list[zipfile.ZipInfo],
+ metadata: dict[str, bytes],
+ total: int,
+ progress: ArchiveMetadataProgress | None,
+ check: Callable[[], None] | None,
+) -> list[tuple[str, int, int]]:
+ current = 0
+ _progress(progress, check, "transferring", 0, total)
+ with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as target:
+ target.comment = source.comment
+ for entry in members:
+ _check_cancelled(check)
+ with (
+ source.open(entry) as incoming,
+ target.open(copy(entry), "w", force_zip64=True) as outgoing,
+ ):
+ while chunk := incoming.read(_CHUNK_BYTES):
+ _check_cancelled(check)
+ outgoing.write(chunk)
+ current += len(chunk)
+ if current > total:
+ raise FileSafetyError("Archive expanded beyond its declared size")
+ _progress(progress, check, "transferring", current, total)
+ for name, payload in metadata.items():
+ _check_cancelled(check)
+ target.writestr(name, payload)
+ current += len(payload)
+ _progress(progress, check, "transferring", current, total)
+ if current != total:
+ raise FileSafetyError("Archive payload size disagrees with its directory")
+ manifest = [(entry.filename, entry.file_size, entry.CRC) for entry in target.infolist()]
+ return manifest
+
+
+def _verify(
+ path: Path,
+ manifest: list[tuple[str, int, int]],
+ comment: bytes,
+ total: int,
+ progress: ArchiveMetadataProgress | None,
+ check: Callable[[], None] | None,
+) -> None:
+ _progress(progress, check, "verifying", 0, total)
+ with zipfile.ZipFile(path) as archive:
+ if archive.comment != comment or manifest != [
+ (entry.filename, entry.file_size, entry.CRC) for entry in archive.infolist()
+ ]:
+ raise FileSafetyError("Temporary archive failed verification")
+ current = 0
+ for entry in archive.infolist():
+ _check_cancelled(check)
+ with archive.open(entry) as stream:
+ while chunk := stream.read(_CHUNK_BYTES):
+ current += len(chunk)
+ _progress(progress, check, "verifying", current, total)
+ if current != total:
+ raise FileSafetyError("Temporary archive failed verification")
diff --git a/src/pullbox/services/catalog/reader.py b/src/pullbox/services/catalog/reader.py
index 304a9c70..3f1104c1 100644
--- a/src/pullbox/services/catalog/reader.py
+++ b/src/pullbox/services/catalog/reader.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import hashlib
import re
import sqlite3
import threading
@@ -43,6 +44,11 @@ class CatalogIssueSummary(IssueSummary):
source_cutoff_at: datetime | None = None
+@dataclass(frozen=True)
+class CatalogIssueMetadata(IssueMetadata):
+ source_cutoff_at: datetime | None = None
+
+
class CatalogReader:
"""Pin a file per query; verify a generation once before serving its rows."""
@@ -55,6 +61,19 @@ def __init__(self, root: Path) -> None:
def available(self) -> bool:
return (self.root / "active.json").exists()
+ async def cache_token(self) -> str | None:
+ """Fingerprint the validated active file without exposing its location."""
+
+ def token() -> str | None:
+ if not self.available:
+ return None
+ path, cutoff = self._generation()
+ stat = path.stat()
+ identity = (str(path), stat.st_ino, stat.st_mtime_ns, stat.st_size, cutoff.isoformat())
+ return hashlib.sha256(repr(identity).encode()).hexdigest()
+
+ return await disk_work(token)
+
def _generation(self) -> tuple[Path, datetime]:
reference = load_json(self.root / "active.json")
version = valid_version(reference.get("version"))
@@ -160,7 +179,9 @@ def _summary(row: Any, cutoff: datetime) -> CatalogIssueSummary:
cutoff,
)
- async def issue(self, issue_id: int) -> IssueMetadata | None:
+ async def issue(
+ self, issue_id: int, *, preserve_number_text: bool = False
+ ) -> IssueMetadata | None:
"""Return only the basic identity fields used during import file matching."""
rows, cutoff = await disk_work(
self._query, f"SELECT {ISSUE_COLUMNS} FROM issues WHERE id=?", (issue_id,)
@@ -168,8 +189,13 @@ async def issue(self, issue_id: int) -> IssueMetadata | None:
if not rows:
return None
row = rows[0]
+ return self._issue_metadata(row, cutoff, preserve_number_text=preserve_number_text)
+
+ def _issue_metadata(
+ self, row: Any, cutoff: datetime, *, preserve_number_text: bool
+ ) -> CatalogIssueMetadata:
summary = self._summary(row, cutoff)
- return IssueMetadata(
+ return CatalogIssueMetadata(
summary.provider_id,
str(row[1]),
summary.issue_number,
@@ -180,7 +206,54 @@ async def issue(self, issue_id: int) -> IssueMetadata | None:
summary.cover_url,
None,
f"https://comicvine.gamespot.com/issue/4000-{row[0]}/",
- issue_number_text=summary.issue_number_text,
+ issue_number_text=(
+ str(row[2] or row[3] or "") if preserve_number_text else summary.issue_number_text
+ ),
+ source_cutoff_at=cutoff,
+ )
+
+ async def recent_issues(self, series_id: int) -> tuple[list[IssueMetadata], int, datetime]:
+ """Newest publication slice and total, pinned to one immutable generation."""
+ if type(series_id) is not int or not 0 < series_id < 2**63:
+ raise ValueError("Invalid catalog series identity")
+ rows, cutoff = await disk_work(
+ self._query,
+ f"WITH page AS (SELECT {ISSUE_COLUMNS} FROM issues WHERE series_id=? "
+ "ORDER BY store_date DESC,id DESC LIMIT 100), "
+ "tally AS (SELECT COUNT(*) AS total FROM issues WHERE series_id=?) "
+ "SELECT page.*,tally.total FROM tally LEFT JOIN page ON 1=1 "
+ "ORDER BY page.store_date DESC,page.id DESC",
+ (series_id, series_id),
+ )
+ return (
+ [
+ self._issue_metadata(row, cutoff, preserve_number_text=True)
+ for row in rows
+ if row[0] is not None
+ ],
+ int(rows[0][-1]),
+ cutoff,
+ )
+
+ async def issue_page(self, series_id: int, *, page: int = 1) -> tuple[list[IssueMetadata], int]:
+ """Count and read a bounded page in one query on one immutable generation."""
+ if type(page) is not int or not 1 <= page <= 10000:
+ raise ValueError("Invalid catalog page")
+ rows, cutoff = await disk_work(
+ self._query,
+ f"WITH page AS (SELECT {ISSUE_COLUMNS} FROM issues WHERE series_id=? "
+ "ORDER BY id LIMIT 100 OFFSET ?), "
+ "tally AS (SELECT COUNT(*) AS total FROM issues WHERE series_id=?) "
+ "SELECT page.*,tally.total FROM tally LEFT JOIN page ON 1=1 ORDER BY page.id",
+ (series_id, (page - 1) * 100, series_id),
+ )
+ return (
+ [
+ self._issue_metadata(row, cutoff, preserve_number_text=True)
+ for row in rows
+ if row[0] is not None
+ ],
+ int(rows[0][-1]),
)
diff --git a/src/pullbox/services/gcd_local_activation.py b/src/pullbox/services/gcd_local_activation.py
new file mode 100644
index 00000000..b0d65d41
--- /dev/null
+++ b/src/pullbox/services/gcd_local_activation.py
@@ -0,0 +1,57 @@
+"""Atomic GCD activation; validation runs before the application write transaction."""
+
+import asyncio
+from collections.abc import Awaitable, Callable
+from contextlib import suppress
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.models.config import SystemConfig
+from pullbox.providers.metadata.gcd_local_database import (
+ GcdDatabaseError,
+ GcdSnapshot,
+ validate_candidate,
+)
+
+ACTIVE_KEY = "metadata.gcd_local.active"
+PREVIOUS_KEY = "metadata.gcd_local.previous"
+
+
+async def validate_for_request(
+ path: str | None, disconnected: Callable[[], Awaitable[bool]]
+) -> GcdSnapshot:
+ task = asyncio.create_task(validate_candidate(path))
+ try:
+ while True:
+ done, _ = await asyncio.wait({task}, timeout=0.25)
+ if await disconnected():
+ raise GcdDatabaseError("GCD validation cancelled. Saved settings are unchanged.")
+ if done:
+ return await task
+ finally:
+ if not task.done():
+ task.cancel()
+ with suppress(asyncio.CancelledError, GcdDatabaseError):
+ await task
+
+
+async def active_snapshot(session: AsyncSession) -> GcdSnapshot | None:
+ row = await session.get(SystemConfig, ACTIVE_KEY, populate_existing=True)
+ return GcdSnapshot.model_validate_json(row.value) if row else None
+
+
+async def activate_snapshot(session: AsyncSession, candidate: GcdSnapshot) -> None:
+ """Caller owns the policy revision lock and commits both records together."""
+ current = await session.get(SystemConfig, ACTIVE_KEY, populate_existing=True)
+ if current is not None and current.value != candidate.model_dump_json():
+ previous = await session.get(SystemConfig, PREVIOUS_KEY, populate_existing=True)
+ if previous is None:
+ previous = SystemConfig(key=PREVIOUS_KEY, value=current.value, value_type="string")
+ session.add(previous)
+ else:
+ previous.value = current.value
+ if current is None:
+ current = SystemConfig(key=ACTIVE_KEY, value="", value_type="string")
+ session.add(current)
+ current.value = candidate.model_dump_json()
+ await session.flush()
diff --git a/src/pullbox/services/import_archive_publication.py b/src/pullbox/services/import_archive_publication.py
new file mode 100644
index 00000000..240f0458
--- /dev/null
+++ b/src/pullbox/services/import_archive_publication.py
@@ -0,0 +1,314 @@
+"""Bind paired archive publication to retained import placement evidence."""
+
+import hashlib
+import json
+import os
+from datetime import UTC, datetime
+from uuid import UUID
+
+from sqlalchemy import or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState
+from pullbox.models.import_job import (
+ ImportControlRequest,
+ ImportedFile,
+ ImportedFileStatus,
+ ImportJob,
+ ImportJobAction,
+ ImportJobActionStatus,
+ ImportJobStatus,
+)
+from pullbox.models.library import LibraryFile, LibraryFileStorageMode
+from pullbox.schemas.archive_publication_owner import ImportArchiveOwner
+from pullbox.services.archive_metadata_publication import (
+ ArchivePublicationError,
+ ArchivePublicationPlan,
+ ArchivePublicationReceipt,
+ _clean_session,
+ _receipt,
+)
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+_ENRICHMENT = "comicinfo_enrichment"
+_SUCCESSOR = "metadata_publication"
+
+
+def import_archive_owner_stopped(job: ImportJob) -> bool:
+ """Only durable stop/rollback controls permit non-canonical settlement."""
+ return job.control_request is ImportControlRequest.CANCEL or job.status in {
+ ImportJobStatus.CANCELLING,
+ ImportJobStatus.CANCELLED,
+ ImportJobStatus.ROLLING_BACK,
+ ImportJobStatus.ROLLED_BACK,
+ }
+
+
+def _digest(value: object) -> str:
+ try:
+ encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
+ except (TypeError, ValueError):
+ raise ArchivePublicationError("import_owner_invalid") from None
+ if len(encoded) > 4 * 1024 * 1024:
+ raise ArchivePublicationError("import_owner_too_large")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def _signature(plan: ArchivePublicationPlan, *, successor: bool = False) -> dict[str, int | str]:
+ fingerprint = plan.stage_fingerprint if successor else plan.target.fingerprint
+ return {
+ "schema_version": 1,
+ "resolved_path": str(plan.target.path),
+ "size": fingerprint[2],
+ "mtime_ns": fingerprint[3],
+ "device": fingerprint[0],
+ "inode": fingerprint[1],
+ "content_digest_algorithm": "sha256",
+ "content_digest": plan.output_digest if successor else plan.source_digest,
+ }
+
+
+async def lock_import_archive_owner(
+ session: AsyncSession, owner: ImportArchiveOwner | None
+) -> None:
+ """Lock the owner before library parents; replay may outlive these rows."""
+ if owner is None:
+ return
+ await _lock_owner_rows(session, owner.job_id, owner.imported_file_id, owner.action_id)
+
+
+async def _lock_owner_rows(
+ session: AsyncSession, job_id: int, imported_file_id: int, action_id: int
+) -> None:
+ for model, local_id in (
+ (ImportJob, job_id),
+ (ImportedFile, imported_file_id),
+ (ImportJobAction, action_id),
+ ):
+ await session.execute(select(model.id).where(model.id == local_id).with_for_update())
+
+
+async def _owner_snapshot(
+ session: AsyncSession,
+ plan: ArchivePublicationPlan,
+ *,
+ job_id: int,
+ imported_file_id: int,
+ action_id: int,
+ stopped: bool = False,
+) -> ImportArchiveOwner:
+ job = await session.get(ImportJob, job_id, populate_existing=True)
+ file = await session.get(ImportedFile, imported_file_id, populate_existing=True)
+ action = await session.get(ImportJobAction, action_id, populate_existing=True)
+ bound = plan.target.binding
+ library_file = await session.get(LibraryFile, bound.library_file_id, populate_existing=True)
+ if (
+ job is None
+ or file is None
+ or action is None
+ or library_file is None
+ or library_file.storage_mode is not LibraryFileStorageMode.MANAGED
+ or library_file.issue_id != bound.metadata.issues[0].local_id
+ or library_file.library_root_id != bound.library_root_id
+ or library_file.file_path != str(plan.target.path)
+ or library_file.file_name != plan.target.path.name
+ or _digest(library_file.source_signature) != _digest(_signature(plan))
+ or (
+ not import_archive_owner_stopped(job)
+ if stopped
+ else job.status is not ImportJobStatus.COMPLETED
+ or job.control_request is not ImportControlRequest.NONE
+ )
+ or file.import_job_id != job.id
+ or action.import_job_id != job.id
+ or file.status is not ImportedFileStatus.IMPORTED
+ or file.library_file_id != bound.library_file_id
+ or file.matched_issue_id != bound.metadata.issues[0].local_id
+ or action.status is not ImportJobActionStatus.COMPLETED
+ or action.action_type != "library_file_registered"
+ or action.phase != "import"
+ ):
+ raise ArchivePublicationError("import_owner_changed")
+ payload = action.payload
+ diagnostics = file.diagnostics
+ details = diagnostics.get(_ENRICHMENT) if isinstance(diagnostics, dict) else None
+ if (
+ not isinstance(payload, dict)
+ or not isinstance(details, dict)
+ or any(type(payload.get(key)) is not int for key in ("imported_file_id", "library_file_id"))
+ or any(type(details.get(key)) is not int for key in ("library_file_id", "issue_id"))
+ or details.get("status") != "pending"
+ or details.get("library_file_id") != bound.library_file_id
+ or details.get("issue_id") != file.matched_issue_id
+ or details.get("artifact_path", str(plan.target.path)) != str(plan.target.path)
+ or payload.get("imported_file_id") != file.id
+ or payload.get("library_file_id") != bound.library_file_id
+ or payload.get("destination_path") != str(plan.target.path)
+ or payload.get("storage_mode") != "managed"
+ or payload.get("transfer_method") not in ("copy", "move", "hardlink")
+ or _digest(payload.get("destination_signature")) != _digest(_signature(plan))
+ or payload.get("embedded_comicinfo_enrichment_deferred") is not True
+ or _SUCCESSOR in payload
+ ):
+ raise ArchivePublicationError("import_owner_changed")
+ return ImportArchiveOwner(
+ job_id=job.id,
+ imported_file_id=file.id,
+ action_id=action.id,
+ action_digest=_digest(payload),
+ pending_digest=_digest(
+ {
+ "details": details,
+ "path": file.file_path,
+ "series": file.import_series_id,
+ "source_signature": file.source_signature,
+ }
+ ),
+ )
+
+
+async def bind_import_archive_publication(
+ session: AsyncSession,
+ plan: ArchivePublicationPlan,
+ *,
+ imported_file_id: int,
+ action_id: int,
+) -> ArchivePublicationPlan:
+ """Bind a prepared publication to its completed import placement."""
+ _clean_session(session)
+ if any(type(value) is not int or value <= 0 for value in (imported_file_id, action_id)):
+ raise ArchivePublicationError("import_owner_invalid")
+ async with metadata_write_scope(session):
+ file = await session.get(ImportedFile, imported_file_id)
+ if file is None:
+ raise ArchivePublicationError("import_owner_missing")
+ job_id = file.import_job_id
+ await _lock_owner_rows(session, job_id, imported_file_id, action_id)
+ owner = await _owner_snapshot(
+ session,
+ plan,
+ job_id=job_id,
+ imported_file_id=imported_file_id,
+ action_id=action_id,
+ )
+ if plan.import_owner is not None and plan.import_owner != owner:
+ raise ArchivePublicationError("import_owner_changed")
+ return plan.model_copy(update={"import_owner": owner})
+
+
+async def require_import_archive_owner(session: AsyncSession, plan: ArchivePublicationPlan) -> None:
+ owner = plan.import_owner
+ if owner is None:
+ return
+ current = await _owner_snapshot(
+ session,
+ plan,
+ job_id=owner.job_id,
+ imported_file_id=owner.imported_file_id,
+ action_id=owner.action_id,
+ )
+ if current != owner:
+ raise ArchivePublicationError("import_owner_changed")
+
+
+async def acknowledge_import_archive_publication(
+ session: AsyncSession,
+ receipt: ArchivePublicationReceipt,
+ *,
+ cancelled: bool = False,
+) -> None:
+ """Caller already holds owner locks and verified the immutable owner snapshot."""
+ owner = receipt.plan.import_owner
+ if owner is None:
+ return
+ action = await session.get(ImportJobAction, owner.action_id)
+ file = await session.get(ImportedFile, owner.imported_file_id)
+ assert action is not None and file is not None
+ action.payload = {**action.payload, _SUCCESSOR: str(receipt.operation_id)}
+ details = dict(file.diagnostics[_ENRICHMENT])
+ details.update(
+ status="cancelled" if cancelled else "complete",
+ publication_id=str(receipt.operation_id),
+ completed_at=datetime.now(UTC).isoformat(),
+ )
+ file.diagnostics = {**file.diagnostics, _ENRICHMENT: details}
+
+
+async def import_rollback_signature(
+ session: AsyncSession,
+ action: ImportJobAction,
+) -> object:
+ """Require a settled successor bound to this original action before rollback.
+
+ Locks remain in the caller's transaction through removal. The original
+ signature is never replaced; a retained finalized publication proves only
+ the successor to compare with the actual filesystem, not permission to skip
+ that comparison.
+ """
+ original_payload = dict(action.payload)
+ async with metadata_write_scope(session):
+ await session.execute(
+ select(ImportJob.id).where(ImportJob.id == action.import_job_id).with_for_update()
+ )
+ await session.refresh(action, with_for_update=True)
+ if action.payload != original_payload:
+ raise ArchivePublicationError("import_owner_changed")
+ file_id = action.payload.get("library_file_id")
+ path = action.payload.get("destination_path")
+ if not isinstance(path, str):
+ raise ArchivePublicationError("import_owner_changed")
+ path_key = hashlib.sha256(os.fsencode(path)).hexdigest()
+ active = await session.scalar(
+ select(ArchiveMetadataPublication.id)
+ .where(
+ or_(
+ ArchiveMetadataPublication.active_file_id == file_id,
+ ArchiveMetadataPublication.active_path_key == path_key,
+ )
+ )
+ .limit(1)
+ )
+ if active is not None:
+ raise ArchivePublicationError("import_owner_publication_pending")
+ if _SUCCESSOR not in action.payload:
+ return action.payload.get("destination_signature")
+ file = await session.scalar(
+ select(LibraryFile)
+ .where(LibraryFile.id == file_id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ try:
+ operation_id = UUID(action.payload[_SUCCESSOR])
+ except (ValueError, TypeError, AttributeError):
+ raise ArchivePublicationError("import_owner_successor_invalid") from None
+ row = await session.scalar(
+ select(ArchiveMetadataPublication)
+ .where(ArchiveMetadataPublication.operation_id == str(operation_id))
+ .with_for_update()
+ )
+ if row is None or row.state not in {PublicationState.FINALIZED, PublicationState.SETTLED}:
+ raise ArchivePublicationError("import_owner_successor_invalid")
+ receipt = _receipt(row)
+ owner = receipt.plan.import_owner
+ binding = receipt.plan.target.binding
+ original = {key: value for key, value in action.payload.items() if key != _SUCCESSOR}
+ if (
+ owner is None
+ or file is None
+ or file.storage_mode is not LibraryFileStorageMode.MANAGED
+ or file.issue_id != binding.metadata.issues[0].local_id
+ or file.library_root_id != binding.library_root_id
+ or file.file_path != str(receipt.plan.target.path)
+ or file.file_name != receipt.plan.target.path.name
+ or _digest(file.source_signature) != _digest(_signature(receipt.plan))
+ or owner.action_id != action.id
+ or owner.job_id != action.import_job_id
+ or owner.imported_file_id != action.payload.get("imported_file_id")
+ or owner.action_digest != _digest(original)
+ or receipt.plan.target.binding.library_file_id != file_id
+ or str(receipt.plan.target.path) != path
+ or action.payload.get("destination_signature") != _signature(receipt.plan)
+ ):
+ raise ArchivePublicationError("import_owner_successor_invalid")
+ return _signature(receipt.plan, successor=True)
diff --git a/src/pullbox/services/import_archive_recovery.py b/src/pullbox/services/import_archive_recovery.py
new file mode 100644
index 00000000..28d3b363
--- /dev/null
+++ b/src/pullbox/services/import_archive_recovery.py
@@ -0,0 +1,98 @@
+"""Settle retained import publication without repeating filesystem mutation."""
+
+from dataclasses import replace
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.models import LibraryFile
+from pullbox.models.archive_metadata_publication import PublicationState
+from pullbox.models.import_job import ImportJob
+from pullbox.services.archive_metadata_finalization import (
+ _check_output,
+ finalize_archive_publication,
+)
+from pullbox.services.archive_metadata_publication import (
+ ArchivePublicationError,
+ ArchivePublicationInspection,
+ ArchivePublicationReceipt,
+ _clean_session,
+ _file_work,
+ _lock_binding,
+ _locked_row,
+ _receipt,
+ load_archive_publication,
+ reconcile_archive_publication,
+)
+from pullbox.services.import_archive_publication import (
+ _owner_snapshot,
+ acknowledge_import_archive_publication,
+ import_archive_owner_stopped,
+)
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+
+async def recover_import_archive_publication(
+ session: AsyncSession,
+ receipt: ArchivePublicationReceipt,
+ inspection: ArchivePublicationInspection,
+) -> ArchivePublicationReceipt:
+ """Classify and settle one inspected import-owned publication.
+
+ Never republish or restore an archive. A stopped owner receives file accounting
+ and successor proof only, not a stale canonical metadata update. All writes
+ belong to the caller's transaction; inspection/hashing happens before it.
+ """
+ _clean_session(session)
+ owner = receipt.plan.import_owner
+ if owner is None:
+ raise ArchivePublicationError("import_owner_missing")
+ if (inspection.operation_id, inspection.revision) != (receipt.operation_id, receipt.revision):
+ raise ArchivePublicationError("publication_changed")
+ async with metadata_write_scope(session):
+ known = await load_archive_publication(session, receipt.operation_id)
+ if known is None or known.plan != receipt.plan:
+ raise ArchivePublicationError("publication_changed")
+ await _lock_binding(session, known.plan)
+ row = await _locked_row(session, receipt.operation_id)
+ current = _receipt(row)
+ if current.state in {
+ PublicationState.FINALIZED,
+ PublicationState.SETTLED,
+ PublicationState.ABANDONED,
+ }:
+ return current
+ if current != receipt:
+ raise ArchivePublicationError("publication_changed")
+ current = await reconcile_archive_publication(session, receipt, inspection)
+ if current.state is not PublicationState.PUBLISHED:
+ return current
+ inspection = replace(inspection, revision=current.revision)
+ job = await session.get(ImportJob, owner.job_id, populate_existing=True)
+ if job is None or not import_archive_owner_stopped(job):
+ return await finalize_archive_publication(session, current, inspection)
+ snapshot = await _owner_snapshot(
+ session,
+ current.plan,
+ job_id=owner.job_id,
+ imported_file_id=owner.imported_file_id,
+ action_id=owner.action_id,
+ stopped=True,
+ )
+ if snapshot != owner:
+ raise ArchivePublicationError("import_owner_changed")
+ modified_at = await _file_work(lambda stop: _check_output(current, inspection))
+ file = await session.get(LibraryFile, current.plan.target.binding.library_file_id)
+ assert file is not None and inspection.fingerprint is not None
+ file.file_size = inspection.fingerprint[2]
+ file.file_modified_at = modified_at
+ file.file_hash = current.plan.output_digest
+ file.has_comicinfo = True
+ await acknowledge_import_archive_publication(session, current, cancelled=True)
+ await session.flush()
+ await _file_work(lambda stop: _check_output(current, inspection))
+ row.state = PublicationState.SETTLED
+ row.revision += 1
+ row.active_file_id = None
+ row.active_path_key = None
+ await session.flush()
+ return _receipt(row)
diff --git a/src/pullbox/services/import_comicinfo_enrichment.py b/src/pullbox/services/import_comicinfo_enrichment.py
index 290021e3..e822cae2 100644
--- a/src/pullbox/services/import_comicinfo_enrichment.py
+++ b/src/pullbox/services/import_comicinfo_enrichment.py
@@ -3,43 +3,59 @@
from __future__ import annotations
import asyncio
+import hashlib
import inspect
-from collections.abc import Awaitable, Callable
+import os
+from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import structlog
+from sqlalchemy import or_
from sqlalchemy import select as sa_select
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import joinedload
+from pullbox.core.metadata_identity import MetadataSource
from pullbox.core.sqlite_lock import (
SQLITE_LOCK_RETRY_ATTEMPTS,
is_sqlite_locked_error,
sqlite_lock_retry_delay,
)
-from pullbox.models.import_job import ImportedFile, ImportedFileStatus, ImportJob, ImportJobStatus
+from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication
+from pullbox.models.import_job import (
+ ImportControlRequest,
+ ImportedFile,
+ ImportedFileStatus,
+ ImportJob,
+ ImportJobStatus,
+)
from pullbox.models.issue import Issue
from pullbox.models.library import LibraryFile
from pullbox.models.series import Series
from pullbox.services.import_comicinfo_metadata import is_retryable_provider_error
from pullbox.services.import_metadata_priority import wait_for_comicinfo_turn
from pullbox.services.import_metadata_progress import track_import_metadata_progress
+from pullbox.services.metadata_sources import read_source_policies
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+ from pullbox.providers.base import IssueMetadata
+
logger = structlog.get_logger(__name__)
COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY = "comicinfo_enrichment"
COMICVINE_BULK_BATCH_SIZE = 5_000
+COMICINFO_FILE_BATCH_SIZE = 200
ImportComicInfoBuildPayload = Callable[..., Awaitable[dict[str, Any]]]
ImportComicInfoApply = Callable[[Path, dict[str, Any]], Any]
ImportComicInfoLogEvent = Callable[..., Awaitable[None]]
-ImportComicInfoPrefetch = Callable[[list[int]], Awaitable[Any]]
+type ImportComicInfoPrefetch = Callable[[list[int]], Awaitable[dict[int, IssueMetadata]]]
+type ImportMetadataWriter = Callable[[int, Mapping[int, IssueMetadata]], Awaitable[bool]]
comicinfo_enrichment_tasks: set[asyncio.Task[None]] = set()
_comicinfo_enrichment_semaphore: asyncio.Semaphore | None = None
@@ -84,6 +100,7 @@ def schedule_import_comicinfo_enrichment(
apply_comicinfo: ImportComicInfoApply,
log_event: ImportComicInfoLogEvent,
prefetch_issue_metadata: ImportComicInfoPrefetch | None = None,
+ metadata_writer: ImportMetadataWriter | None = None,
) -> None:
"""Queue deferred issue metadata and ComicInfo rewrites after Step 4 completes."""
if session_factory is None:
@@ -98,6 +115,7 @@ async def run_enrichment() -> None:
apply_comicinfo=apply_comicinfo,
log_event=log_event,
prefetch_issue_metadata=prefetch_issue_metadata,
+ metadata_writer=metadata_writer,
)
except Exception as exc:
logger.warning(
@@ -118,8 +136,13 @@ async def run_pending_import_comicinfo_enrichment(
apply_comicinfo: ImportComicInfoApply,
log_event: ImportComicInfoLogEvent,
prefetch_issue_metadata: ImportComicInfoPrefetch | None = None,
+ metadata_writer: ImportMetadataWriter | None = None,
) -> int:
"""Refresh deferred ComicInfo metadata for all completed jobs with pending rows."""
+ from pullbox.tasks.import_archive_recovery import recover_import_archive_publications
+
+ async with comicinfo_enrichment_gate(), session_factory() as session:
+ await recover_import_archive_publications(session)
pending_job_ids = await _load_pending_import_job_ids(session_factory)
for job_id in pending_job_ids:
await run_import_comicinfo_enrichment(
@@ -129,6 +152,7 @@ async def run_pending_import_comicinfo_enrichment(
apply_comicinfo=apply_comicinfo,
log_event=log_event,
prefetch_issue_metadata=prefetch_issue_metadata,
+ metadata_writer=metadata_writer,
)
return len(pending_job_ids)
@@ -141,6 +165,7 @@ async def run_import_comicinfo_enrichment(
apply_comicinfo: ImportComicInfoApply,
log_event: ImportComicInfoLogEvent,
prefetch_issue_metadata: ImportComicInfoPrefetch | None = None,
+ metadata_writer: ImportMetadataWriter | None = None,
) -> None:
"""Refresh deferred ComicInfo metadata for imported files in one import job."""
async with (
@@ -154,6 +179,7 @@ async def run_import_comicinfo_enrichment(
apply_comicinfo=apply_comicinfo,
log_event=log_event,
prefetch_issue_metadata=prefetch_issue_metadata,
+ metadata_writer=metadata_writer,
)
@@ -165,29 +191,27 @@ async def _run_import_comicinfo_enrichment_while_fenced(
apply_comicinfo: ImportComicInfoApply,
log_event: ImportComicInfoLogEvent,
prefetch_issue_metadata: ImportComicInfoPrefetch | None = None,
+ metadata_writer: ImportMetadataWriter | None = None,
) -> None:
"""Run one job while holding the process-local filesystem mutation fence."""
+ from pullbox.tasks.import_archive_recovery import recover_import_archive_publications
+
+ async with session_factory() as session:
+ await recover_import_archive_publications(session, job_id=job_id)
if not await _import_job_is_completed(session_factory, job_id=job_id):
return
pending_ids = await _load_pending_imported_file_ids(session_factory, job_id=job_id)
- if prefetch_issue_metadata is not None:
- issue_cv_ids = await _load_pending_issue_cv_ids(session_factory, job_id=job_id)
- if issue_cv_ids:
- for start in range(0, len(issue_cv_ids), COMICVINE_BULK_BATCH_SIZE):
- batch = issue_cv_ids[start : start + COMICVINE_BULK_BATCH_SIZE]
- await wait_for_comicinfo_turn()
- try:
- await prefetch_issue_metadata(batch)
- except Exception as exc:
- logger.warning(
- "import_comicinfo_metadata_batch_prefetch_failed",
- job_id=job_id,
- issue_count=len(batch),
- error=str(exc),
- )
- for imported_file_id in pending_ids:
+ async for imported_file_id, prefetched in _enrichment_inputs(
+ session_factory,
+ job_id=job_id,
+ pending_ids=pending_ids,
+ prefetch=prefetch_issue_metadata,
+ paired=metadata_writer is not None,
+ ):
await wait_for_comicinfo_turn()
try:
+ if metadata_writer is not None and await metadata_writer(imported_file_id, prefetched):
+ continue
prepared = await _prepare_pending_imported_file_with_retry(
session_factory,
imported_file_id=imported_file_id,
@@ -217,6 +241,10 @@ async def _run_import_comicinfo_enrichment_while_fenced(
log_event=log_event,
)
except Exception as exc:
+ if metadata_writer is not None and not await _import_job_is_completed(
+ session_factory, job_id=job_id
+ ):
+ return
if is_retryable_provider_error(exc):
logger.warning(
"import_comicinfo_enrichment_deferred_for_provider",
@@ -234,6 +262,50 @@ async def _run_import_comicinfo_enrichment_while_fenced(
)
+async def _enrichment_inputs(
+ session_factory: async_sessionmaker[AsyncSession],
+ *,
+ job_id: int,
+ pending_ids: list[int],
+ prefetch: ImportComicInfoPrefetch | None,
+ paired: bool,
+) -> AsyncIterator[tuple[int, Mapping[int, IssueMetadata]]]:
+ """Consume each bounded batch before fetching more full provider metadata."""
+ size = min(COMICINFO_FILE_BATCH_SIZE, COMICVINE_BULK_BATCH_SIZE)
+ for start in range(0, len(pending_ids), size):
+ if not await _import_job_is_completed(session_factory, job_id=job_id):
+ return
+ file_ids = pending_ids[start : start + size]
+ enabled = True
+ if paired:
+ async with session_factory() as session:
+ enabled = any(
+ policy.source is MetadataSource.COMICVINE_API and policy.enabled
+ for policy in await read_source_policies(session)
+ )
+ metadata: dict[int, IssueMetadata] = {}
+ if prefetch is not None and enabled:
+ provider_ids = await _load_pending_issue_cv_ids(
+ session_factory, job_id=job_id, imported_file_ids=file_ids
+ )
+ if provider_ids:
+ await wait_for_comicinfo_turn()
+ try:
+ metadata = await prefetch(provider_ids)
+ except Exception as exc:
+ logger.warning(
+ "import_comicinfo_metadata_batch_prefetch_failed",
+ job_id=job_id,
+ issue_count=len(provider_ids),
+ error=str(exc),
+ )
+ if paired:
+ # Do not report a sparse paired write as complete after a failed fetch.
+ return
+ for file_id in file_ids:
+ yield file_id, metadata
+
+
async def _import_job_is_completed(
session_factory: async_sessionmaker[AsyncSession],
*,
@@ -241,7 +313,11 @@ async def _import_job_is_completed(
) -> bool:
"""Read durable job state at a filesystem safe boundary."""
async with session_factory() as session:
- status = await session.scalar(sa_select(ImportJob.status).where(ImportJob.id == job_id))
+ status = await session.scalar(
+ sa_select(ImportJob.status).where(
+ ImportJob.id == job_id, ImportJob.control_request == ImportControlRequest.NONE
+ )
+ )
await session.rollback()
return status is ImportJobStatus.COMPLETED
@@ -257,6 +333,7 @@ async def _load_pending_imported_file_ids(
.join(ImportJob, ImportedFile.import_job_id == ImportJob.id)
.where(ImportedFile.import_job_id == job_id)
.where(ImportJob.status == ImportJobStatus.COMPLETED)
+ .where(ImportJob.control_request == ImportControlRequest.NONE)
.where(ImportedFile.status == ImportedFileStatus.IMPORTED)
)
ids: list[int] = []
@@ -271,6 +348,7 @@ async def _load_pending_issue_cv_ids(
session_factory: async_sessionmaker[AsyncSession],
*,
job_id: int,
+ imported_file_ids: list[int],
) -> list[int]:
"""Load unique provider issue IDs already recorded in pending diagnostics."""
async with session_factory() as session:
@@ -278,7 +356,9 @@ async def _load_pending_issue_cv_ids(
sa_select(ImportedFile.diagnostics)
.join(ImportJob, ImportedFile.import_job_id == ImportJob.id)
.where(ImportedFile.import_job_id == job_id)
+ .where(ImportedFile.id.in_(imported_file_ids))
.where(ImportJob.status == ImportJobStatus.COMPLETED)
+ .where(ImportJob.control_request == ImportControlRequest.NONE)
.where(ImportedFile.status == ImportedFileStatus.IMPORTED)
.order_by(ImportedFile.id)
)
@@ -310,6 +390,7 @@ async def _load_pending_import_job_ids(
sa_select(ImportedFile.import_job_id, ImportedFile.diagnostics)
.join(ImportJob, ImportedFile.import_job_id == ImportJob.id)
.where(ImportJob.status == ImportJobStatus.COMPLETED)
+ .where(ImportJob.control_request == ImportControlRequest.NONE)
.where(ImportedFile.status == ImportedFileStatus.IMPORTED)
.order_by(ImportedFile.import_job_id)
)
@@ -389,7 +470,10 @@ async def _prepare_pending_imported_file(
return None
assert imported_file is not None
job_status = await session.scalar(
- sa_select(ImportJob.status).where(ImportJob.id == imported_file.import_job_id)
+ sa_select(ImportJob.status).where(
+ ImportJob.id == imported_file.import_job_id,
+ ImportJob.control_request == ImportControlRequest.NONE,
+ )
)
if job_status is not ImportJobStatus.COMPLETED:
return None
@@ -411,6 +495,25 @@ async def _prepare_pending_imported_file(
if library_file is None:
raise ValueError(f"Library file {library_file_id} no longer exists")
+ reserved = await session.scalar(
+ sa_select(ArchiveMetadataPublication.id)
+ .where(
+ or_(
+ ArchiveMetadataPublication.active_file_id == library_file.id,
+ ArchiveMetadataPublication.active_path_key
+ == hashlib.sha256(os.fsencode(library_file.file_path)).hexdigest(),
+ )
+ )
+ .limit(1)
+ )
+ if reserved is not None:
+ logger.warning(
+ "import_comicinfo_publication_pending",
+ imported_file_id=imported_file_id,
+ publication_id=reserved,
+ )
+ return None
+
recorded_issue_id = _recorded_positive_int(details, "issue_id")
expected_issue_id = imported_file.matched_issue_id or recorded_issue_id
if expected_issue_id is not None and library_file.issue_id != expected_issue_id:
@@ -566,6 +669,24 @@ async def _mark_pending_file_failed(
imported_file = await session.get(ImportedFile, imported_file_id)
if imported_file is None:
return True
+ active_publication = await session.scalar(
+ sa_select(ArchiveMetadataPublication.id)
+ .where(
+ ArchiveMetadataPublication.active_file_id == imported_file.library_file_id,
+ ArchiveMetadataPublication.active_path_key.is_not(None),
+ )
+ .limit(1)
+ )
+ if active_publication is not None:
+ # Pending diagnostics are part of the immutable owner evidence.
+ # Changing them would prevent restart from settling publication.
+ logger.warning(
+ "import_metadata_publication_needs_recovery",
+ job_id=job_id,
+ imported_file_id=imported_file_id,
+ publication_id=active_publication,
+ )
+ return False
_set_comicinfo_enrichment_status(
imported_file,
status="failed",
diff --git a/src/pullbox/services/import_file_execution.py b/src/pullbox/services/import_file_execution.py
index 0148ad16..b973b8c9 100644
--- a/src/pullbox/services/import_file_execution.py
+++ b/src/pullbox/services/import_file_execution.py
@@ -17,6 +17,7 @@
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import joinedload
+from pullbox.config import get_settings
from pullbox.core.exceptions import (
ConfigurationError,
ImportDestinationValidationError,
@@ -1270,6 +1271,16 @@ async def report_current_file(
effective_embedded_comicinfo = update_embedded_comicinfo_from_match and not bool(
getattr(prepared, "skip_embedded_comicinfo", False)
)
+ defer_paired_metadata = (
+ get_settings().metadata_paired_import_writer_enabled
+ and defer_comicinfo_enrichment
+ and effective_embedded_comicinfo
+ and move_to_library
+ and Path(prepared.registration_source).suffix.casefold() in {".cbz", ".zip"}
+ )
+ if defer_paired_metadata:
+ # Copy unchanged first; the owned background job reconciles both XML files.
+ effective_embedded_comicinfo = False
preparation_warning = getattr(prepared, "preparation_warning", None)
if preparation_warning:
await log_event(
@@ -1424,7 +1435,7 @@ async def report_current_file(
imp_file.matched_issue_id = resolved_issue.id
imp_file.library_file_id = library_file.id
imp_file.status = ImportedFileStatus.IMPORTED
- comicinfo_enrichment_deferred = (
+ comicinfo_enrichment_deferred = defer_paired_metadata or (
bool(defer_comicinfo_enrichment)
and comicinfo_payload is not None
and resolved_issue.comicvine_id is not None
@@ -1464,7 +1475,7 @@ async def report_current_file(
unit="steps",
live_only=True,
)
- await record_action(
+ placement_action = await record_action(
session,
current_job,
phase="import",
@@ -1515,6 +1526,13 @@ async def report_current_file(
),
},
)
+ if comicinfo_enrichment_deferred and isinstance(placement_action.id, int):
+ diagnostics = dict(imp_file.diagnostics)
+ diagnostics[_COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY] = {
+ **diagnostics[_COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY],
+ "action_id": placement_action.id,
+ }
+ imp_file.diagnostics = diagnostics
await log_event(
session,
diff --git a/src/pullbox/services/import_job_actions.py b/src/pullbox/services/import_job_actions.py
index def26bfd..9167f2a9 100644
--- a/src/pullbox/services/import_job_actions.py
+++ b/src/pullbox/services/import_job_actions.py
@@ -10,9 +10,10 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, TypedDict
+import structlog
+from sqlalchemy import false, or_
from sqlalchemy import func as sa_func
from sqlalchemy import insert as sa_insert
-from sqlalchemy import or_
from sqlalchemy import select as sa_select
from sqlalchemy import update as sa_update
@@ -21,6 +22,7 @@
build_file_identity_signature,
build_managed_placement_signature,
)
+from pullbox.core.metadata_identity import IdentityNamespace
from pullbox.models.blocklist import BlocklistEntry
from pullbox.models.direct_acquisition import DirectAcquisitionAttempt
from pullbox.models.download import DownloadHistory
@@ -40,6 +42,7 @@
LibraryRoot,
MatchConfidence,
)
+from pullbox.models.metadata_identity import StoryArcIdentityEvent
from pullbox.models.pending_match import PendingMatch
from pullbox.models.reader import IssueReaderState
from pullbox.models.search_log import SearchLog
@@ -56,6 +59,11 @@
)
from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry
from pullbox.models.story_arc_sync import StoryArcSyncWork, StoryArcSyncWorkState
+from pullbox.services.import_story_arc_identity import (
+ IDENTITY_VERIFIED_ACTION,
+ require_owned_arc_identity_history,
+ rollback_import_arc_identity,
+)
from pullbox.utilities.settings import restore_file_from_utility_trash
if TYPE_CHECKING:
@@ -65,6 +73,7 @@
_ACTION_SEQUENCE_CACHE_KEY = "pullbox.import_action_last_sequence"
+logger = structlog.get_logger(__name__)
_ACTION_INSERT_BATCH_SIZE = 200
_STORY_ARC_MANAGED_PLACEMENT_ACTION = "story_arc_managed_placement_requested"
_STORY_ARC_PLACEMENT_PHASE = "story_arc_placements"
@@ -788,11 +797,43 @@ async def rollback_action(
await _rollback_updated_story_arc_membership(session, action, payload)
elif action_type == "story_arc_external_identity_created":
await _rollback_created_story_arc_external_identity(session, action, payload)
+ elif action_type == IDENTITY_VERIFIED_ACTION:
+ await _require_story_arc_ownership(
+ session,
+ action=action,
+ imported_story_arc_id=_positive_int(
+ payload.get("imported_story_arc_id"), "imported_story_arc_id"
+ ),
+ story_arc_id=_positive_int(payload.get("story_arc_id"), "story_arc_id"),
+ )
+ await rollback_import_arc_identity(session, action)
elif action_type == "story_arc_policy_updated":
await _rollback_story_arc_policy_update(session, action, payload)
elif action_type == "story_arc_created":
await _rollback_created_story_arc(session, action, payload)
elif action_type == "library_file_registered":
+ from pullbox.services.archive_metadata_publication import ArchivePublicationError
+ from pullbox.services.import_archive_publication import import_rollback_signature
+
+ if action.status is ImportJobActionStatus.ROLLED_BACK:
+ return
+ try:
+ rollback_signature = await import_rollback_signature(session, action)
+ except ArchivePublicationError as exc:
+ logger.warning(
+ "import_archive_rollback_preserved",
+ action_id=action.id,
+ job_id=action.import_job_id,
+ reason=exc.code,
+ )
+ action.status = ImportJobActionStatus.ROLLBACK_FAILED
+ action.error_message = (
+ "Archive metadata publication is pending or its import ownership changed. "
+ "Pullbox preserved the file for recovery."
+ )
+ action.rolled_back_at = None
+ await session.flush()
+ return
library_file_id = int(payload.get("library_file_id") or 0)
destination_path = Path(str(payload.get("destination_path") or ""))
original_source_path = Path(str(payload.get("original_source_path") or ""))
@@ -842,7 +883,7 @@ async def rollback_action(
and not _managed_destination_matches_rollback_action(
destination_path,
library_file=library_file,
- expected_signature=payload.get("destination_signature"),
+ expected_signature=rollback_signature,
)
):
action.status = ImportJobActionStatus.ROLLBACK_FAILED
@@ -2619,7 +2660,12 @@ async def _rollback_created_story_arc_external_identity(
identity_id = _positive_int(payload.get("external_identity_id"), "external_identity_id")
story_arc_id = _positive_int(payload.get("story_arc_id"), "story_arc_id")
expected_after = _payload_mapping(payload, "expected_after")
- identity = await session.get(StoryArcExternalIdentity, identity_id)
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(sa_update(StoryArc).where(false()).values(revision=StoryArc.revision))
+ await session.execute(
+ sa_select(StoryArc.id).where(StoryArc.id == story_arc_id).with_for_update()
+ )
+ identity = await session.get(StoryArcExternalIdentity, identity_id, populate_existing=True)
if identity is None:
return
await _require_story_arc_ownership(
@@ -2635,6 +2681,25 @@ async def _rollback_created_story_arc_external_identity(
or _external_identity_state(identity) != expected_after
):
raise ValueError("Story-arc external identity changed after import; rollback refused")
+ if identity.source in set(IdentityNamespace) and identity.namespace == "story_arc":
+ history = await session.scalar(
+ sa_select(StoryArcIdentityEvent.id)
+ .where(
+ StoryArcIdentityEvent.story_arc_id == story_arc_id,
+ StoryArcIdentityEvent.identity_namespace == identity.source,
+ )
+ .limit(1)
+ )
+ if (
+ identity.verification_state is not None
+ or identity.evidence_kind is not None
+ or identity.evidence_locator is not None
+ or identity.verified_at is not None
+ or identity.last_seen_at is not None
+ or identity.revision != 1
+ or history is not None
+ ):
+ raise ValueError("Story-arc external identity changed after import; rollback refused")
await session.delete(identity)
@@ -2669,7 +2734,14 @@ async def _rollback_created_story_arc(
) -> None:
story_arc_id = _positive_int(payload.get("story_arc_id"), "story_arc_id")
expected_after = _payload_mapping(payload, "expected_after")
- arc = await session.get(StoryArc, story_arc_id)
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(sa_update(StoryArc).where(false()).values(revision=StoryArc.revision))
+ arc = await session.scalar(
+ sa_select(StoryArc)
+ .where(StoryArc.id == story_arc_id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
if arc is None:
return
await _require_story_arc_ownership(
@@ -2714,6 +2786,9 @@ async def _rollback_created_story_arc(
)
if membership_count or identity_count or placement_count:
raise ValueError("Story arc still has related rows; rollback refused")
+ await require_owned_arc_identity_history(
+ session, arc_id=story_arc_id, import_job_id=action.import_job_id
+ )
await session.delete(arc)
diff --git a/src/pullbox/services/import_metadata_follow_up.py b/src/pullbox/services/import_metadata_follow_up.py
new file mode 100644
index 00000000..170d9096
--- /dev/null
+++ b/src/pullbox/services/import_metadata_follow_up.py
@@ -0,0 +1,157 @@
+"""Bounded follow-up and explicit retries for imported archive metadata writes."""
+
+from datetime import UTC, datetime
+
+from sqlalchemy import ColumnElement, and_, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import NotFoundError, ValidationError
+from pullbox.models import LibraryFile
+from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication
+from pullbox.models.import_job import (
+ ImportControlRequest,
+ ImportedFile,
+ ImportedFileStatus,
+ ImportJob,
+ ImportJobLog,
+ ImportJobStatus,
+)
+from pullbox.services.library_mutation_coordination import lock_file_mutation_admission
+
+PAGE_SIZE = 25
+
+
+def failed_metadata_write_filter() -> ColumnElement[bool]:
+ return and_(
+ ImportedFile.status == ImportedFileStatus.IMPORTED,
+ ImportedFile.diagnostics["comicinfo_enrichment"]["status"].as_string() == "failed",
+ )
+
+
+async def count_failed_metadata_writes(session: AsyncSession, job_id: int) -> int:
+ return int(
+ await session.scalar(
+ select(func.count())
+ .select_from(ImportedFile)
+ .where(
+ ImportedFile.import_job_id == job_id,
+ failed_metadata_write_filter(),
+ )
+ )
+ or 0
+ )
+
+
+async def load_metadata_write_follow_up(
+ session: AsyncSession,
+ job_id: int,
+ *,
+ page: int = 1,
+) -> dict[str, object]:
+ job = await session.get(ImportJob, job_id)
+ if job is None or job.archived_at is not None:
+ raise NotFoundError("ImportJob", job_id)
+ total = await count_failed_metadata_writes(session, job_id)
+ pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
+ page = min(max(page, 1), pages)
+ files = list(
+ await session.scalars(
+ select(ImportedFile)
+ .where(
+ ImportedFile.import_job_id == job_id,
+ failed_metadata_write_filter(),
+ )
+ .order_by(ImportedFile.id)
+ .offset((page - 1) * PAGE_SIZE)
+ .limit(PAGE_SIZE)
+ )
+ )
+ return {
+ "job": job,
+ "metadata_files": files,
+ "metadata_total": total,
+ "metadata_page": page,
+ "metadata_pages": pages,
+ "metadata_retry_allowed": job.status is ImportJobStatus.COMPLETED
+ and job.control_request is ImportControlRequest.NONE,
+ }
+
+
+async def retry_import_metadata_write(
+ session: AsyncSession,
+ job_id: int,
+ file_id: int,
+ *,
+ actor: str,
+) -> bool:
+ """Queue only failed metadata work; preserve placement and publication evidence.
+
+ The caller commits before scheduling the existing background job. All file,
+ identity and root checks still run in that job, never in the request's write
+ transaction. This does not approve new file contents or change a match.
+ """
+ await lock_file_mutation_admission(session)
+ job = await session.scalar(
+ select(ImportJob)
+ .where(ImportJob.id == job_id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if job is None or job.archived_at is not None:
+ raise NotFoundError("ImportJob", job_id)
+ if (
+ job.status is not ImportJobStatus.COMPLETED
+ or job.control_request is not ImportControlRequest.NONE
+ ):
+ raise ValidationError("This import is no longer ready for a metadata retry.")
+ file = await session.scalar(
+ select(ImportedFile)
+ .where(
+ ImportedFile.id == file_id,
+ ImportedFile.import_job_id == job_id,
+ )
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if file is None:
+ raise NotFoundError("ImportedFile", file_id)
+ details = file.diagnostics.get("comicinfo_enrichment", {})
+ if not isinstance(details, dict) or file.status is not ImportedFileStatus.IMPORTED:
+ raise ValidationError("This file has no imported metadata work to retry.")
+ if details.get("status") in {"pending", "complete"}:
+ return False
+ if details.get("status") != "failed":
+ raise ValidationError("This file has no failed metadata write to retry.")
+ if file.library_file_id is None or await session.get(LibraryFile, file.library_file_id) is None:
+ raise ValidationError("The library file is no longer registered. Rescan its series first.")
+ active = await session.scalar(
+ select(ArchiveMetadataPublication.id)
+ .where(
+ ArchiveMetadataPublication.active_file_id == file.library_file_id,
+ ArchiveMetadataPublication.active_path_key.is_not(None),
+ )
+ .limit(1)
+ )
+ if active is not None:
+ raise ValidationError(
+ "An archive write still needs recovery. Retry after recovery completes."
+ )
+ file.diagnostics = {
+ **file.diagnostics,
+ "comicinfo_enrichment": {
+ **details,
+ "status": "pending",
+ "retry_requested_at": datetime.now(UTC).isoformat(),
+ },
+ }
+ session.add(
+ ImportJobLog(
+ import_job_id=job_id,
+ level="INFO",
+ event="import_archive_metadata_retry_requested",
+ message=f"Archive metadata retry requested for {file.file_name}",
+ data={"imported_file_id": file_id, "actor": actor},
+ )
+ )
+ await session.flush()
+ return True
diff --git a/src/pullbox/services/import_reference_recovery.py b/src/pullbox/services/import_reference_recovery.py
index e60edb9d..cad50cff 100644
--- a/src/pullbox/services/import_reference_recovery.py
+++ b/src/pullbox/services/import_reference_recovery.py
@@ -12,7 +12,7 @@
from sqlalchemy import and_, or_, select
-from pullbox.core.exceptions import ConfigurationError
+from pullbox.core.exceptions import ConfigurationError, ValidationError
from pullbox.core.library_file_ownership import build_file_identity_signature
from pullbox.core.name_matcher import NameMatcher
from pullbox.models.import_job import (
@@ -37,6 +37,10 @@
catalog_target_agrees,
record_catalog_review,
)
+from pullbox.services.metadata_writer_identity import (
+ attach_issue_summary_identities,
+ metadata_write_scope,
+)
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
@@ -206,10 +210,6 @@ async def repair_catalog_references(
progress: Callable[[int, int], Awaitable[None]] | None = None,
) -> int:
"""Create missing metadata targets and reuse ownership repair, never file import."""
- from pullbox.services.import_completed_cleanup import (
- _apply_mixed_folder_resolutions,
- _MixedFolderResolution,
- )
from pullbox.services.import_workflow_state import raise_if_job_cancelled
current = await reference_candidates(session, job.id)
@@ -276,143 +276,29 @@ async def repair_catalog_references(
or root.path != inspected_root_path
):
continue
- cv_id = int(target["cv_id"])
- issue_cv_id = int(target["summary"]["provider_id"])
- series = await session.scalar(select(Series).where(Series.comicvine_id == cv_id))
- created_series = series is None
- issue = await session.scalar(select(Issue).where(Issue.comicvine_id == issue_cv_id))
- reparent_issue = issue is not None and (series is None or issue.series_id != series.id)
- if reparent_issue and (
- issue is None
- or issue.id != library.issue_id
- or issue.series_id != item.series_id
- or identity.get("issue_cv_id") != issue.comicvine_id
- or identity["evidence"] != "comicinfo"
- ):
- continue
- if issue is not None and (
- issue.effective_issue_number_text != identity["issue_number"]
- or issue.issue_type.value != str(target["summary"].get("issue_type") or "issue")
- ):
- continue
- if issue is not None and await session.scalar(
- select(LibraryFile.id).where(
- LibraryFile.issue_id == issue.id, LibraryFile.id != library.id
- )
- ):
- continue
- if (
- reparent_issue
- and issue is not None
- and await session.scalar(
- select(ImportedFile.id).where(
- ImportedFile.matched_issue_id == issue.id,
- ImportedFile.id != file.id,
- ImportedFile.status == ImportedFileStatus.IMPORTED,
+ try:
+ async with metadata_write_scope(session):
+ changed = await _repair_reference(
+ session,
+ job,
+ metadata,
+ file,
+ item,
+ library,
+ root,
+ inspected_root_path,
+ identity,
+ target,
)
- )
- ):
+ except ValidationError as exc:
+ if (exc.details or {}).get("reason") != "metadata_identity_conflict":
+ raise
+ await session.refresh(file)
+ record_catalog_review(file, identity, "Identity ownership needs review before repair.")
+ await session.commit()
continue
- if await session.scalar(
- select(ImportedFile.id).where(
- ImportedFile.matched_issue_cv_id == issue_cv_id,
- ImportedFile.include_in_import.is_(True),
- ImportedFile.status.in_((ImportedFileStatus.MATCHED, ImportedFileStatus.CONFIRMED)),
- )
- ):
+ if not changed:
continue
- if series is not None and (issue is None or reparent_issue):
- existing = list(
- await session.scalars(select(Issue).where(Issue.series_id == series.id))
- )
- if any(row.effective_issue_number_text == identity["issue_number"] for row in existing):
- continue
- if series is None:
- series = await metadata.upsert_series_metadata(
- session,
- cv_id,
- CatalogSeriesMetadata(
- provider_id=str(cv_id),
- title=str(target["title"]),
- sort_title=str(target["title"]),
- year_start=target.get("year"),
- year_end=None,
- status=None,
- publisher=target.get("publisher"),
- description=None,
- cover_url=target.get("cover_url"),
- issue_count=target.get("issue_count"),
- comicvine_url=target.get("comicvine_url"),
- ),
- )
- series.monitored = False
- series.issue_catalog_state = IssueCatalogState.PARTIAL
- previous_series_id = issue.series_id if reparent_issue and issue is not None else None
- if reparent_issue and issue is not None:
- # The exact embedded identity and catalog agree, and this is the
- # only registration. Retain the issue ID (including reader state).
- issue.series_id = series.id
- if issue is None:
- payload = target["summary"]
- cutoff = payload.get("source_cutoff_at")
- summary = CatalogIssueSummary(
- source_cutoff_at=datetime.fromisoformat(cutoff) if cutoff else None,
- **{
- key: payload[key] for key in IssueSummary.__dataclass_fields__ if key in payload
- },
- )
- await metadata.upsert_issue_summaries(session, series, [summary])
- issue = await session.scalar(select(Issue).where(Issue.comicvine_id == issue_cv_id))
- if issue is None or issue.series_id != series.id:
- continue
- resolution = _MixedFolderResolution(
- file_id=file.id,
- source_import_series_id=item.id,
- source_import_series_name=item.raw_series_name,
- target_series_id=series.id,
- target_series_title=series.title,
- target_issue_id=issue.id,
- target_issue_cv_id=issue.comicvine_id,
- target_issue_number=issue.issue_number,
- target_issue_number_text=issue.effective_issue_number_text,
- target_library_file_id=None,
- source_library_file_id=library.id,
- source_issue_id=library.issue_id,
- source_library_updated_at=library.updated_at.isoformat(),
- evidence_source=identity["evidence"],
- source_series_name=identity["query"],
- source_updated_at=file.updated_at.isoformat(),
- )
- affected, _ = await _apply_mixed_folder_resolutions(session, job, resolutions=(resolution,))
- if previous_series_id is not None:
- file.diagnostics = {
- **file.diagnostics,
- "completed_import_cleanup": {
- **file.diagnostics["completed_import_cleanup"],
- "source_catalog_series_id": previous_series_id,
- "issue_identity_preserved": True,
- },
- }
- previous_issue = await session.get(Issue, resolution.source_issue_id)
- if (
- previous_issue is not None
- and previous_issue.comicvine_id is None
- and previous_issue.metadata_source in {"provisional_import", "import_placeholder"}
- and not await session.scalar(
- select(LibraryFile.id).where(LibraryFile.issue_id == previous_issue.id)
- )
- ):
- # Keep the audit/reader row, but do not search for an issue invented
- # from a misplaced filename under the wrong series.
- previous_issue.status = IssueStatus.SKIPPED
- if created_series:
- target_group = await session.get(ImportedSeries, file.import_series_id)
- assert target_group is not None
- target_group.status = ImportSeriesStatus.IMPORTED
- apply_proven_identity(
- file, issue_cv_id=issue_cv_id, series_cv_id=cv_id, summary=target["summary"]
- )
- await refresh_recovered_groups(session, job, affected)
repaired += 1
state = dict(job.progress_snapshot.get("deferred_recovery") or {})
state["reference_files_repaired"] = repaired
@@ -421,3 +307,190 @@ async def repair_catalog_references(
if progress is not None and plans:
await progress(len(plans), len(plans))
return repaired
+
+
+async def _repair_reference(
+ session: AsyncSession,
+ job: ImportJob,
+ metadata: MetadataService,
+ file: ImportedFile,
+ item: ImportedSeries,
+ library: LibraryFile,
+ root: LibraryRoot,
+ inspected_root_path: str,
+ identity: dict[str, Any],
+ target: dict[str, Any],
+) -> bool:
+ """Keep assignment and identity evidence in the same per-file savepoint."""
+ from pullbox.services.import_completed_cleanup import (
+ _apply_mixed_folder_resolutions,
+ _MixedFolderResolution,
+ )
+
+ cv_id = int(target["cv_id"])
+ issue_cv_id = int(target["summary"]["provider_id"])
+ series = await session.scalar(select(Series).where(Series.comicvine_id == cv_id))
+ issue = await session.scalar(select(Issue).where(Issue.comicvine_id == issue_cv_id))
+ source_issue = await session.get(Issue, library.issue_id)
+ expected_parents = {row.id: row.series_id for row in (issue, source_issue) if row is not None}
+ parent_ids = set(expected_parents.values())
+ if series is not None:
+ parent_ids.add(series.id)
+ await session.execute(
+ select(Series.id).where(Series.id.in_(parent_ids)).order_by(Series.id).with_for_update()
+ )
+ locked = list(
+ await session.scalars(
+ select(Issue)
+ .where(Issue.id.in_(expected_parents))
+ .order_by(Issue.id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ )
+ if len(locked) != len(expected_parents) or any(
+ row.series_id != expected_parents[row.id] for row in locked
+ ):
+ return False
+ for row in (file, item, library, root):
+ await session.refresh(row)
+ if (
+ _stamp(file, item, library) != identity["stamp"]
+ or not root.enabled
+ or not root.allow_referenced_registrations
+ or root.path != inspected_root_path
+ ):
+ return False
+
+ created_series = series is None
+ reparent_issue = issue is not None and (series is None or issue.series_id != series.id)
+ if reparent_issue and (
+ issue is None
+ or issue.id != library.issue_id
+ or issue.series_id != item.series_id
+ or identity.get("issue_cv_id") != issue.comicvine_id
+ or identity["evidence"] != "comicinfo"
+ ):
+ return False
+ if issue is not None and (
+ issue.effective_issue_number_text != identity["issue_number"]
+ or issue.issue_type.value != str(target["summary"].get("issue_type") or "issue")
+ ):
+ return False
+ if issue is not None and await session.scalar(
+ select(LibraryFile.id).where(LibraryFile.issue_id == issue.id, LibraryFile.id != library.id)
+ ):
+ return False
+ if (
+ reparent_issue
+ and issue is not None
+ and await session.scalar(
+ select(ImportedFile.id).where(
+ ImportedFile.matched_issue_id == issue.id,
+ ImportedFile.id != file.id,
+ ImportedFile.status == ImportedFileStatus.IMPORTED,
+ )
+ )
+ ):
+ return False
+ if await session.scalar(
+ select(ImportedFile.id).where(
+ ImportedFile.matched_issue_cv_id == issue_cv_id,
+ ImportedFile.include_in_import.is_(True),
+ ImportedFile.status.in_((ImportedFileStatus.MATCHED, ImportedFileStatus.CONFIRMED)),
+ )
+ ):
+ return False
+ if series is not None and (issue is None or reparent_issue):
+ existing = list(await session.scalars(select(Issue).where(Issue.series_id == series.id)))
+ if any(row.effective_issue_number_text == identity["issue_number"] for row in existing):
+ return False
+ if series is None:
+ series = await metadata.upsert_series_metadata(
+ session,
+ cv_id,
+ CatalogSeriesMetadata(
+ provider_id=str(cv_id),
+ title=str(target["title"]),
+ sort_title=str(target["title"]),
+ year_start=target.get("year"),
+ year_end=None,
+ status=None,
+ publisher=target.get("publisher"),
+ description=None,
+ cover_url=target.get("cover_url"),
+ issue_count=target.get("issue_count"),
+ comicvine_url=target.get("comicvine_url"),
+ ),
+ )
+ series.monitored = False
+ series.issue_catalog_state = IssueCatalogState.PARTIAL
+ previous_series_id = issue.series_id if reparent_issue and issue is not None else None
+ if reparent_issue and issue is not None:
+ # Preserve the issue ID and reader state when exact embedded and catalog
+ # identities agree. The surrounding savepoint also encloses its history.
+ issue.series_id = series.id
+ payload = target["summary"]
+ cutoff = payload.get("source_cutoff_at")
+ summary = CatalogIssueSummary(
+ source_cutoff_at=datetime.fromisoformat(cutoff) if cutoff else None,
+ **{key: payload[key] for key in IssueSummary.__dataclass_fields__ if key in payload},
+ )
+ if issue is None:
+ await metadata.upsert_issue_summaries(session, series, [summary])
+ issue = await session.scalar(select(Issue).where(Issue.comicvine_id == issue_cv_id))
+ else:
+ await attach_issue_summary_identities(session, series, [(issue, summary)], None)
+ if issue is None or issue.series_id != series.id:
+ raise ValidationError(
+ "The recovery target changed. Review the match before retrying.",
+ details={"reason": "metadata_identity_conflict"},
+ )
+ resolution = _MixedFolderResolution(
+ file_id=file.id,
+ source_import_series_id=item.id,
+ source_import_series_name=item.raw_series_name,
+ target_series_id=series.id,
+ target_series_title=series.title,
+ target_issue_id=issue.id,
+ target_issue_cv_id=issue.comicvine_id,
+ target_issue_number=issue.issue_number,
+ target_issue_number_text=issue.effective_issue_number_text,
+ target_library_file_id=None,
+ source_library_file_id=library.id,
+ source_issue_id=library.issue_id,
+ source_library_updated_at=library.updated_at.isoformat(),
+ evidence_source=identity["evidence"],
+ source_series_name=identity["query"],
+ source_updated_at=file.updated_at.isoformat(),
+ )
+ affected, _ = await _apply_mixed_folder_resolutions(session, job, resolutions=(resolution,))
+ if previous_series_id is not None:
+ file.diagnostics = {
+ **file.diagnostics,
+ "completed_import_cleanup": {
+ **file.diagnostics["completed_import_cleanup"],
+ "source_catalog_series_id": previous_series_id,
+ "issue_identity_preserved": True,
+ },
+ }
+ previous_issue = await session.get(Issue, resolution.source_issue_id)
+ if (
+ previous_issue is not None
+ and previous_issue.comicvine_id is None
+ and previous_issue.metadata_source in {"provisional_import", "import_placeholder"}
+ and not await session.scalar(
+ select(LibraryFile.id).where(LibraryFile.issue_id == previous_issue.id)
+ )
+ ):
+ # Keep audit/reader state without searching for an invented issue.
+ previous_issue.status = IssueStatus.SKIPPED
+ if created_series:
+ target_group = await session.get(ImportedSeries, file.import_series_id)
+ assert target_group is not None
+ target_group.status = ImportSeriesStatus.IMPORTED
+ apply_proven_identity(
+ file, issue_cv_id=issue_cv_id, series_cv_id=cv_id, summary=target["summary"]
+ )
+ await refresh_recovered_groups(session, job, affected)
+ return True
diff --git a/src/pullbox/services/import_rollback_execution.py b/src/pullbox/services/import_rollback_execution.py
index 9bed442a..c117385c 100644
--- a/src/pullbox/services/import_rollback_execution.py
+++ b/src/pullbox/services/import_rollback_execution.py
@@ -192,7 +192,10 @@ async def rollback_import_job(
progress_callback: ProgressCallback | None = None,
) -> bool:
"""Rollback durable import actions in reverse execution order."""
+ from pullbox.tasks.import_archive_recovery import recover_import_archive_publications
+
async with comicinfo_enrichment_gate():
+ await recover_import_archive_publications(session, job_id=job_id)
return await _rollback_import_job_while_enrichment_fenced(
session,
job_id,
diff --git a/src/pullbox/services/import_service.py b/src/pullbox/services/import_service.py
index edea9580..54705543 100644
--- a/src/pullbox/services/import_service.py
+++ b/src/pullbox/services/import_service.py
@@ -762,6 +762,12 @@ def schedule_comicinfo_enrichment(
job_id: int,
) -> None:
"""Queue deferred ComicInfo rewrites after the import transaction commits."""
+ from functools import partial
+
+ from pullbox.tasks.import_metadata_writing import write_imported_archive_metadata
+
+ if session_factory is None:
+ return
schedule_import_comicinfo_enrichment(
session_factory,
job_id=job_id,
@@ -769,6 +775,11 @@ def schedule_comicinfo_enrichment(
apply_comicinfo=self._apply_comicinfo_to_imported_artifact,
log_event=self._log_event,
prefetch_issue_metadata=self._metadata_service.prefetch_issue_metadata_batch,
+ metadata_writer=(
+ partial(write_imported_archive_metadata, session_factory)
+ if get_settings().metadata_paired_import_writer_enabled
+ else None
+ ),
)
def schedule_story_arc_sync(self) -> None:
@@ -782,12 +793,21 @@ async def recover_pending_comicinfo_enrichment(
session_factory: async_sessionmaker[AsyncSession],
) -> int:
"""Resume deferred ComicInfo rewrites left pending after a restart."""
+ from functools import partial
+
+ from pullbox.tasks.import_metadata_writing import write_imported_archive_metadata
+
return await run_pending_import_comicinfo_enrichment(
session_factory,
build_comicinfo_payload=self._build_comicinfo_payload_for_issue,
apply_comicinfo=self._apply_comicinfo_to_imported_artifact,
log_event=self._log_event,
prefetch_issue_metadata=self._metadata_service.prefetch_issue_metadata_batch,
+ metadata_writer=(
+ partial(write_imported_archive_metadata, session_factory)
+ if get_settings().metadata_paired_import_writer_enabled
+ else None
+ ),
)
async def recover_pending_catalog_hydration(
diff --git a/src/pullbox/services/import_story_arc_identity.py b/src/pullbox/services/import_story_arc_identity.py
new file mode 100644
index 00000000..d366aed6
--- /dev/null
+++ b/src/pullbox/services/import_story_arc_identity.py
@@ -0,0 +1,399 @@
+"""Verified import arc identities and guarded, history-preserving journal undo."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import datetime
+from typing import TYPE_CHECKING, Any
+from uuid import NAMESPACE_URL, uuid5
+
+from sqlalchemy import false, select, update
+
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_identity_events import (
+ IdentityEventActor,
+ IdentityEventEvidence,
+ IdentityEventRequest,
+ IdentityEvidenceLocator,
+ IdentityEvidenceRecordKind,
+ prepare_identity_event,
+)
+from pullbox.core.metadata_identity_state import (
+ IdentityVerificationAction,
+ IdentityVerificationState,
+)
+from pullbox.models.import_job import ImportJobAction
+from pullbox.models.metadata_identity import StoryArcIdentityEvent
+from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity, StoryArcSourceKind
+from pullbox.services.metadata_identity_attachment import attach_verified_identities
+
+if TYPE_CHECKING:
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ from pullbox.models.import_job import ImportJob
+ from pullbox.models.story_arc_import import ImportedStoryArc
+ from pullbox.services.import_job_execution_types import RecordActionFunc
+
+IDENTITY_VERIFIED_ACTION = "story_arc_identity_verified"
+
+
+def _json(value: object) -> str:
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
+
+
+def _digest(value: object) -> str:
+ return hashlib.sha256(_json(value).encode("ascii")).hexdigest()
+
+
+def identity_state(identity: StoryArcExternalIdentity) -> dict[str, Any]:
+ """Persist a complete proof snapshot, including the monotonic revision."""
+ return {
+ "story_arc_id": identity.story_arc_id,
+ "source": identity.source,
+ "namespace": identity.namespace,
+ "external_id": identity.external_id,
+ "source_url": identity.source_url,
+ "evidence": identity.evidence,
+ "verification_state": identity.verification_state,
+ "evidence_kind": identity.evidence_kind,
+ "evidence_locator": identity.evidence_locator,
+ "verified_at": identity.verified_at.isoformat() if identity.verified_at else None,
+ "last_seen_at": identity.last_seen_at.isoformat() if identity.last_seen_at else None,
+ "created_at": identity.created_at.isoformat(),
+ "revision": identity.revision,
+ }
+
+
+async def _latest_event(
+ session: AsyncSession, arc_id: int, source: str
+) -> StoryArcIdentityEvent | None:
+ result: StoryArcIdentityEvent | None = await session.scalar(
+ select(StoryArcIdentityEvent)
+ .where(
+ StoryArcIdentityEvent.story_arc_id == arc_id,
+ StoryArcIdentityEvent.identity_namespace == source,
+ )
+ .order_by(StoryArcIdentityEvent.id.desc())
+ .limit(1)
+ .execution_options(populate_existing=True)
+ )
+ return result
+
+
+async def attach_import_arc_identity(
+ session: AsyncSession,
+ *,
+ arc: StoryArc,
+ staged: ImportedStoryArc,
+ external_id: str,
+ evidence_revision: str,
+ job: ImportJob,
+ record_action: RecordActionFunc | None,
+) -> str:
+ """Attach exact saved evidence without treating a source key as a provider ID."""
+ # The materializer owns an outer per-arc savepoint and locks existing arcs.
+ await session.flush()
+ await session.refresh(arc)
+ owner = await session.scalar(
+ select(StoryArcExternalIdentity)
+ .where(
+ StoryArcExternalIdentity.story_arc_id == arc.id,
+ StoryArcExternalIdentity.source == "comicvine",
+ StoryArcExternalIdentity.namespace == "story_arc",
+ )
+ .execution_options(populate_existing=True)
+ )
+ before = identity_state(owner) if owner is not None else None
+ cv_before = arc.comicvine_id
+ prior_event = await _latest_event(session, arc.id, "comicvine")
+ request = IdentityEventRequest(
+ uuid5(NAMESPACE_URL, f"pullbox:import-story-arc:{job.id}:{staged.id}"),
+ arc.id,
+ IdentityVerificationAction.VERIFY,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(
+ ExternalIdentityRef(
+ IdentityNamespace.COMICVINE, MetadataEntityKind.STORY_ARC, external_id
+ ),
+ IdentityEvidenceKind.MYLAR_DATABASE
+ if staged.source_kind is StoryArcSourceKind.MYLAR3
+ else IdentityEvidenceKind.MIGRATION,
+ ),
+ evidence_revision,
+ locator=IdentityEvidenceLocator(
+ IdentityEvidenceRecordKind.IMPORTED_STORY_ARC, staged.id
+ ),
+ ),
+ )
+ receipt = (
+ await attach_verified_identities(session, [request], require_current_ownership=True)
+ )[0]
+ await session.refresh(arc)
+ owner = await session.scalar(
+ select(StoryArcExternalIdentity)
+ .where(
+ StoryArcExternalIdentity.story_arc_id == arc.id,
+ StoryArcExternalIdentity.source == "comicvine",
+ StoryArcExternalIdentity.namespace == "story_arc",
+ )
+ .execution_options(populate_existing=True)
+ )
+ assert owner is not None
+ if not receipt.replayed and record_action is not None:
+ await record_action(
+ session,
+ job,
+ phase="story_arcs",
+ action_type=IDENTITY_VERIFIED_ACTION,
+ payload={
+ "schema_version": 1,
+ "story_arc_id": arc.id,
+ "imported_story_arc_id": staged.id,
+ "external_identity_id": owner.id,
+ "identity_event_id": receipt.event_id,
+ "previous_event_id": prior_event.id if prior_event else None,
+ "restore_before": before,
+ "expected_after": identity_state(owner),
+ "comicvine_id_before": cv_before,
+ "comicvine_id_after": arc.comicvine_id,
+ },
+ )
+ return "created" if before is None else "reused"
+
+
+def _rollback_request(
+ action: ImportJobAction, restored_revision: int | None
+) -> tuple[str, str, str]:
+ payload = action.payload
+ expected = payload["expected_after"]
+ prepared = prepare_identity_event(
+ IdentityEventRequest(
+ uuid5(
+ NAMESPACE_URL,
+ f"pullbox:undo-arc-identity:{action.id}:{payload['identity_event_id']}",
+ ),
+ payload["story_arc_id"],
+ IdentityVerificationAction.OBSERVE,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(
+ ExternalIdentityRef(
+ IdentityNamespace(expected["source"]),
+ MetadataEntityKind.STORY_ARC,
+ expected["external_id"],
+ ),
+ IdentityEvidenceKind.MIGRATION,
+ ),
+ _digest(payload),
+ locator=IdentityEvidenceLocator(
+ IdentityEvidenceRecordKind.IMPORT_JOB_ACTION, action.id
+ ),
+ ),
+ actor=IdentityEventActor.MIGRATION,
+ )
+ )
+ # Rollback observes the restored state; it is not a new verification or a
+ # rejection. This adapter extension records the revision without rewinding it.
+ request = json.loads(prepared.request_json)
+ request["import_rollback"] = {
+ "schema_version": 1,
+ "restored_revision": restored_revision,
+ "restored_event_id": payload["previous_event_id"],
+ }
+ return prepared.event_key, _digest(request), _json(request)
+
+
+async def _rollback_chain_revision(
+ session: AsyncSession, action: ImportJobAction, latest: StoryArcIdentityEvent | None
+) -> int | None:
+ """Recognize only a later undo from this job which restored this exact proof."""
+ if latest is None:
+ return None
+ request = json.loads(latest.request_json)
+ origin = request.get("origin", {})
+ restored = request.get("import_rollback", {})
+ if (
+ origin.get("record_kind") != "import_job_action"
+ or restored.get("restored_event_id") != action.payload["identity_event_id"]
+ or type(restored.get("restored_revision")) is not int
+ ):
+ return None
+ previous = await session.get(ImportJobAction, origin.get("record_id"))
+ if (
+ previous is None
+ or previous.action_type != IDENTITY_VERIFIED_ACTION
+ or previous.import_job_id != action.import_job_id
+ or previous.sequence_no <= action.sequence_no
+ or previous.payload["story_arc_id"] != action.payload["story_arc_id"]
+ or latest.identity_namespace != action.payload["expected_after"]["source"]
+ or latest.external_id != action.payload["expected_after"]["external_id"]
+ or latest.verification_state != action.payload["expected_after"]["verification_state"]
+ ):
+ return None
+ revision = int(restored["restored_revision"])
+ if (latest.event_key, latest.request_fingerprint, latest.request_json) != _rollback_request(
+ previous, revision
+ ):
+ return None
+ return revision
+
+
+async def rollback_import_arc_identity(session: AsyncSession, action: ImportJobAction) -> None:
+ """Reverse only the persisted action's still-owned mutation; never erase history.
+
+ The journal dispatcher validates the saved payload and staging/job ownership.
+ Parent locking serializes this path with attachment/catalog mutations.
+ """
+ payload = action.payload
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(update(StoryArc).where(false()).values(revision=StoryArc.revision))
+ arc = await session.scalar(
+ select(StoryArc)
+ .where(StoryArc.id == payload["story_arc_id"])
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if arc is None:
+ return
+ event_key, _, _ = _rollback_request(action, None)
+ previous_undo = await session.scalar(
+ select(StoryArcIdentityEvent).where(
+ StoryArcIdentityEvent.story_arc_id == arc.id,
+ StoryArcIdentityEvent.event_key == event_key,
+ )
+ )
+ if previous_undo is not None:
+ revision = (
+ json.loads(previous_undo.request_json)
+ .get("import_rollback", {})
+ .get("restored_revision")
+ )
+ if (
+ previous_undo.event_key,
+ previous_undo.request_fingerprint,
+ previous_undo.request_json,
+ ) != _rollback_request(action, revision):
+ raise ValueError("Story-arc identity rollback receipt changed after import")
+ return
+ owner = await session.scalar(
+ select(StoryArcExternalIdentity)
+ .where(StoryArcExternalIdentity.id == payload["external_identity_id"])
+ .execution_options(populate_existing=True)
+ )
+ expected = dict(payload["expected_after"])
+ original = await session.get(
+ StoryArcIdentityEvent, payload["identity_event_id"], populate_existing=True
+ )
+ if (
+ original is None
+ or original.story_arc_id != arc.id
+ or original.identity_namespace != expected["source"]
+ or original.external_id != expected["external_id"]
+ or original.verification_state is not IdentityVerificationState.VERIFIED
+ or original.request_json != expected["evidence_locator"]
+ or original.request_fingerprint
+ != hashlib.sha256(original.request_json.encode("ascii")).hexdigest()
+ ):
+ raise ValueError("Story-arc identity history changed after import; rollback refused")
+ latest = await _latest_event(session, arc.id, expected["source"])
+ if latest is None or latest.id != payload["identity_event_id"]:
+ restored_revision = await _rollback_chain_revision(session, action, latest)
+ if restored_revision is None:
+ raise ValueError("Story-arc identity changed after import; rollback refused")
+ expected["revision"] = restored_revision
+ if (
+ owner is None
+ or identity_state(owner) != expected
+ or arc.comicvine_id != payload["comicvine_id_after"]
+ ):
+ raise ValueError("Story-arc identity changed after import; rollback refused")
+ before = payload["restore_before"]
+ revision = owner.revision + 1 if before is not None else None
+ state = IdentityVerificationState.OBSERVED
+ if before is None:
+ await session.delete(owner)
+ else:
+ for field in ("verification_state", "evidence_kind", "evidence_locator"):
+ setattr(owner, field, before[field])
+ for field in ("verified_at", "last_seen_at"):
+ setattr(owner, field, datetime.fromisoformat(before[field]) if before[field] else None)
+ assert revision is not None
+ owner.revision = revision
+ state = (
+ IdentityVerificationState(before["verification_state"])
+ if before["verification_state"]
+ else state
+ )
+ arc.comicvine_id = payload["comicvine_id_before"]
+ key, fingerprint, request_json = _rollback_request(action, revision)
+ session.add(
+ StoryArcIdentityEvent(
+ story_arc_id=arc.id,
+ identity_namespace=IdentityNamespace(expected["source"]),
+ external_id=expected["external_id"],
+ verification_state=state,
+ evidence_kind=IdentityEvidenceKind.MIGRATION,
+ event_key=key,
+ request_fingerprint=fingerprint,
+ request_json=request_json,
+ )
+ )
+ await session.flush()
+
+
+async def require_owned_arc_identity_history(
+ session: AsyncSession, *, arc_id: int, import_job_id: int
+) -> None:
+ """Deleting an import-created parent must not cascade another writer's evidence."""
+ actions = list(
+ await session.scalars(
+ select(ImportJobAction).where(
+ ImportJobAction.import_job_id == import_job_id,
+ ImportJobAction.action_type == IDENTITY_VERIFIED_ACTION,
+ ImportJobAction.payload["story_arc_id"].as_integer() == arc_id,
+ )
+ )
+ )
+ by_event = {action.payload["identity_event_id"]: action for action in actions}
+ by_undo = {_rollback_request(action, None)[0]: action for action in actions}
+ result = await session.stream_scalars(
+ select(StoryArcIdentityEvent)
+ .where(StoryArcIdentityEvent.story_arc_id == arc_id)
+ .execution_options(yield_per=200, populate_existing=True)
+ )
+ try:
+ async for event in result:
+ original = by_event.get(event.id)
+ if original is not None:
+ expected = original.payload["expected_after"]
+ if (
+ event.identity_namespace == expected["source"]
+ and event.external_id == expected["external_id"]
+ and event.verification_state is IdentityVerificationState.VERIFIED
+ and event.request_json == expected["evidence_locator"]
+ and event.request_fingerprint
+ == hashlib.sha256(event.request_json.encode("ascii")).hexdigest()
+ ):
+ continue
+ undone = by_undo.get(event.event_key)
+ if undone is not None:
+ revision = (
+ json.loads(event.request_json)
+ .get("import_rollback", {})
+ .get("restored_revision")
+ )
+ if (
+ event.event_key,
+ event.request_fingerprint,
+ event.request_json,
+ ) == _rollback_request(undone, revision):
+ continue
+ raise ValueError("Story arc has later identity history; rollback refused")
+ finally:
+ await result.close()
diff --git a/src/pullbox/services/import_story_arc_materialization.py b/src/pullbox/services/import_story_arc_materialization.py
index 14cf9b95..7ac43831 100644
--- a/src/pullbox/services/import_story_arc_materialization.py
+++ b/src/pullbox/services/import_story_arc_materialization.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import hashlib
+import json
import os
import stat
from collections.abc import Awaitable, Callable, Mapping, Sequence
@@ -10,10 +12,15 @@
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING, Literal, cast
-from sqlalchemy import func, or_, select, tuple_
+import structlog
+from sqlalchemy import false, func, inspect, or_, select, tuple_, update
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
+from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id
from pullbox.core.issue_numbers import normalize_issue_number_text
+from pullbox.core.metadata_identity_events import IdentityEventReplayConflictError
+from pullbox.core.metadata_identity_state import IdentityReviewRequiredError
from pullbox.core.story_arc_naming import (
validate_story_arc_file_template,
validate_story_arc_folder_template,
@@ -40,6 +47,8 @@
StoryArcSourceKind,
)
from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry
+from pullbox.services.import_story_arc_identity import attach_import_arc_identity
+from pullbox.services.metadata_identity_attachment import IdentityAttachmentConflictError
from pullbox.services.story_arc_service import StoryArcService, StoryArcServiceError
from pullbox.services.story_arc_sync_queue import (
MAX_IMPORT_STORY_ARC_SYNC_ENQUEUE_BATCH_SIZE,
@@ -58,6 +67,7 @@
CancellationCheck = Callable[[], Awaitable[None]]
+logger = structlog.get_logger(__name__)
DurableCheckpoint = Callable[[], Awaitable[None]]
CountField = Literal[
"arcs_created",
@@ -214,7 +224,7 @@ class _MaterializationBatch:
library_root_ids: set[int]
identity_evidence: dict[
int,
- tuple[list[tuple[str, str, str]], str | None],
+ tuple[list[tuple[str, str, str]], str | None, str],
]
@@ -462,13 +472,25 @@ async def _load_batch_external_identity_evidence(
staged_arcs: Sequence[ImportedStoryArc],
entry_page_size: int,
cancellation_check: CancellationCheck | None,
-) -> dict[int, tuple[list[tuple[str, str, str]], str | None]]:
+) -> dict[int, tuple[list[tuple[str, str, str]], str | None, str]]:
"""Scan provider-neutral entry evidence without materializing entry ORM rows."""
staged_arc_ids = [int(staged_arc.id) for staged_arc in staged_arcs]
comicvine_ids: dict[int, set[str]] = {arc_id: set() for arc_id in staged_arc_ids}
+ revisions = {
+ int(arc.id): hashlib.sha256(
+ json.dumps(
+ [arc.source_kind.value, arc.source_key, arc.source_arc_id],
+ ensure_ascii=True,
+ separators=(",", ":"),
+ ).encode("ascii")
+ )
+ for arc in staged_arcs
+ }
+ invalid_arc_ids: set[int] = set()
row_count = 0
result = await session.stream(
select(
+ ImportedStoryArcEntry.id,
ImportedStoryArcEntry.imported_story_arc_id,
ImportedStoryArcEntry.evidence,
)
@@ -480,29 +502,46 @@ async def _load_batch_external_identity_evidence(
.execution_options(yield_per=entry_page_size)
)
try:
- async for imported_story_arc_id, evidence in result:
+ async for entry_id, imported_story_arc_id, evidence in result:
+ revisions[int(imported_story_arc_id)].update(
+ json.dumps(
+ [entry_id, evidence], sort_keys=True, ensure_ascii=True, separators=(",", ":")
+ ).encode("ascii")
+ )
values = comicvine_ids[int(imported_story_arc_id)]
cv_arc_id = _mapping(evidence).get("cv_arc_id")
- if cv_arc_id is not None and str(cv_arc_id).strip() and len(values) < 2:
- values.add(str(cv_arc_id))
+ if cv_arc_id is not None:
+ try:
+ normalized = normalize_comicvine_arc_id(cv_arc_id)
+ except ValueError:
+ invalid_arc_ids.add(int(imported_story_arc_id))
+ else:
+ if len(values) < 2:
+ values.add(normalized)
row_count += 1
if row_count % entry_page_size == 0:
await _checkpoint(cancellation_check)
finally:
await result.close()
- loaded: dict[int, tuple[list[tuple[str, str, str]], str | None]] = {}
+ loaded: dict[int, tuple[list[tuple[str, str, str]], str | None, str]] = {}
for staged_arc in staged_arcs:
identities: list[tuple[str, str, str]] = []
if staged_arc.source_arc_id:
identities.append((staged_arc.source_kind.value, "story_arc", staged_arc.source_arc_id))
values = comicvine_ids[int(staged_arc.id)]
warning_code = None
- if len(values) == 1:
+ if int(staged_arc.id) in invalid_arc_ids:
+ warning_code = "invalid_external_identity_evidence"
+ elif len(values) == 1:
identities.append(("comicvine", "story_arc", next(iter(values))))
elif len(values) > 1:
warning_code = "conflicting_external_identity_evidence"
- loaded[int(staged_arc.id)] = (identities, warning_code)
+ loaded[int(staged_arc.id)] = (
+ identities,
+ warning_code,
+ revisions[int(staged_arc.id)].hexdigest(),
+ )
return loaded
@@ -514,7 +553,7 @@ async def _prepare_materialization_batch(
state: _MaterializationState,
identity_evidence: dict[
int,
- tuple[list[tuple[str, str, str]], str | None],
+ tuple[list[tuple[str, str, str]], str | None, str],
],
) -> _MaterializationBatch:
"""Prefetch all canonical lookups needed by one bounded staging page."""
@@ -524,7 +563,7 @@ async def _prepare_materialization_batch(
library_root_ids: set[int] = set()
for staged_arc in staged_arcs:
- identities, _warning = identity_evidence[int(staged_arc.id)]
+ identities, _warning, _revision = identity_evidence[int(staged_arc.id)]
identity_keys.update(identities)
if staged_arc.materialized_story_arc_id is not None:
target_arc_ids.add(int(staged_arc.materialized_story_arc_id))
@@ -580,7 +619,15 @@ async def _prepare_materialization_batch(
missing_arc_ids = target_arc_ids - state.arcs_by_id.keys()
if missing_arc_ids:
arcs = list(
- (await session.scalars(select(StoryArc).where(StoryArc.id.in_(missing_arc_ids)))).all()
+ (
+ await session.scalars(
+ select(StoryArc)
+ .where(StoryArc.id.in_(missing_arc_ids))
+ .order_by(StoryArc.id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ ).all()
)
for arc in arcs:
state.arcs_by_id[int(arc.id)] = arc
@@ -619,6 +666,91 @@ async def _materialize_one_arc(
warnings: list[StoryArcMaterializationWarning],
record_action: RecordActionFunc | None,
batch: _MaterializationBatch,
+) -> _ArcMaterializationContext | None:
+ """Keep each arc, policy, identity, and journal mutation atomic on conflict."""
+ state = batch.state
+ arc_id = int(staged_arc.id)
+ identities, _, _ = batch.identity_evidence[arc_id]
+ cached_arcs = dict(state.arcs_by_id) # Bounded to the current staging page.
+ cached_identities = {key: state.identities_by_key.get(key) for key in identities}
+ loaded_keys = {key for key in identities if key in state.loaded_identity_keys}
+ source_key = (staged_arc.source_kind, staged_arc.source_key)
+ recovered = state.job_arc_ids_by_import_identity.get(source_key)
+ trial_counts = _MutableCounts()
+ trial_warnings: list[StoryArcMaterializationWarning] = []
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(update(StoryArc).where(false()).values(revision=StoryArc.revision))
+ try:
+ async with session.begin_nested():
+ context = await _materialize_one_arc_in_transaction(
+ session,
+ import_job_id=import_job_id,
+ job=job,
+ staged_arc=staged_arc,
+ counts=trial_counts,
+ warnings=trial_warnings,
+ record_action=record_action,
+ batch=batch,
+ )
+ for field, amount in _count_snapshot(trial_counts).items():
+ setattr(counts, field, getattr(counts, field) + amount)
+ warnings.extend(trial_warnings)
+ return context
+ except (
+ IdentityAttachmentConflictError,
+ IdentityReviewRequiredError,
+ IdentityEventReplayConflictError,
+ IntegrityError,
+ ) as exc:
+ # SAVEPOINT rollback expires touched ORM rows. Refresh only this bounded
+ # page, and restore only this arc's cache keys, not the whole job index.
+ state.arcs_by_id = cached_arcs
+ logger.warning(
+ "import_story_arc_identity_review_required",
+ import_job_id=import_job_id,
+ imported_story_arc_id=arc_id,
+ error_type=type(exc).__name__,
+ )
+ for arc in cached_arcs.values():
+ if inspect(arc).expired:
+ await session.refresh(arc)
+ for key, owner in cached_identities.items():
+ if owner is None:
+ state.identities_by_key.pop(key, None)
+ else:
+ state.identities_by_key[key] = owner
+ if key not in loaded_keys:
+ state.loaded_identity_keys.discard(key)
+ if recovered is None:
+ state.job_arc_ids_by_import_identity.pop(source_key, None)
+ else:
+ state.job_arc_ids_by_import_identity[source_key] = recovered
+ await session.refresh(staged_arc)
+ if inspect(job).expired:
+ await session.refresh(job)
+ staged_arc.status = ImportedStoryArcStatus.FAILED
+ counts.arcs_failed += 1
+ _warn(warnings, "external_identity_requires_explicit_merge_review", staged_arc)
+ _persist_arc_materialization_diagnostics(
+ staged_arc,
+ story_arc_id=None,
+ status="failed",
+ counts=_count_snapshot(_MutableCounts(arcs_failed=1)),
+ warning_codes=["external_identity_requires_explicit_merge_review"],
+ )
+ return None
+
+
+async def _materialize_one_arc_in_transaction(
+ session: AsyncSession,
+ *,
+ import_job_id: int,
+ job: ImportJob,
+ staged_arc: ImportedStoryArc,
+ counts: _MutableCounts,
+ warnings: list[StoryArcMaterializationWarning],
+ record_action: RecordActionFunc | None,
+ batch: _MaterializationBatch,
) -> _ArcMaterializationContext | None:
arc_warning_start = len(warnings)
arc_counts = _MutableCounts()
@@ -626,7 +758,7 @@ async def _materialize_one_arc(
if policy.warning_code is not None:
_warn(warnings, policy.warning_code, staged_arc)
- identities, identity_warning = batch.identity_evidence[int(staged_arc.id)]
+ identities, identity_warning, evidence_revision = batch.identity_evidence[int(staged_arc.id)]
if identity_warning is not None:
_warn(warnings, identity_warning, staged_arc)
_increment_counts(counts, arc_counts, "arcs_failed")
@@ -707,6 +839,26 @@ async def _materialize_one_arc(
)
for identity in identities:
+ if identity[0] == "comicvine":
+ outcome = await attach_import_arc_identity(
+ session,
+ arc=arc,
+ staged=staged_arc,
+ external_id=identity[2],
+ evidence_revision=evidence_revision,
+ job=job,
+ record_action=record_action,
+ )
+ batch.state.identities_by_key[identity] = int(arc.id)
+ batch.state.loaded_identity_keys.add(identity)
+ _increment_counts(
+ counts,
+ arc_counts,
+ "external_identities_created"
+ if outcome == "created"
+ else "external_identities_reused",
+ )
+ continue
identity_outcome, materialized_identity = await _materialize_external_identity(
session,
arc=arc,
@@ -732,7 +884,7 @@ async def _materialize_one_arc(
elif identity_outcome == "reused":
_increment_counts(counts, arc_counts, "external_identities_reused")
else:
- _warn(warnings, "external_identity_conflict", staged_arc)
+ raise IdentityAttachmentConflictError("Scoped import identity changed owner")
return _ArcMaterializationContext(
staged_arc=staged_arc,
diff --git a/src/pullbox/services/import_story_arc_staging.py b/src/pullbox/services/import_story_arc_staging.py
index 4fffa8d7..6b6aa493 100644
--- a/src/pullbox/services/import_story_arc_staging.py
+++ b/src/pullbox/services/import_story_arc_staging.py
@@ -6,12 +6,14 @@
import json
import re
from collections.abc import Awaitable, Callable
+from contextlib import suppress
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import TYPE_CHECKING, cast
from sqlalchemy import select
+from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id
from pullbox.core.mylar_story_arc_policy import build_mylar_story_arc_policy_draft
from pullbox.models.import_job import ImportedFile, ImportedFileStatus
from pullbox.models.story_arc import (
@@ -791,6 +793,9 @@ def _mylar_external_identities(
external_id = _bounded_identifier(source_arc.cv_arc_id, 255)
if external_id is None:
return []
+ # Retain invalid source evidence for review; materialization refuses it.
+ with suppress(ValueError):
+ external_id = normalize_comicvine_arc_id(external_id)
return [
{
"source": "comicvine",
diff --git a/src/pullbox/services/issue_file_metadata.py b/src/pullbox/services/issue_file_metadata.py
new file mode 100644
index 00000000..2c022f7b
--- /dev/null
+++ b/src/pullbox/services/issue_file_metadata.py
@@ -0,0 +1,383 @@
+"""Read-only approval and journaled writing of existing managed CBZ metadata."""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+from xml.etree import ElementTree as ET
+from zipfile import BadZipFile, ZipFile
+
+from pydantic import TypeAdapter
+from sqlalchemy import select
+
+from pullbox.core.archive import ArchiveError
+from pullbox.core.archive_metadata import (
+ MAX_METADATA_BYTES,
+ ArchiveMetadataFiles,
+ read_archive_metadata,
+)
+from pullbox.models import LibraryFile
+from pullbox.models.archive_metadata_publication import PublicationState
+from pullbox.models.import_job import ImportedFile, ImportJob, ImportJobStatus
+from pullbox.schemas.issue_file_metadata import (
+ FileMetadataChange,
+ FileMetadataChoices,
+ FileMetadataPreview,
+)
+from pullbox.services.archive_metadata_binding import (
+ ArchiveMetadataBindingError,
+ ArchiveMetadataTarget,
+ archive_primary_identity,
+ assemble_bound_archive_metadata,
+ inspect_archive_metadata_target,
+ read_archive_metadata_binding,
+)
+from pullbox.services.archive_metadata_finalization import finalize_archive_publication
+from pullbox.services.archive_metadata_publication import (
+ ArchivePublicationError,
+ inspect_archive_publication,
+ load_archive_publication,
+ prepare_archive_publication,
+ publish_archive_publication,
+ reconcile_archive_publication,
+ record_archive_publication,
+)
+from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata
+from pullbox.services.archive_metadata_rendering import (
+ ArchiveMetadataRenderError,
+ render_archive_metadata,
+)
+from pullbox.services.issue_file_metadata_review import review_metadata_fields
+from pullbox.services.library_mutation_coordination import lock_file_mutation_admission
+from pullbox.utilities.executors.archive_metadata_staging import stage_cbz_metadata_interruptible
+from pullbox.utilities.import_guards import ensure_no_active_import_file_mutation
+from pullbox.utilities.job_queue_cancellation import drain_task
+
+if TYPE_CHECKING:
+ from uuid import UUID
+
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+ from pullbox.core.metadata_identity import ExternalIdentityRef
+ from pullbox.schemas.metadata_snapshot import MetadataSnapshot
+ from pullbox.utilities.executors.archive_subprocess import ControlCheck, ProgressCallback
+
+
+def file_metadata_error(exc: BaseException) -> str:
+ code = getattr(exc, "code", "")
+ if code == "unresolved_conflicts":
+ return "Choose a value for each conflicting field and update the preview before writing."
+ if code in {"invalid_choice", "choices_changed"}:
+ return "The available metadata choices changed. Preview again and review each field."
+ if code == "archive_unreadable":
+ return "This comic archive cannot be read. Replace or repair it, then preview again."
+ if code == "unreconciled_field":
+ field = getattr(exc, "field", "metadata")
+ label = (
+ field.replace("_", " ") if field in {"title", "issue_count", "credits"} else "metadata"
+ )
+ return (
+ f"File and library metadata disagree about {label}. "
+ "The file was left unchanged; review these existing values before writing."
+ )
+ if code == "reference_only":
+ return (
+ "Files kept in place are not modified. Use a separate managed copy to write metadata."
+ )
+ if code in {"root_not_managed", "readonly_source"}:
+ return "This library location is read-only. Choose a writable managed library copy."
+ if code == "import_rollback_protected":
+ return (
+ "This file is protected by import rollback history. "
+ "Its original bytes were left unchanged."
+ )
+ if code in {
+ "identity_requires_review",
+ "parent_identity_missing",
+ "unverified_identity",
+ "identity_conflict",
+ }:
+ return "Review and verify the issue and series provider links before writing this file."
+ if code == "unsupported_format":
+ return "File metadata writing currently supports managed CBZ files only."
+ if code in {"target_missing", "source_unavailable"}:
+ return (
+ "The registered comic file is unavailable. Restore or reconcile it, then preview again."
+ )
+ if code in {"publication_busy", "publication_review"}:
+ return (
+ "A previous file operation needs review. "
+ "Check Utilities before writing this file again."
+ )
+ if code in {
+ "approval_changed",
+ "binding_changed",
+ "source_changed",
+ "file_changed",
+ "directories_changed",
+ }:
+ return "The file or metadata changed after preview. Preview again before writing."
+ return (
+ "The embedded metadata could not be safely reconciled. "
+ "Check the file and provider matches, then preview again."
+ )
+
+
+@dataclass(frozen=True)
+class PreparedFileMetadata:
+ target: ArchiveMetadataTarget
+ series: MetadataSnapshot
+ issue: MetadataSnapshot
+ primary: ExternalIdentityRef | None
+ preview: FileMetadataPreview
+
+
+async def prepare_file_metadata(
+ session: AsyncSession, issue_id: int, *, choices: FileMetadataChoices | None = None
+) -> PreparedFileMetadata:
+ """Release the clean read transaction before archive I/O; never fetch providers."""
+ if session.new or session.dirty or session.deleted:
+ raise ArchiveMetadataBindingError("pending_session_changes")
+ ids = list(
+ await session.scalars(
+ select(LibraryFile.id).where(LibraryFile.issue_id == issue_id).limit(2)
+ )
+ )
+ if len(ids) != 1:
+ raise ArchiveMetadataBindingError("target_missing")
+ binding = await read_archive_metadata_binding(session, ids[0], expected_issue_id=issue_id)
+ await _require_no_import_owner(session, ids[0])
+ await session.commit()
+ target = await inspect_archive_metadata_target(binding)
+ try:
+ files = await asyncio.to_thread(
+ read_archive_metadata, target.path, "cbz", max_solid_scan_bytes=MAX_METADATA_BYTES
+ )
+ prepared = await asyncio.to_thread(_prepare, target, files, choices or {})
+ except (ArchiveError, BadZipFile) as exc:
+ raise ArchiveMetadataBindingError("archive_unreadable") from exc
+ await asyncio.to_thread(target.check_unchanged)
+ return prepared
+
+
+async def _require_no_import_owner(session: AsyncSession, file_id: int) -> None:
+ protected = await session.scalar(
+ select(ImportedFile.id)
+ .join(ImportJob)
+ .where(
+ ImportedFile.library_file_id == file_id,
+ ImportJob.status != ImportJobStatus.ROLLED_BACK,
+ )
+ .limit(1)
+ )
+ if protected:
+ raise ArchiveMetadataBindingError("import_rollback_protected")
+
+
+def _fields(payload: bytes | None) -> dict[str, str]:
+ if payload is None:
+ return {}
+ # Rendering validated the bounded XML before this display-only projection.
+ root = ET.fromstring(payload)
+ result: dict[str, str] = {}
+
+ def visit(node: ET.Element, path: str) -> None:
+ if node.text and node.text.strip():
+ result[path] = node.text.strip()
+ for key, value in sorted(node.attrib.items()):
+ result[path + "/@" + key] = value
+ counts: dict[str, int] = {}
+ for child in node:
+ counts[child.tag] = counts.get(child.tag, 0) + 1
+ visit(child, f"{path}/{child.tag}[{counts[child.tag]}]")
+
+ visit(root, root.tag)
+ return result
+
+
+def _prepare(
+ target: ArchiveMetadataTarget, files: ArchiveMetadataFiles, choices: FileMetadataChoices
+) -> PreparedFileMetadata:
+ binding = target.binding
+ archive = reconcile_archive_metadata(files)
+ series, issue = assemble_bound_archive_metadata(binding, archive, now=datetime.now(UTC))
+ primary = archive_primary_identity(binding, archive)
+ series, issue, conflicts = review_metadata_fields(binding, archive, series, issue, choices)
+ ready = all(item.selected is not None for item in conflicts)
+ evidence = TypeAdapter(ArchiveMetadataTarget).dump_json(target)
+ evidence += json.dumps(choices, sort_keys=True, separators=(",", ":")).encode()
+ try:
+ rendered = render_archive_metadata(
+ series,
+ issue,
+ files,
+ primary_identity=primary,
+ previous_series=binding.metadata.series.baseline,
+ previous_issue=binding.metadata.issues[0].baseline,
+ )
+ except ArchiveMetadataRenderError as exc:
+ # Only descriptive disagreements enter review; identity/schema guards still fail closed.
+ if (
+ ready
+ or exc.code != "unreconciled_field"
+ or not any(
+ item.key.split(".")[1] == exc.field for item in conflicts if item.selected is None
+ )
+ ):
+ raise
+ rendered = None
+ if not ready:
+ return PreparedFileMetadata(
+ target,
+ series,
+ issue,
+ primary,
+ FileMetadataPreview(
+ file_id=binding.library_file_id,
+ file_name=target.path.name,
+ changes=[],
+ review_key=hashlib.sha256(
+ evidence + (files.comicinfo.payload or b"") + (files.metroninfo.payload or b"")
+ ).hexdigest(),
+ unchanged=False,
+ ready=False,
+ conflicts=conflicts,
+ ),
+ )
+ assert rendered is not None
+ changes = []
+ pairs = (
+ ("ComicInfo.xml", files.comicinfo.payload, rendered.comicinfo),
+ ("MetronInfo.xml", files.metroninfo.payload, rendered.metroninfo),
+ )
+ for name, previous, output in pairs:
+ old, new = _fields(previous), _fields(output)
+ for field in sorted(old.keys() | new.keys()):
+ if old.get(field) != new.get(field):
+ changes.append(
+ FileMetadataChange(
+ document=name,
+ field=field,
+ before=old[field][:500] if field in old else None,
+ after=new[field][:500] if field in new else None,
+ )
+ )
+ if previous is None:
+ changes.insert(
+ 0,
+ FileMetadataChange(
+ document=name, field="Document", before=None, after="Add reconciled metadata"
+ ),
+ )
+ review_key = hashlib.sha256(evidence + rendered.comicinfo + rendered.metroninfo).hexdigest()
+ with ZipFile(target.path) as archive_file:
+ canonical_names = {"ComicInfo.xml", "MetronInfo.xml"} <= set(archive_file.namelist())
+ unchanged = canonical_names and all(old == new for _, old, new in pairs)
+ return PreparedFileMetadata(
+ target,
+ series,
+ issue,
+ primary,
+ FileMetadataPreview(
+ file_id=binding.library_file_id,
+ file_name=target.path.name,
+ changes=changes[:200],
+ review_key=review_key,
+ unchanged=unchanged,
+ conflicts=conflicts,
+ ),
+ )
+
+
+async def recover_file_metadata(
+ factory: async_sessionmaker[AsyncSession], operation: UUID
+) -> PublicationState | None:
+ """Classify one owned receipt; never publish or remove files during recovery."""
+ async with factory() as session:
+ receipt = await load_archive_publication(session, operation)
+ if receipt is None:
+ return None
+ if receipt.plan.import_owner is not None:
+ raise ArchivePublicationError("publication_review")
+ if receipt.state in {PublicationState.FINALIZED, PublicationState.ABANDONED}:
+ return receipt.state
+ inspection = await inspect_archive_publication(receipt)
+ async with factory.begin() as session:
+ receipt = await reconcile_archive_publication(session, receipt, inspection)
+ if receipt.state is PublicationState.PUBLISHED:
+ inspection = await inspect_archive_publication(receipt)
+ async with factory.begin() as session:
+ receipt = await finalize_archive_publication(session, receipt, inspection)
+ if receipt.state is PublicationState.REVIEW:
+ raise ArchivePublicationError("publication_review")
+ return receipt.state
+
+
+async def write_file_metadata(
+ factory: async_sessionmaker[AsyncSession],
+ issue_id: int,
+ review_key: str,
+ operation: UUID,
+ *,
+ limit: int,
+ check_control: ControlCheck,
+ progress: ProgressCallback,
+ job_id: str | None = None,
+ choices: FileMetadataChoices | None = None,
+) -> str:
+ """Approval is checked again before staging and under the publication lock."""
+ recovered = await recover_file_metadata(factory, operation)
+ if recovered is PublicationState.FINALIZED:
+ return "recovered"
+ if recovered is not None:
+ raise ArchivePublicationError("approval_changed")
+ await check_control()
+ async with factory() as session:
+ prepared = await prepare_file_metadata(session, issue_id, choices=choices)
+ if not prepared.preview.ready:
+ raise ArchivePublicationError("unresolved_conflicts")
+ if prepared.preview.review_key != review_key:
+ raise ArchivePublicationError("approval_changed")
+ if prepared.preview.unchanged:
+ return "unchanged"
+ target, series, issue = prepared.target, prepared.series, prepared.issue
+ try:
+ async with stage_cbz_metadata_interruptible(
+ target.path,
+ target.path.parent,
+ series,
+ issue,
+ max_uncompressed_bytes=limit,
+ primary_identity=prepared.primary,
+ previous_series=target.binding.metadata.series.baseline,
+ previous_issue=target.binding.metadata.issues[0].baseline,
+ cancellation_check=check_control,
+ progress_callback=progress,
+ ) as staged:
+ plan = await prepare_archive_publication(target, staged, series, issue)
+ if job_id is not None:
+ plan = plan.model_copy(update={"metadata_job_id": job_id})
+ await check_control()
+ async with factory.begin() as session:
+ await lock_file_mutation_admission(session)
+ await ensure_no_active_import_file_mutation(session)
+ await _require_no_import_owner(session, target.binding.library_file_id)
+ await record_archive_publication(session, plan, operation)
+ await check_control()
+ async with factory.begin() as session:
+ receipt = await publish_archive_publication(session, operation)
+ # Once the replacement is committed, finish accounting even if cancel arrives.
+ inspection = await inspect_archive_publication(receipt)
+ async with factory.begin() as session:
+ await finalize_archive_publication(session, receipt, inspection)
+ return "written"
+ except BaseException:
+ # The owned intent is settled before cancellation releases the job lane.
+ task = asyncio.create_task(recover_file_metadata(factory, operation))
+ if await drain_task(task) is PublicationState.FINALIZED:
+ return "recovered"
+ raise
diff --git a/src/pullbox/services/issue_file_metadata_recovery.py b/src/pullbox/services/issue_file_metadata_recovery.py
new file mode 100644
index 00000000..16e56dc7
--- /dev/null
+++ b/src/pullbox/services/issue_file_metadata_recovery.py
@@ -0,0 +1,97 @@
+"""Settle only journaled existing-file metadata writes before utility recovery."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+import structlog
+from sqlalchemy import func, select
+
+from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState
+from pullbox.services.archive_metadata_publication import load_archive_publication
+from pullbox.services.issue_file_metadata import file_metadata_error, recover_file_metadata
+from pullbox.services.utility_operation_progress import project_utility_operation_progress
+from pullbox.utilities.models import ItemState, JobState, JobType, UtilityJob, UtilityJobItem
+
+if TYPE_CHECKING:
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+logger = structlog.get_logger(__name__)
+
+
+async def recover_file_metadata_jobs(factory: async_sessionmaker[AsyncSession]) -> None:
+ cursor = 0
+ while True:
+ async with factory() as session:
+ rows = list(
+ (
+ await session.execute(
+ select(
+ ArchiveMetadataPublication.id,
+ ArchiveMetadataPublication.operation_id,
+ )
+ .where(
+ ArchiveMetadataPublication.id > cursor,
+ ArchiveMetadataPublication.active_path_key.is_not(None),
+ )
+ .order_by(ArchiveMetadataPublication.id)
+ .limit(100)
+ )
+ ).all()
+ )
+ if not rows:
+ return
+ for row_id, operation_id in rows:
+ cursor = row_id
+ async with factory() as session:
+ receipt = await load_archive_publication(session, UUID(operation_id))
+ if receipt is None or receipt.plan.metadata_job_id is None:
+ continue
+ try:
+ state = await recover_file_metadata(factory, receipt.operation_id)
+ except (ValueError, OSError) as exc:
+ logger.warning(
+ "file_metadata_recovery_needs_review",
+ operation_id=operation_id,
+ reason=getattr(exc, "code", "unavailable"),
+ )
+ async with factory.begin() as session:
+ job = await session.get(UtilityJob, receipt.plan.metadata_job_id)
+ if job is not None and job.job_type == JobType.FILE_METADATA:
+ job.state = JobState.FAILED
+ job.error_message = file_metadata_error(exc)
+ await project_utility_operation_progress(session, job)
+ continue
+ if state is not PublicationState.FINALIZED:
+ continue
+ async with factory.begin() as session:
+ item = await session.get(UtilityJobItem, receipt.operation_id.hex)
+ job = await session.get(UtilityJob, receipt.plan.metadata_job_id)
+ if (
+ item is None
+ or job is None
+ or item.job_id != job.id
+ or job.job_type != JobType.FILE_METADATA
+ ):
+ continue
+ item.state = ItemState.COMPLETED
+ item.error_message = None
+ item.completed_at = datetime.now(UTC).isoformat()
+ item.after_state = '{"outcome":"recovered"}'
+ job.completed_items = await session.scalar(
+ select(func.count())
+ .select_from(UtilityJobItem)
+ .where(
+ UtilityJobItem.job_id == job.id, UtilityJobItem.state == ItemState.COMPLETED
+ )
+ )
+ if job.completed_items == job.total_items and job.state not in {
+ JobState.CANCELLING,
+ JobState.CANCELLED,
+ }:
+ job.state = JobState.COMPLETED
+ job.completed_at = item.completed_at
+ job.error_message = None
+ await project_utility_operation_progress(session, job)
diff --git a/src/pullbox/services/issue_file_metadata_review.py b/src/pullbox/services/issue_file_metadata_review.py
new file mode 100644
index 00000000..c9081936
--- /dev/null
+++ b/src/pullbox/services/issue_file_metadata_review.py
@@ -0,0 +1,171 @@
+"""Explicit descriptive choices from inspected values, never identity overrides."""
+
+from datetime import UTC, date, datetime
+from typing import cast
+
+from pullbox.core.archive_metadata_fields import canonical_archive_format, comicinfo_credits
+from pullbox.core.metadata_identity import MetadataEntityKind
+from pullbox.schemas.issue_file_metadata import (
+ FileMetadataChoices,
+ FileMetadataConflict,
+ FileMetadataField,
+ FileMetadataOption,
+ FileMetadataSource,
+)
+from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, field_domain
+from pullbox.services.archive_metadata_binding import ArchiveMetadataBinding
+from pullbox.services.archive_metadata_preservation import ArchiveMetadataRenderError
+from pullbox.services.archive_metadata_reconciliation import ArchiveMetadataReconciliation
+from pullbox.services.archive_metadata_rendering import _series_separator_key
+
+_FIELDS = {
+ "series": (
+ "title",
+ "sort_title",
+ "publisher",
+ "language",
+ "year_start",
+ "volume",
+ "series_type",
+ "issue_count",
+ ),
+ "issue": ("title", "description", "cover_date", "store_date", "page_count", "credits"),
+}
+
+
+def _display(value: object) -> str:
+ if value is None:
+ return "Not set"
+ if isinstance(value, tuple):
+ return "\n".join(f"{item.name} ({item.role})" for item in value)
+ return value.isoformat() if isinstance(value, date) else str(value)
+
+
+def _equal(field: str, source: FileMetadataSource, old: object, new: object) -> bool:
+ if field == "series_type":
+ old = canonical_archive_format(str(old)) if old is not None else None
+ new = canonical_archive_format(str(new)) if new is not None else None
+ if field == "credits" and source == "ComicInfo.xml" and isinstance(new, tuple):
+ new = comicinfo_credits(new)
+ return old == new
+
+
+def review_metadata_fields(
+ binding: ArchiveMetadataBinding,
+ archive: ArchiveMetadataReconciliation,
+ series: MetadataSnapshot,
+ issue: MetadataSnapshot,
+ choices: FileMetadataChoices,
+) -> tuple[MetadataSnapshot, MetadataSnapshot, list[FileMetadataConflict]]:
+ """A choice names a source; its actual value comes only from this bound read."""
+ conflicts: list[FileMetadataConflict] = []
+ snapshots = {"series": series, "issue": issue}
+ seen: set[str] = set()
+ for kind, fields in _FIELDS.items():
+ current = binding.metadata.series if kind == "series" else binding.metadata.issues[0]
+ snapshot = snapshots[kind]
+ values = snapshot.values.model_dump()
+ origins = {item.field: item for item in snapshot.origins}
+ for field in fields:
+ key = cast("FileMetadataField", f"{kind}.{field}")
+ options: dict[FileMetadataSource, object] = {"library": getattr(current.values, field)}
+ for source, document in (
+ ("ComicInfo.xml", archive.comicinfo),
+ ("MetronInfo.xml", archive.metroninfo),
+ ):
+ old = getattr(getattr(document, kind), field)
+ if old is not None:
+ options[cast("FileMetadataSource", source)] = old
+ proposed = getattr(snapshot.values, field)
+ disagrees = any(
+ source != "library" and not _equal(field, source, old, proposed)
+ for source, old in options.items()
+ )
+ # Partial ComicInfo dates are evidence, but not a selectable complete date.
+ parts = archive.comicinfo.publication_date_parts
+ if kind == "issue" and field == "cover_date" and any(p is not None for p in parts):
+ new = (
+ (proposed.year, proposed.month, proposed.day)
+ if isinstance(proposed, date)
+ else (None, None, None)
+ )
+ disagrees = disagrees or (
+ proposed is not None
+ and any(
+ old is not None and old != value
+ for old, value in zip(parts, new, strict=True)
+ )
+ )
+ if (
+ kind == "series"
+ and field == "title"
+ and isinstance(proposed, str)
+ and any(item.identity in issue.identities for item in archive.evidence)
+ and all(
+ isinstance(old, str)
+ and _series_separator_key(old) == _series_separator_key(proposed)
+ for source, old in options.items()
+ if source != "library"
+ )
+ ):
+ disagrees = False
+ if not disagrees:
+ continue
+ seen.add(key)
+ selected = choices.get(key)
+ allowed = {
+ source for source in options if field != "issue_count" or source == "library"
+ }
+ if kind == "series" and field in {"title", "sort_title"}:
+ allowed = {source for source in allowed if options[source]}
+ if selected is not None and selected not in allowed:
+ raise ArchiveMetadataRenderError("invalid_choice", field)
+ display = [
+ FileMetadataOption(
+ source=source, value=_display(value), selectable=source in allowed
+ )
+ for source, value in options.items()
+ ]
+ if (
+ kind == "issue"
+ and field == "cover_date"
+ and "ComicInfo.xml" not in options
+ and any(p is not None for p in parts)
+ ):
+ display.append(
+ FileMetadataOption(
+ source="ComicInfo.xml",
+ value="-".join(str(p) if p is not None else "?" for p in parts),
+ selectable=False,
+ )
+ )
+ conflicts.append(
+ FileMetadataConflict(
+ key=key,
+ label=f"{kind.title()} {field.replace('_', ' ')}",
+ options=display,
+ selected=selected,
+ note="Issue count comes from the library catalog; file counts cannot change it."
+ if field == "issue_count"
+ else "",
+ )
+ )
+ if selected is not None:
+ values[field] = options[selected]
+ origins[field] = FieldOrigin(
+ field=field,
+ domain=field_domain(snapshot.entity_kind, field),
+ observed_at=datetime.now(UTC),
+ user_override=True,
+ )
+ snapshots[kind] = MetadataSnapshot.model_validate(
+ {
+ **snapshot.model_dump(),
+ "values": values,
+ "origins": tuple(origins.values()),
+ }
+ )
+ if set(choices) - seen:
+ raise ArchiveMetadataRenderError("choices_changed")
+ assert snapshots["series"].entity_kind is MetadataEntityKind.SERIES
+ return snapshots["series"], snapshots["issue"], conflicts
diff --git a/src/pullbox/services/issue_metadata_links.py b/src/pullbox/services/issue_metadata_links.py
new file mode 100644
index 00000000..d80db498
--- /dev/null
+++ b/src/pullbox/services/issue_metadata_links.py
@@ -0,0 +1,347 @@
+"""Reviewed issue links require a verified series parent and never touch files."""
+
+import hashlib
+import json
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from uuid import uuid4
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import NotFoundError
+from pullbox.core.issue_numbers import normalize_issue_number_text
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.core.metadata_identity_events import IdentityEventEvidence, IdentityEventRequest
+from pullbox.core.metadata_identity_state import IdentityVerificationAction as Action
+from pullbox.core.metadata_identity_state import IdentityVerificationState as State
+from pullbox.models import Issue, Series
+from pullbox.models.metadata_identity import IssueIdentityEvent
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.models.series import IssueCatalogState
+from pullbox.schemas.issue_metadata_links import (
+ IssueCandidatesQuery,
+ IssueLinkCurrent,
+ IssueLinkPreview,
+ IssueLinkQuery,
+ IssueLinkSource,
+ IssueLinksRead,
+)
+from pullbox.schemas.metadata_identity_review import IdentityReviewRead, IdentityReviewReceiptRead
+from pullbox.schemas.metadata_sources import (
+ MetadataFetch,
+ MetadataPage,
+ ProviderIssueRead,
+ SourceCapability,
+ SourceStatus,
+)
+from pullbox.schemas.series_metadata_links import SeriesLinkConfirm, SeriesLinkIdentity
+from pullbox.services.metadata_assembly import assemble_metadata
+from pullbox.services.metadata_discovery import MetadataSourceRegistry, describe_source_policies
+from pullbox.services.metadata_identity_review import (
+ apply_identity_review,
+ preview_identity_review,
+ record_identity_observation,
+)
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_series_refresh_state import (
+ ISSUE_FIELDS,
+ SeriesRefreshState,
+ read_series_refresh_state,
+)
+from pullbox.services.metadata_sources import load_source_runtime
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+from pullbox.services.series_metadata_links import SOURCE_LABELS
+
+
+@dataclass(frozen=True)
+class IssueLinkContext:
+ current: IssueLinkCurrent
+ parents: tuple[tuple[ExternalIdentityRef, State | None, int], ...]
+ other_claims: tuple[tuple[str, str, str, int], ...]
+
+
+async def _state(session: AsyncSession, issue_id: int) -> SeriesRefreshState:
+ series_id = await session.scalar(select(Issue.series_id).where(Issue.id == issue_id))
+ if series_id is None:
+ raise NotFoundError("Issue", issue_id)
+ return await read_series_refresh_state(
+ session, series_id, issue_ids=(issue_id,), allow_partial_catalog=True
+ )
+
+
+async def _context(
+ session: AsyncSession, issue_id: int, source: MetadataSource
+) -> IssueLinkContext:
+ state = await _state(session, issue_id)
+ return _context_from_state(state, source)
+
+
+def _context_from_state(state: SeriesRefreshState, source: MetadataSource) -> IssueLinkContext:
+ issue = state.issues[0]
+ claims = tuple(
+ (ref.namespace.value, ref.external_id, status.value if status else "unknown", revision)
+ for ref, status, revision in issue.claims
+ if ref.namespace is not source.identity_namespace
+ )
+ if issue.comicvine_id and source.identity_namespace is not IdentityNamespace.COMICVINE:
+ claims += (("comicvine", str(issue.comicvine_id), "legacy", 0),)
+ return IssueLinkContext(
+ IssueLinkCurrent(
+ series_id=state.series.local_id,
+ series_title=state.series.values.title or "Untitled series",
+ issue_number_text=issue.values.issue_number_text or "",
+ title=issue.values.title,
+ cover_date=issue.values.cover_date,
+ page_count=issue.values.page_count,
+ ),
+ state.series.claims,
+ claims,
+ )
+
+
+def _parent(context: IssueLinkContext, source: MetadataSource) -> ExternalIdentityRef:
+ for ref, state, _ in context.parents:
+ if ref.namespace is source.identity_namespace and state is State.VERIFIED:
+ return ref
+ raise ValueError("Link this provider to the series first, then review this issue match.")
+
+
+def _revision(context: IssueLinkContext, source: MetadataSource, revision: int) -> str:
+ # Only a review freshness digest, never authentication or password storage.
+ payload = {
+ "purpose": "issue-link-v1",
+ "current": context.current.model_dump(mode="json"),
+ "parents": [
+ (ref.namespace.value, ref.external_id, state, rev)
+ for ref, state, rev in context.parents
+ ],
+ "other_claims": context.other_claims,
+ "source": source.value,
+ "revision": revision,
+ }
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
+
+
+async def _lock(session: AsyncSession, context: IssueLinkContext, issue_id: int) -> None:
+ await session.execute(
+ select(MetadataSourceConfig)
+ .order_by(MetadataSourceConfig.source)
+ .with_for_update(read=True)
+ )
+ await session.execute(
+ select(Series.id).where(Series.id == context.current.series_id).with_for_update()
+ )
+ await session.execute(select(Issue.id).where(Issue.id == issue_id).with_for_update())
+
+
+async def _registry(session: AsyncSession, *, gcd_api_enabled: bool) -> MetadataSourceRegistry:
+ return MetadataSourceRegistry(
+ await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled),
+ gcd_api_enabled=gcd_api_enabled,
+ read_cache=source_read_cache(session),
+ )
+
+
+async def read_issue_links(
+ session: AsyncSession, issue_id: int, *, gcd_api_enabled: bool
+) -> IssueLinksRead:
+ state = await _state(session, issue_id)
+ issue = state.issues[0]
+ owned = {ref.namespace for ref, _, _ in issue.claims}
+ parents = {ref.namespace: ref for ref in state.series.identities}
+ providers = describe_source_policies(list(state.policies), gcd_api_enabled=gcd_api_enabled)
+ available = [
+ item
+ for item in providers
+ if item.enabled
+ and item.availability is None
+ and SourceCapability.ISSUE_DETAILS in item.capabilities
+ ]
+ syncing = (
+ await session.scalar(
+ select(Series.issue_catalog_state).where(Series.id == state.series.local_id)
+ )
+ is IssueCatalogState.HYDRATING
+ )
+ snapshot = assemble_metadata(
+ MetadataEntityKind.ISSUE,
+ issue.identities,
+ [],
+ list(state.policies),
+ now=datetime.now(UTC),
+ current=issue.values,
+ previous=issue.baseline,
+ fields=ISSUE_FIELDS,
+ parent_identities=state.series.identities,
+ )
+ return IssueLinksRead(
+ current=_context_from_state(state, MetadataSource.METRON_API).current,
+ identities=[
+ SeriesLinkIdentity(namespace=ref.namespace, external_id=ref.external_id, state=status)
+ for ref, status, _ in issue.claims
+ if status is not None
+ ],
+ sources=[
+ IssueLinkSource(
+ source=item.source,
+ label=SOURCE_LABELS[item.source],
+ series_external_id=parents[item.source.identity_namespace].external_id,
+ )
+ for item in available
+ if item.source.identity_namespace in parents
+ and item.source.identity_namespace not in owned
+ and SourceCapability.ISSUE_LIST in item.capabilities
+ ],
+ origins=list(snapshot.origins),
+ syncing=syncing,
+ can_refresh=not syncing
+ and any(
+ item.source.identity_namespace in {ref.namespace for ref in issue.identities}
+ and item.source.identity_namespace in parents
+ for item in available
+ ),
+ )
+
+
+async def issue_link_candidates(
+ session: AsyncSession, issue_id: int, query: IssueCandidatesQuery, *, gcd_api_enabled: bool
+) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ context = await _context(session, issue_id, query.source)
+ parent = _parent(context, query.source)
+ registry = await _registry(session, gcd_api_enabled=gcd_api_enabled)
+ revision = registry.runtime[query.source].policy.revision
+ await session.rollback()
+ result = await registry.issues(query.source, parent.external_id, page=query.page)
+ current = await _context(session, issue_id, query.source)
+ updated = await _registry(session, gcd_api_enabled=gcd_api_enabled)
+ if current != context or updated.runtime[query.source].policy.revision != revision:
+ raise ValueError("The issue or source settings changed. Load the provider issues again.")
+ return result
+
+
+async def preview_issue_link(
+ session: AsyncSession, issue_id: int, query: IssueLinkQuery, *, gcd_api_enabled: bool
+) -> IssueLinkPreview:
+ context = await _context(session, issue_id, query.source)
+ parent = _parent(context, query.source)
+ registry = await _registry(session, gcd_api_enabled=gcd_api_enabled)
+ revision = registry.runtime[query.source].policy.revision
+ await session.rollback()
+ result = await registry.issue(query.source, query.external_id)
+ if result.status is not SourceStatus.OK or result.data is None:
+ raise ValueError(
+ "This provider could not load the issue. Check Metadata settings or try again later."
+ )
+ candidate = result.data
+ if (
+ candidate.source is not query.source
+ or candidate.identity_namespace is not query.source.identity_namespace
+ or candidate.external_id != query.external_id
+ or candidate.series_external_id != parent.external_id
+ or normalize_issue_number_text(candidate.issue_number_text)
+ != context.current.issue_number_text
+ ):
+ raise ValueError(
+ "This provider issue disagrees with the series or exact issue number. "
+ "Choose another match; no links were changed."
+ )
+ if any(
+ cross.entity_kind is not MetadataEntityKind.ISSUE
+ or any(
+ namespace == cross.namespace.value and external_id != cross.external_id
+ for namespace, external_id, *_ in context.other_claims
+ )
+ for cross in candidate.cross_identities
+ ):
+ raise ValueError(
+ "This provider identifies a different issue than your existing match. "
+ "No links were changed."
+ )
+ async with metadata_write_scope(session):
+ await _lock(session, context, issue_id)
+ updated = await _registry(session, gcd_api_enabled=gcd_api_enabled)
+ if (
+ updated.runtime[query.source].policy.revision != revision
+ or updated._unavailable(query.source, capability=SourceCapability.ISSUE_DETAILS)
+ or context != await _context(session, issue_id, query.source)
+ ):
+ raise ValueError("The issue or source settings changed. Preview the match again.")
+ identity = ExternalIdentityRef(
+ query.source.identity_namespace, MetadataEntityKind.ISSUE, query.external_id
+ )
+ saved = await record_identity_observation(
+ session,
+ IdentityEventRequest(
+ uuid4(),
+ issue_id,
+ Action.OBSERVE,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(
+ identity, IdentityEvidenceKind.PROVIDER_RESULT, query.source
+ ),
+ _revision(context, query.source, revision),
+ source_identity=identity,
+ parent_identity=parent,
+ ),
+ ),
+ )
+ review = IdentityReviewRead.model_validate(
+ await preview_identity_review(
+ session, MetadataEntityKind.ISSUE, issue_id, saved.event_id
+ )
+ )
+ return IssueLinkPreview(
+ current=context.current, candidate=candidate, source_revision=revision, review=review
+ )
+
+
+async def confirm_issue_link(
+ session: AsyncSession,
+ issue_id: int,
+ body: SeriesLinkConfirm,
+ *,
+ user_id: int,
+ gcd_api_enabled: bool,
+) -> IdentityReviewReceiptRead:
+ state = await _state(session, issue_id)
+ context = _context_from_state(state, MetadataSource.METRON_API)
+ async with metadata_write_scope(session):
+ await _lock(session, context, issue_id)
+ event = await session.get(IssueIdentityEvent, body.event_id)
+ if event is None or event.issue_id != issue_id:
+ raise ValueError("This saved match no longer exists. Preview it again.")
+ try:
+ evidence = json.loads(event.request_json)
+ source = MetadataSource(evidence["origin"]["source_instance"])
+ digest = evidence["evidence_revision"]
+ except (ValueError, KeyError, TypeError) as exc:
+ raise ValueError("Preview this provider match before linking it.") from exc
+ current = await _context(session, issue_id, source)
+ registry = await _registry(session, gcd_api_enabled=gcd_api_enabled)
+ _parent(current, source)
+ if (
+ state.series.local_id != current.current.series_id
+ or registry.runtime[source].policy.revision != body.source_revision
+ or registry._unavailable(source, capability=SourceCapability.ISSUE_DETAILS)
+ or digest != _revision(current, source, body.source_revision)
+ ):
+ raise ValueError("The issue or source settings changed. Preview the match again.")
+ receipt = await apply_identity_review(
+ session,
+ MetadataEntityKind.ISSUE,
+ issue_id,
+ body.event_id,
+ action=Action.CONFIRM,
+ fingerprint=body.fingerprint,
+ review_revision=body.review_revision,
+ actor_user_id=user_id,
+ )
+ return IdentityReviewReceiptRead(event_id=receipt.event_id, replayed=receipt.replayed)
diff --git a/src/pullbox/services/library_conversion_files.py b/src/pullbox/services/library_conversion_files.py
new file mode 100644
index 00000000..bc072426
--- /dev/null
+++ b/src/pullbox/services/library_conversion_files.py
@@ -0,0 +1,211 @@
+"""Private conversion preparation and immutable evidence for short publication."""
+
+import asyncio
+import os
+import stat
+import tempfile
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from datetime import UTC, datetime
+from pathlib import Path
+from threading import Event
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.file_publication import publish_file_without_overwrite
+from pullbox.services.archive_metadata_binding import FileFingerprint
+from pullbox.services.archive_metadata_publication import _digest, _file_work, _fingerprint
+from pullbox.utilities.executors.archive_subprocess import (
+ convert_file_interruptible,
+ transfer_file_interruptible,
+)
+
+
+class ConversionFile(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+ path: Path
+ fingerprint: FileFingerprint
+ digest: str = Field(pattern=r"^[a-f0-9]{64}$")
+
+
+class ConversionBinding(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+ root_id: int
+ root_path: str
+ file_id: int | None
+ issue_id: int | None
+ file_format: str | None
+ file_size: int | None
+ file_modified_at: datetime | None
+
+
+class ConversionPlan(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+ schema_version: Literal[1] = 1
+ binding: ConversionBinding
+ original: ConversionFile
+ output: ConversionFile
+ backup: ConversionFile
+ output_stage: Path
+ backup_stage: Path
+ directories: tuple[tuple[Path, int, int, int], ...]
+
+ @property
+ def paths(self) -> tuple[Path, ...]:
+ return (
+ self.original.path,
+ self.output.path,
+ self.backup.path,
+ self.output_stage,
+ self.backup_stage,
+ )
+
+
+def decode_plan(encoded: str) -> ConversionPlan:
+ if not isinstance(encoded, str) or len(encoded.encode("utf-8")) > 65536:
+ raise ValidationError("Conversion recovery evidence exceeds its limit.")
+ plan = ConversionPlan.model_validate_json(encoded)
+ if (
+ any(not path.is_absolute() or ".." in path.parts for path in plan.paths)
+ or len(set(plan.paths)) != 5
+ or plan.output.path != plan.original.path.with_suffix(".cbz")
+ or not plan.original.path.is_relative_to(Path(plan.binding.root_path).resolve())
+ or plan.output_stage.parent.parent != plan.output.path.parent
+ or plan.backup_stage.parent.parent != plan.backup.path.parent
+ or any(
+ not stage.parent.name.startswith(".pullbox-conversion-")
+ for stage in (plan.output_stage, plan.backup_stage)
+ )
+ ):
+ raise ValidationError("Conversion recovery evidence is invalid.")
+ return plan
+
+
+def _regular(path: Path) -> FileFingerprint:
+ evidence = _fingerprint(path)
+ if evidence is None or not stat.S_ISREG(evidence[5]) or path.resolve() != path:
+ raise ValidationError("Conversion requires an unchanged regular file.")
+ return evidence
+
+
+def directories(*paths: Path) -> tuple[tuple[Path, int, int, int], ...]:
+ result = []
+ for path in sorted(set(parent for value in paths for parent in value.parents)):
+ info = path.lstat()
+ if not stat.S_ISDIR(info.st_mode) or path.resolve() != path:
+ raise ValidationError("Conversion locations must not contain symbolic links.")
+ result.append((path, info.st_dev, info.st_ino, info.st_mode))
+ return tuple(result)
+
+
+def check_directories(plan: ConversionPlan) -> None:
+ for path, device, inode, mode in plan.directories:
+ info = path.lstat()
+ if (info.st_dev, info.st_ino, info.st_mode) != (device, inode, mode):
+ raise ValidationError("Conversion locations changed; review before retrying.")
+
+
+def matches(actual: FileFingerprint | None, expected: FileFingerprint) -> bool:
+ # Exclusive rename/link publication may change ctime, but not content identity.
+ return bool(actual and actual[:4] == expected[:4] and actual[5] == expected[5])
+
+
+def sync_directory(path: Path) -> None:
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+
+
+def publish(plan: ConversionPlan) -> None:
+ check_directories(plan)
+ if _fingerprint(plan.original.path) != plan.original.fingerprint:
+ raise ValidationError("The original changed during conversion.")
+ for stage, destination in ((plan.backup_stage, plan.backup), (plan.output_stage, plan.output)):
+ if stage.resolve(strict=True) != stage or _fingerprint(stage) != destination.fingerprint:
+ raise ValidationError("Prepared conversion files changed.")
+ publish_file_without_overwrite(stage, destination.path)
+ sync_directory(destination.path.parent)
+
+
+@asynccontextmanager
+async def prepare_conversion(
+ source: Path, backup: Path, binding: ConversionBinding
+) -> AsyncIterator[ConversionPlan]:
+ """No public destination or source mutation; workers are reaped before cleanup."""
+ source = source.absolute()
+ before = _regular(source)
+ backup.parent.mkdir(parents=True, exist_ok=True)
+ dirs = directories(source, backup)
+ with (
+ tempfile.TemporaryDirectory(prefix=".pullbox-conversion-", dir=source.parent) as output_dir,
+ tempfile.TemporaryDirectory(prefix=".pullbox-conversion-", dir=backup.parent) as backup_dir,
+ ):
+ output_stage = Path(output_dir) / "output.cbz"
+ backup_stage = Path(backup_dir) / "original"
+ await convert_file_interruptible(source, "cbz", output_path=output_stage)
+ await transfer_file_interruptible(source, backup_stage, "copy")
+
+ def inspect(stop: Event) -> ConversionPlan:
+ if _regular(source) != before:
+ raise ValidationError("The original changed during conversion.")
+ # Permission metadata is applied only to new owned stages, never to the source.
+ os.chmod(output_stage, stat.S_IMODE(before[5]) & 0o777)
+ now = datetime.now(UTC).timestamp()
+ os.utime(backup_stage, (now, now))
+ files = []
+ for path in (source, output_stage, backup_stage):
+ info = _regular(path)
+ digest = _digest(path, info, stop)
+ if path != source:
+ with path.open("rb") as stream:
+ os.fsync(stream.fileno())
+ files.append(ConversionFile(path=path, fingerprint=info, digest=digest))
+ if files[0].digest != files[2].digest or files[0].fingerprint != before:
+ raise ValidationError("The original changed while its backup was prepared.")
+ plan = ConversionPlan(
+ binding=binding,
+ original=files[0],
+ output=files[1].model_copy(update={"path": source.with_suffix(".cbz")}),
+ backup=files[2].model_copy(update={"path": backup}),
+ output_stage=output_stage,
+ backup_stage=backup_stage,
+ directories=dirs,
+ )
+ check_directories(plan)
+ return decode_plan(plan.model_dump_json())
+
+ yield await _file_work(inspect)
+
+
+async def inspect_conversion(plan: ConversionPlan) -> dict[str, ConversionFile | None]:
+ def inspect(stop: Event) -> dict[str, ConversionFile | None]:
+ check_directories(plan)
+ result: dict[str, ConversionFile | None] = {}
+ for name in ("original", "output", "backup"):
+ expected: ConversionFile = getattr(plan, name)
+ actual = _fingerprint(expected.path)
+ result[name] = None
+ if actual is not None:
+ digest = "0" * 64
+ if matches(actual, expected.fingerprint):
+ digest = _digest(expected.path, actual, stop)
+ result[name] = ConversionFile(path=expected.path, fingerprint=actual, digest=digest)
+ return result
+
+ return await _file_work(inspect)
+
+
+async def remove_original(plan: ConversionPlan, inspected: ConversionFile | None) -> None:
+ def remove() -> None:
+ check_directories(plan)
+ if _fingerprint(plan.original.path) != (inspected.fingerprint if inspected else None):
+ raise ValidationError("The original changed before conversion cleanup.")
+ if inspected:
+ plan.original.path.unlink()
+ sync_directory(plan.original.path.parent)
+
+ await asyncio.to_thread(remove)
diff --git a/src/pullbox/services/library_conversion_recovery.py b/src/pullbox/services/library_conversion_recovery.py
new file mode 100644
index 00000000..2b267863
--- /dev/null
+++ b/src/pullbox/services/library_conversion_recovery.py
@@ -0,0 +1,321 @@
+"""Durable Library conversion admission, registration and evidence-based recovery."""
+
+import asyncio
+from pathlib import Path
+from uuid import UUID
+
+import structlog
+from sqlalchemy import case, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.library_file_ownership import require_mutable_library_target
+from pullbox.models import LibraryFile, LibraryRoot
+from pullbox.models.library import LibraryFileStorageMode
+from pullbox.models.library_conversion import LibraryConversion
+from pullbox.services.archive_metadata_publication import _fingerprint
+from pullbox.services.library_conversion_files import (
+ ConversionBinding,
+ ConversionFile,
+ ConversionPlan,
+ check_directories,
+ decode_plan,
+ inspect_conversion,
+ matches,
+ publish,
+ remove_original,
+)
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+)
+
+logger = structlog.get_logger(__name__)
+
+
+async def read_conversion_binding(session: AsyncSession, source: Path) -> ConversionBinding:
+ await require_mutable_library_target(
+ session, source, include_descendants=False, operation="converted"
+ )
+ file = await session.scalar(
+ select(LibraryFile)
+ .where(LibraryFile.file_path == str(source))
+ .execution_options(populate_existing=True)
+ )
+ roots = list(
+ (
+ await session.scalars(
+ select(LibraryRoot)
+ .where(LibraryRoot.enabled.is_(True), LibraryRoot.allow_managed_writes.is_(True))
+ .limit(201)
+ .execution_options(populate_existing=True)
+ )
+ ).all()
+ )
+ candidates = [root for root in roots if source.is_relative_to(Path(root.path).resolve())]
+ if file is not None:
+ candidates = [root for root in candidates if root.id == file.library_root_id]
+ elif candidates:
+ depth = max(len(Path(root.path).resolve().parts) for root in candidates)
+ candidates = [root for root in candidates if len(Path(root.path).resolve().parts) == depth]
+ if (
+ len(roots) > 200
+ or len(candidates) != 1
+ or (
+ file is not None
+ and (
+ file.library_root_id != candidates[0].id
+ or file.storage_mode is not LibraryFileStorageMode.MANAGED
+ )
+ )
+ ):
+ raise ValidationError(
+ "Conversion requires one enabled library root that allows managed files."
+ )
+ root = candidates[0]
+ return ConversionBinding(
+ root_id=root.id,
+ root_path=root.path,
+ file_id=file.id if file else None,
+ issue_id=file.issue_id if file else None,
+ file_format=file.file_format.value if file else None,
+ file_size=file.file_size if file else None,
+ file_modified_at=file.file_modified_at if file else None,
+ )
+
+
+async def require_no_library_conversion(
+ session: AsyncSession,
+ *paths: Path,
+ include_descendants: bool,
+ excluding: str | None = None,
+) -> None:
+ targets = {variant for path in paths for variant in (path.absolute(), path.resolve())}
+ last_id = 0
+ while True:
+ query = select(
+ LibraryConversion.id,
+ case(
+ (
+ func.length(LibraryConversion.plan_json) <= 65536,
+ LibraryConversion.plan_json,
+ ),
+ else_=None,
+ ),
+ ).where(LibraryConversion.active.is_(True), LibraryConversion.id > last_id)
+ if excluding is not None:
+ query = query.where(LibraryConversion.operation_id != excluding)
+ rows = (await session.execute(query.order_by(LibraryConversion.id).limit(8))).all()
+ if not rows:
+ return
+ for row_id, encoded in rows:
+ last_id = row_id
+ try:
+ plan = decode_plan(encoded)
+ except (TypeError, ValueError):
+ raise ValidationError(
+ "A pending conversion needs recovery before files can change."
+ ) from None
+ if any(
+ reserved == target or (include_descendants and reserved.is_relative_to(target))
+ for reserved in plan.paths
+ for target in targets
+ ):
+ raise ValidationError(
+ "A conversion is pending for this file or folder. "
+ "Restart Pullbox to recover it before retrying."
+ )
+
+
+async def record_conversion(
+ session: AsyncSession, plan: ConversionPlan, operation_id: UUID
+) -> None:
+ from pullbox.services.library_mutation_coordination import require_no_archive_publication
+
+ decode_plan(plan.model_dump_json())
+ await lock_file_mutation_admission(session)
+ await require_no_archive_publication(session, *plan.paths, include_descendants=False)
+ if await read_conversion_binding(session, plan.original.path) != plan.binding:
+ raise ValidationError("The library registration changed during conversion.")
+ if await session.scalar(
+ select(LibraryFile.id).where(LibraryFile.file_path == str(plan.output.path))
+ ):
+ raise ValidationError("The converted file's destination is already registered.")
+ check_directories(plan)
+ if _fingerprint(plan.original.path) != plan.original.fingerprint:
+ raise ValidationError("The original changed during conversion.")
+ session.add(
+ LibraryConversion(
+ operation_id=str(operation_id),
+ library_file_id=plan.binding.file_id,
+ plan_json=plan.model_dump_json(),
+ )
+ )
+ await session.flush()
+ session.info[f"conversion_intent:{operation_id}"] = session.sync_session.get_transaction()
+
+
+async def publish_conversion(session: AsyncSession, operation_id: UUID) -> None:
+ if (
+ session.in_transaction()
+ and session.info.get(f"conversion_intent:{operation_id}")
+ is session.sync_session.get_transaction()
+ ):
+ raise ValidationError("Conversion intent must commit before publication.")
+ await lock_file_mutation_admission(session)
+ row = await session.scalar(
+ select(LibraryConversion)
+ .where(LibraryConversion.operation_id == str(operation_id))
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if row is None or not row.active or row.state != "intended":
+ raise ValidationError("Conversion has already been recovered; retry from the Library.")
+ plan = decode_plan(row.plan_json)
+ if await read_conversion_binding(session, plan.original.path) != plan.binding:
+ raise ValidationError("The library registration changed during conversion.")
+ await finish_short_mutation(asyncio.create_task(asyncio.to_thread(publish, plan)))
+
+
+async def recover_conversion(session: AsyncSession, operation_id: UUID) -> str:
+ """Own clean session transactions; never resume a converter or publish a stage."""
+
+ if session.new or session.dirty or session.deleted or session.in_nested_transaction():
+ raise ValidationError("Conversion recovery requires a clean session.")
+ row = await session.scalar(
+ select(LibraryConversion)
+ .where(LibraryConversion.operation_id == str(operation_id))
+ .execution_options(populate_existing=True)
+ )
+ if row is None:
+ return "missing"
+ encoded, state, active = row.plan_json, row.state, row.active
+ await session.commit()
+ if not active or state == "review":
+ return state
+ plan = decode_plan(encoded)
+ inspected = await inspect_conversion(plan)
+ return await _apply_inspected_conversion(session, operation_id, encoded, plan, inspected)
+
+
+async def _apply_inspected_conversion(
+ session: AsyncSession,
+ operation_id: UUID,
+ encoded: str,
+ plan: ConversionPlan,
+ inspected: dict[str, ConversionFile | None],
+) -> str:
+ from pullbox.services.library_convert_service import _sync_converted_file_record
+
+ await lock_file_mutation_admission(session)
+ row = await session.scalar(
+ select(LibraryConversion)
+ .where(LibraryConversion.operation_id == str(operation_id))
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if row is None or not row.active or row.state == "review":
+ await session.rollback()
+ return row.state if row else "missing"
+ if row.plan_json != encoded:
+ raise ValidationError("Conversion evidence changed during recovery.")
+ check_directories(plan)
+ for name, actual in inspected.items():
+ if _fingerprint(getattr(plan, name).path) != (actual.fingerprint if actual else None):
+ raise ValidationError("Conversion files changed during recovery.")
+ proven = {
+ name: bool(
+ actual
+ and matches(actual.fingerprint, getattr(plan, name).fingerprint)
+ and actual.digest == getattr(plan, name).digest
+ )
+ for name, actual in inspected.items()
+ }
+ if inspected["output"] is None and proven["original"] and row.state == "intended":
+ row.state, row.active = "abandoned", False
+ await session.commit()
+ return "abandoned"
+ if (
+ not proven["output"]
+ or not proven["backup"]
+ or (inspected["original"] is not None and not proven["original"])
+ ):
+ row.state = "review"
+ await session.commit()
+ return "review"
+
+ if row.state == "intended":
+ try:
+ current = await read_conversion_binding(session, plan.original.path)
+ if current != plan.binding or not proven["original"]:
+ raise ValidationError("Conversion registration changed.")
+ if await session.scalar(
+ select(LibraryFile.id).where(LibraryFile.file_path == str(plan.output.path))
+ ):
+ raise ValidationError("Conversion destination is already registered.")
+ await _sync_converted_file_record(
+ session, before_path=str(plan.original.path), after_path=str(plan.output.path)
+ )
+ except ValidationError:
+ row.state = "review"
+ await session.commit()
+ return "review"
+ row.state = "registered"
+ # This commit atomically records registration. Lost acknowledgements leave
+ # the original and the journal intact; subsequent recovery reads DB truth.
+ await session.commit()
+ return await _apply_inspected_conversion(session, operation_id, encoded, plan, inspected)
+
+ current = await read_conversion_binding(session, plan.output.path)
+ expected = plan.binding
+ if (
+ current.file_id != expected.file_id
+ or current.issue_id != expected.issue_id
+ or current.root_id != expected.root_id
+ or current.root_path != expected.root_path
+ or (
+ current.file_id is not None
+ and (current.file_format != "cbz" or current.file_size != plan.output.fingerprint[2])
+ )
+ ):
+ row.state = "review"
+ await session.commit()
+ return "review"
+ await finish_short_mutation(asyncio.create_task(remove_original(plan, inspected["original"])))
+ row.state, row.active = "complete", False
+ await session.commit()
+ return "complete"
+
+
+async def recover_library_conversions(session: AsyncSession) -> int:
+ """Bounded startup pass; unresolved evidence stays reserved and is logged."""
+ if session.new or session.dirty or session.deleted or session.in_nested_transaction():
+ raise ValidationError("Conversion recovery requires a clean session.")
+ last_id = completed = 0
+ while True:
+ rows = (
+ await session.execute(
+ select(LibraryConversion.id, LibraryConversion.operation_id)
+ .where(
+ LibraryConversion.active.is_(True),
+ LibraryConversion.state != "review",
+ LibraryConversion.id > last_id,
+ )
+ .order_by(LibraryConversion.id)
+ .limit(8)
+ )
+ ).all()
+ await session.commit()
+ if not rows:
+ return completed
+ for row_id, operation in rows:
+ last_id = row_id
+ try:
+ state = await recover_conversion(session, UUID(operation))
+ completed += state in {"complete", "abandoned"}
+ logger.info("library_conversion_recovered", operation_id=operation, state=state)
+ except Exception:
+ await session.rollback()
+ logger.warning(
+ "library_conversion_recovery_deferred", operation_id=operation, exc_info=True
+ )
diff --git a/src/pullbox/services/library_convert_service.py b/src/pullbox/services/library_convert_service.py
index 77c569fa..893ecf80 100644
--- a/src/pullbox/services/library_convert_service.py
+++ b/src/pullbox/services/library_convert_service.py
@@ -2,19 +2,31 @@
from __future__ import annotations
+import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
+from uuid import uuid4
import structlog
from sqlalchemy import select
from pullbox.core.exceptions import ValidationError
-from pullbox.core.library_file_ownership import require_mutable_library_target
from pullbox.models.library import FileFormat, LibraryFile
-from pullbox.utilities.executors.file_converter import convert_file
-from pullbox.utilities.settings import move_file_to_utility_trash, restore_file_from_utility_trash
+from pullbox.services.library_conversion_files import prepare_conversion
+from pullbox.services.library_conversion_recovery import (
+ publish_conversion,
+ read_conversion_binding,
+ record_conversion,
+ recover_conversion,
+)
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+ require_no_archive_publication,
+)
+from pullbox.utilities.settings import build_trash_destination
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,6 +60,7 @@ async def _sync_converted_file_record(
library_file.file_path = after_path
library_file.file_name = updated_path.name
library_file.file_format = FileFormat.CBZ
+ library_file.file_hash = None
if metadata_embedded:
library_file.has_comicinfo = True
if updated_path.exists():
@@ -73,62 +86,42 @@ async def convert_library_file(
trash_dir: Path,
trash_relative_path: str | Path,
) -> LibraryConvertOutcome:
- """Convert a single Library browser file immediately and sync tracked DB metadata."""
- converted_path: Path | None = None
- trash_path: Path | None = None
-
+ """Own the conversion session lifecycle, retaining recoverable public artifacts."""
+ if session.new or session.dirty or session.deleted or session.in_nested_transaction():
+ raise ValidationError("Conversion requires a clean session.")
+ source = source.absolute()
+ operation_id = uuid4()
+ target = source.with_suffix(".cbz")
+ relative = Path(trash_relative_path)
+ if relative.is_absolute() or ".." in relative.parts:
+ raise ValidationError("Invalid conversion trash location.")
+ backup = build_trash_destination(trash_dir.absolute(), source, relative_path=relative)
+ backup = backup.parent / f"conversion-{operation_id.hex}" / backup.name
try:
- await require_mutable_library_target(
- session,
- source,
- include_descendants=False,
- operation="converted",
- )
- converted_path = await convert_file(source, "cbz")
- trash_path = move_file_to_utility_trash(
- source,
- trash_dir,
- relative_path=trash_relative_path,
- )
- await _sync_converted_file_record(
- session,
- before_path=str(source),
- after_path=str(converted_path),
- metadata_embedded=False,
+ await lock_file_mutation_admission(session)
+ await require_no_archive_publication(
+ session, source, target, backup, include_descendants=False
)
+ binding = await read_conversion_binding(session, source)
+ if target.exists() or target.is_symlink() or target == source:
+ raise FileExistsError
await session.commit()
- return LibraryConvertOutcome(
- kind="file",
- source_path=str(source),
- target_path=str(converted_path),
- original_trash_path=str(trash_path),
- )
- except ValidationError:
- await session.rollback()
- raise
- except Exception as exc:
- await session.rollback()
-
- restored = False
- if trash_path is not None and trash_path.exists() and not source.exists():
- try:
- restore_file_from_utility_trash(
- trash_path,
- source,
- converted_path=converted_path,
- )
- restored = True
- except Exception:
- logger.exception(
- "library_convert_rollback_failed",
- source_path=str(source),
- target_path=str(converted_path) if converted_path is not None else None,
- trash_path=str(trash_path),
+ async with prepare_conversion(source, backup, binding) as plan:
+ await record_conversion(session, plan, operation_id)
+ await finish_short_mutation(asyncio.create_task(session.commit()))
+ await publish_conversion(session, operation_id)
+ await finish_short_mutation(asyncio.create_task(session.commit()))
+ state = await recover_conversion(session, operation_id)
+ if state != "complete":
+ raise ValidationError(
+ "Conversion needs review. The original and recovery evidence were preserved."
)
- elif converted_path is not None and converted_path.exists():
- converted_path.unlink()
-
- message = _conversion_error_message(exc)
- if restored and message == "Conversion could not be completed.":
- message += " The original file was restored."
- raise ValidationError(message) from exc
+ return LibraryConvertOutcome("file", str(source), str(target), str(backup))
+ except BaseException as exc:
+ await session.rollback()
+ if isinstance(exc, ValidationError) or not isinstance(exc, Exception):
+ raise
+ logger.warning(
+ "library_conversion_interrupted", operation_id=str(operation_id), exc_info=True
+ )
+ raise ValidationError(_conversion_error_message(exc)) from exc
diff --git a/src/pullbox/services/library_delete_service.py b/src/pullbox/services/library_delete_service.py
index fe27ab34..95eb85c7 100644
--- a/src/pullbox/services/library_delete_service.py
+++ b/src/pullbox/services/library_delete_service.py
@@ -203,7 +203,10 @@ async def build_delete_context(
file_clause = (
LibraryFile.file_path == prefix
if kind == "file"
- else or_(LibraryFile.file_path == prefix, LibraryFile.file_path.like(f"{prefix}/%"))
+ else or_(
+ LibraryFile.file_path == prefix,
+ LibraryFile.file_path.startswith(f"{prefix}/", autoescape=True),
+ )
)
tracked_file_count = int(
(await session.execute(select(func.count(LibraryFile.id)).where(file_clause))).scalar_one()
@@ -216,7 +219,10 @@ async def build_delete_context(
(
await session.execute(
select(func.count(Series.id)).where(
- or_(Series.path == prefix, Series.path.like(f"{prefix}/%"))
+ or_(
+ Series.path == prefix,
+ Series.path.startswith(f"{prefix}/", autoescape=True),
+ )
)
)
).scalar_one()
@@ -297,7 +303,10 @@ async def delete_library_entry(
file_clause = (
LibraryFile.file_path == prefix
if kind == "file"
- else or_(LibraryFile.file_path == prefix, LibraryFile.file_path.like(f"{prefix}/%"))
+ else or_(
+ LibraryFile.file_path == prefix,
+ LibraryFile.file_path.startswith(f"{prefix}/", autoescape=True),
+ )
)
tracked_files = list(
(
@@ -317,7 +326,10 @@ async def delete_library_entry(
(
await session.execute(
select(Series).where(
- or_(Series.path == prefix, Series.path.like(f"{prefix}/%"))
+ or_(
+ Series.path == prefix,
+ Series.path.startswith(f"{prefix}/", autoescape=True),
+ )
)
)
)
diff --git a/src/pullbox/services/library_mutation_coordination.py b/src/pullbox/services/library_mutation_coordination.py
new file mode 100644
index 00000000..6950a74f
--- /dev/null
+++ b/src/pullbox/services/library_mutation_coordination.py
@@ -0,0 +1,117 @@
+"""Serialize short path changes with admission of durable archive publications."""
+
+import asyncio
+import hashlib
+import os
+from contextlib import suppress
+from pathlib import Path
+
+from sqlalchemy import case, false, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.models import Series
+from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication
+
+# Two fixed PostgreSQL advisory-lock keys: PULL / FILE. Never derived from user input.
+_LOCK_NAMESPACE = 0x50554C4C
+_LOCK_RESOURCE = 0x46494C45
+_MAX_PLAN_BYTES = 4 * 1024 * 1024
+_PAGE_SIZE = 8
+
+
+async def finish_short_mutation[T](task: asyncio.Task[T]) -> T:
+ """Join short rename/commit/cleanup work before cancellation releases its mutex."""
+ try:
+ return await asyncio.shield(task)
+ except asyncio.CancelledError:
+ while not task.done():
+ with suppress(asyncio.CancelledError, Exception):
+ await asyncio.shield(task)
+ if not task.cancelled():
+ task.exception()
+ raise
+
+
+async def lock_file_mutation_admission(session: AsyncSession) -> None:
+ """Hold the admission mutex until the caller commits or rolls back.
+
+ Acquire before entity locks. Only short path changes and publication admission
+ belong here, never archive reads, conversion, hashing or provider requests.
+ Durable publication reservations protect the longer interval between commits.
+ """
+ dialect = session.get_bind().dialect.name
+ if dialect == "sqlite":
+ await session.execute(update(Series).where(false()).values(path=Series.path))
+ elif dialect == "postgresql":
+ await session.execute(select(func.pg_advisory_xact_lock(_LOCK_NAMESPACE, _LOCK_RESOURCE)))
+ else:
+ raise ValidationError("This database cannot safely coordinate library file changes.")
+
+
+async def require_no_archive_publication(
+ session: AsyncSession, *paths: Path, include_descendants: bool
+) -> None:
+ """Check retained source/stage reservations under the admission mutex.
+
+ Do not depend on live LibraryFile rows: their deletion deliberately leaves
+ publication evidence behind. Bound each page and fail closed on corrupt plans.
+ """
+ from pullbox.services.archive_metadata_publication import ArchivePublicationPlan
+ from pullbox.services.library_conversion_recovery import require_no_library_conversion
+ from pullbox.services.library_removal import require_no_library_removal
+
+ await require_no_library_removal(session, *paths, include_descendants=include_descendants)
+ await require_no_library_conversion(session, *paths, include_descendants=include_descendants)
+
+ targets = {variant for path in paths for variant in (path.absolute(), path.resolve())}
+ last_id = 0
+ while True:
+ rows = (
+ await session.execute(
+ select(
+ ArchiveMetadataPublication.id,
+ ArchiveMetadataPublication.active_path_key,
+ case(
+ (
+ func.length(ArchiveMetadataPublication.plan_json) <= _MAX_PLAN_BYTES,
+ ArchiveMetadataPublication.plan_json,
+ ),
+ else_=None,
+ ),
+ )
+ .where(
+ ArchiveMetadataPublication.id > last_id,
+ ArchiveMetadataPublication.active_path_key.is_not(None),
+ )
+ .order_by(ArchiveMetadataPublication.id)
+ .limit(_PAGE_SIZE)
+ )
+ ).all()
+ if not rows:
+ return
+ for row_id, path_key, encoded in rows:
+ last_id = row_id
+ try:
+ if encoded is None or len(encoded.encode("utf-8")) > _MAX_PLAN_BYTES:
+ raise ValueError("Invalid publication plan")
+ plan = ArchivePublicationPlan.model_validate_json(encoded)
+ if (
+ not plan.target.path.is_absolute()
+ or not plan.stage_path.is_absolute()
+ or hashlib.sha256(os.fsencode(plan.target.path)).hexdigest() != path_key
+ ):
+ raise ValueError("Changed publication plan")
+ except ValueError:
+ raise ValidationError(
+ "A pending metadata update needs recovery before library files can be renamed."
+ ) from None
+ for reserved in (plan.target.path, plan.stage_path):
+ if any(
+ reserved == target or (include_descendants and reserved.is_relative_to(target))
+ for target in targets
+ ):
+ raise ValidationError(
+ "A metadata update is still pending for this file or folder. "
+ "Finish or recover that update, then retry the rename."
+ )
diff --git a/src/pullbox/services/library_removal.py b/src/pullbox/services/library_removal.py
new file mode 100644
index 00000000..c7891fc6
--- /dev/null
+++ b/src/pullbox/services/library_removal.py
@@ -0,0 +1,393 @@
+"""Durable staging and recovery boundary for library removal owners."""
+
+import asyncio
+import hashlib
+import os
+import stat
+from contextlib import suppress
+from pathlib import Path
+from typing import Literal
+from uuid import UUID, uuid4
+
+import structlog
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy import case, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.file_publication import rename_path_without_overwrite
+from pullbox.core.library_file_ownership import require_mutable_library_target
+from pullbox.models import LibraryRoot
+from pullbox.models.library_removal import LibraryRemoval
+from pullbox.services.archive_metadata_binding import FileFingerprint
+from pullbox.services.archive_metadata_publication import _fingerprint
+from pullbox.services.library_conversion_files import directories, matches, sync_directory
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+)
+
+logger = structlog.get_logger(__name__)
+
+
+def trash_path_key(path: Path) -> str:
+ """Index literal trash paths without indexing arbitrarily long filenames."""
+ return hashlib.sha256(os.fsencode(path)).hexdigest()
+
+
+class RemovalPlan(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+ schema_version: Literal[1] = 1
+ operation_id: UUID
+ source: Path
+ stage: Path
+ root_id: int
+ root_path: Path
+ fingerprint: FileFingerprint
+ directories: tuple[tuple[Path, int, int, int], ...]
+ disposition: Literal["retain", "delete", "trash"] = "retain"
+ trash_path: Path | None = None
+ trash_stage: Path | None = None
+
+
+def prepare_removal(
+ source: Path,
+ *,
+ root_id: int,
+ root_path: Path,
+ disposition: Literal["retain", "delete", "trash"] = "retain",
+ trash_path: Path | None = None,
+) -> RemovalPlan:
+ """Create an empty private sibling; caller owns recording or discarding intent."""
+ source, root_path = source.absolute(), root_path.resolve()
+ before = _fingerprint(source)
+ if (
+ before is None
+ or source.resolve() != source
+ or source == root_path
+ or not source.is_relative_to(root_path)
+ or not (stat.S_ISREG(before[5]) or stat.S_ISDIR(before[5]))
+ ):
+ raise ValidationError("Library removal requires a regular file or folder inside its root.")
+ if (disposition == "trash") != (trash_path is not None):
+ raise ValidationError("Trash removal requires an explicit destination.")
+ if trash_path is not None and (
+ not trash_path.is_absolute()
+ or trash_path.resolve() != trash_path
+ or trash_path.is_relative_to(source)
+ or source.is_relative_to(trash_path)
+ ):
+ raise ValidationError("Trash must be a separate, resolved destination.")
+ operation = uuid4()
+ private = source.parent / f".pullbox-removal-{operation.hex}"
+ private.mkdir(mode=0o700)
+ created = private.lstat()
+ trash_private: Path | None = None
+ trash_created = None
+ try:
+ if trash_path is not None:
+ trash_path.parent.mkdir(parents=True, exist_ok=True)
+ trash_private = trash_path.parent / f".pullbox-removal-trash-{operation.hex}"
+ trash_private.mkdir(mode=0o700)
+ trash_created = trash_private.lstat()
+ trash_stage = trash_private / "payload" if trash_private else None
+ plan = RemovalPlan(
+ operation_id=operation,
+ source=source,
+ stage=private / "payload",
+ root_id=root_id,
+ root_path=root_path,
+ fingerprint=before,
+ directories=directories(
+ source, private / "payload", *((trash_path, trash_stage) if trash_stage else ())
+ ),
+ disposition=disposition,
+ trash_path=trash_path,
+ trash_stage=trash_stage,
+ )
+ _check_locations(plan)
+ return decode_removal(plan.model_dump_json())
+ except BaseException:
+ if trash_private is not None and trash_created is not None:
+ with suppress(OSError):
+ current = trash_private.lstat()
+ if (current.st_dev, current.st_ino) == (trash_created.st_dev, trash_created.st_ino):
+ trash_private.rmdir()
+ with suppress(OSError):
+ current = private.lstat()
+ if (current.st_dev, current.st_ino) == (created.st_dev, created.st_ino):
+ private.rmdir()
+ raise
+
+
+async def recover_library_removals(session: AsyncSession) -> int:
+ """Bounded startup pass restoring uncommitted intent, never deleting artifacts."""
+ if session.new or session.dirty or session.deleted or session.in_nested_transaction():
+ raise ValidationError("Library removal recovery requires a clean session.")
+ last_id = restored = 0
+ while True:
+ rows = (
+ await session.execute(
+ select(LibraryRemoval.id, LibraryRemoval.operation_id)
+ .where(
+ LibraryRemoval.id > last_id,
+ LibraryRemoval.active.is_(True),
+ LibraryRemoval.state == "intended",
+ )
+ .order_by(LibraryRemoval.id)
+ .limit(8)
+ )
+ ).all()
+ await session.commit()
+ if not rows:
+ from pullbox.services.library_removal_workspaces import prune_terminal_removals
+
+ await prune_terminal_removals(session)
+ return restored
+ for row_id, operation in rows:
+ last_id = row_id
+ try:
+ state = await recover_uncommitted_removal(session, UUID(operation))
+ restored += state == "abandoned"
+ logger.info("library_removal_recovered", operation_id=operation, state=state)
+ except Exception:
+ await session.rollback()
+ logger.warning(
+ "library_removal_recovery_deferred", operation_id=operation, exc_info=True
+ )
+
+
+def decode_removal(encoded: str, *, operation_id: str | None = None) -> RemovalPlan:
+ if not isinstance(encoded, str) or len(encoded.encode("utf-8")) > 65536:
+ raise ValidationError("Library removal evidence exceeds its limit.")
+ plan = RemovalPlan.model_validate_json(encoded)
+ expected_dirs = set(plan.source.parents) | set(plan.stage.parents)
+ if plan.trash_path is not None and plan.trash_stage is not None:
+ expected_dirs |= set(plan.trash_path.parents) | set(plan.trash_stage.parents)
+ if (
+ plan.trash_path.resolve() != plan.trash_path
+ or plan.trash_path.is_relative_to(plan.source)
+ or plan.source.is_relative_to(plan.trash_path)
+ or plan.trash_stage
+ != plan.trash_path.parent
+ / f".pullbox-removal-trash-{plan.operation_id.hex}"
+ / "payload"
+ ):
+ raise ValidationError("Library removal trash evidence is invalid.")
+ if (plan.disposition == "trash") != (
+ plan.trash_path is not None and plan.trash_stage is not None
+ ) or (plan.disposition != "trash" and (plan.trash_path or plan.trash_stage)):
+ raise ValidationError("Library removal disposition is invalid.")
+ if (
+ (operation_id is not None and str(plan.operation_id) != operation_id)
+ or any(
+ not path.is_absolute() or ".." in path.parts
+ for path in (plan.source, plan.stage, plan.root_path, plan.trash_path, plan.trash_stage)
+ if path is not None
+ )
+ or plan.stage
+ != plan.source.parent / f".pullbox-removal-{plan.operation_id.hex}" / "payload"
+ or plan.source == plan.root_path
+ or not plan.source.is_relative_to(plan.root_path)
+ or len(plan.directories) != len(expected_dirs)
+ or {entry[0] for entry in plan.directories} != expected_dirs
+ or not (stat.S_ISREG(plan.fingerprint[5]) or stat.S_ISDIR(plan.fingerprint[5]))
+ or any(not stat.S_ISDIR(mode) for _, _, _, mode in plan.directories)
+ ):
+ raise ValidationError("Library removal evidence is invalid.")
+ return plan
+
+
+def _check_locations(plan: RemovalPlan) -> None:
+ # Removal and conversion retain the same immutable parent-directory proof.
+ for path, device, inode, mode in plan.directories:
+ info = path.lstat()
+ if (info.st_dev, info.st_ino, info.st_mode) != (
+ device,
+ inode,
+ mode,
+ ) or path.resolve() != path:
+ raise ValidationError("Library removal locations changed; review before retrying.")
+ if stat.S_IMODE(plan.stage.parent.stat().st_mode) != 0o700:
+ raise ValidationError("Library removal staging must remain private.")
+ if plan.trash_stage and stat.S_IMODE(plan.trash_stage.parent.stat().st_mode) != 0o700:
+ raise ValidationError("Library removal trash staging must remain private.")
+
+
+async def _check_authority(session: AsyncSession, plan: RemovalPlan) -> None:
+ root = await session.get(
+ LibraryRoot, plan.root_id, populate_existing=True, with_for_update=True
+ )
+ if (
+ root is None
+ or not root.enabled
+ or not root.allow_managed_writes
+ or Path(root.path).resolve() != plan.root_path
+ ):
+ raise ValidationError("Library removal requires its unchanged managed root.")
+ await require_mutable_library_target(
+ session,
+ plan.source,
+ include_descendants=stat.S_ISDIR(plan.fingerprint[5]),
+ operation="removed",
+ )
+ _check_locations(plan)
+
+
+async def record_removal(session: AsyncSession, plan: RemovalPlan) -> None:
+ """Reserve a caller-authorized target; caller commits before staging."""
+ from pullbox.services.library_mutation_coordination import require_no_archive_publication
+
+ decode_removal(plan.model_dump_json())
+ await lock_file_mutation_admission(session)
+ await require_no_archive_publication(
+ session,
+ plan.source,
+ plan.stage.parent,
+ *(
+ (plan.trash_path, plan.trash_stage.parent)
+ if plan.trash_path and plan.trash_stage
+ else ()
+ ),
+ include_descendants=True,
+ )
+ await _check_authority(session, plan)
+ if _fingerprint(plan.source) != plan.fingerprint or _fingerprint(plan.stage) is not None:
+ raise ValidationError("Library removal source or staging changed.")
+ session.add(
+ LibraryRemoval(
+ operation_id=str(plan.operation_id),
+ plan_json=plan.model_dump_json(),
+ trash_path_key=trash_path_key(plan.trash_path) if plan.trash_path else None,
+ )
+ )
+ await session.flush()
+ session.info[f"removal_intent:{plan.operation_id}"] = session.sync_session.get_transaction()
+
+
+async def _locked_removal(session: AsyncSession, operation_id: UUID) -> LibraryRemoval | None:
+ await lock_file_mutation_admission(session)
+ row: LibraryRemoval | None = await session.scalar(
+ select(LibraryRemoval)
+ .where(LibraryRemoval.operation_id == str(operation_id))
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ return row
+
+
+async def stage_removal(session: AsyncSession, operation_id: UUID) -> None:
+ """Short rename; owner detaches matching DB records in this same transaction."""
+ if (
+ session.in_transaction()
+ and session.info.get(f"removal_intent:{operation_id}")
+ is session.sync_session.get_transaction()
+ ):
+ raise ValidationError("Library removal intent must commit before staging.")
+ row = await _locked_removal(session, operation_id)
+ if row is None or not row.active or row.state != "intended":
+ raise ValidationError("Library removal has already been recovered; retry the operation.")
+ plan = decode_removal(row.plan_json, operation_id=row.operation_id)
+ await _check_authority(session, plan)
+
+ def stage() -> None:
+ _check_locations(plan)
+ if _fingerprint(plan.source) != plan.fingerprint:
+ raise ValidationError("Library removal source changed.")
+ rename_path_without_overwrite(plan.source, plan.stage)
+ sync_directory(plan.source.parent)
+ sync_directory(plan.stage.parent)
+
+ await finish_short_mutation(asyncio.create_task(asyncio.to_thread(stage)))
+ row.state = "detached"
+ await session.flush()
+
+
+async def recover_uncommitted_removal(session: AsyncSession, operation_id: UUID) -> str:
+ """Own a clean session; restore an uncommitted removal, never resume deletion."""
+ if session.new or session.dirty or session.deleted or session.in_nested_transaction():
+ raise ValidationError("Library removal recovery requires a clean session.")
+ row = await _locked_removal(session, operation_id)
+ if row is None or not row.active or row.state != "intended":
+ state = row.state if row else "missing"
+ await session.rollback()
+ return state
+ plan = decode_removal(row.plan_json, operation_id=row.operation_id)
+
+ def restore() -> bool:
+ _check_locations(plan)
+ source, stage = _fingerprint(plan.source), _fingerprint(plan.stage)
+ if matches(source, plan.fingerprint) and stage is None:
+ return True
+ if source is None and matches(stage, plan.fingerprint):
+ rename_path_without_overwrite(plan.stage, plan.source)
+ sync_directory(plan.source.parent)
+ sync_directory(plan.stage.parent)
+ return True
+ return False
+
+ restored = await finish_short_mutation(asyncio.create_task(asyncio.to_thread(restore)))
+ row.state, row.active = ("abandoned", False) if restored else ("review", True)
+ state = row.state
+ await finish_short_mutation(asyncio.create_task(session.commit()))
+ if restored:
+ from pullbox.services.library_removal_workspaces import prune_terminal_removal
+
+ await prune_terminal_removal(session, operation_id)
+ return state
+
+
+async def require_no_library_removal(
+ session: AsyncSession, *paths: Path, include_descendants: bool
+) -> None:
+ """Check durable path scopes while the caller holds mutation admission."""
+ targets = {variant for path in paths for variant in (path.absolute(), path.resolve())}
+ last_id = 0
+ while True:
+ rows = (
+ await session.execute(
+ select(
+ LibraryRemoval.id,
+ LibraryRemoval.operation_id,
+ case(
+ (func.length(LibraryRemoval.plan_json) <= 65536, LibraryRemoval.plan_json),
+ else_=None,
+ ),
+ )
+ .where(LibraryRemoval.active.is_(True), LibraryRemoval.id > last_id)
+ .order_by(LibraryRemoval.id)
+ .limit(8)
+ )
+ ).all()
+ if not rows:
+ return
+ for row_id, operation_id, encoded in rows:
+ last_id = row_id
+ try:
+ plan = decode_removal(encoded, operation_id=operation_id)
+ except (ValueError, TypeError):
+ raise ValidationError(
+ "A pending library removal needs recovery before files can change."
+ ) from None
+ for reserved, descendants in (
+ (plan.source, stat.S_ISDIR(plan.fingerprint[5])),
+ (plan.stage.parent, True),
+ *(
+ (
+ (plan.trash_path, stat.S_ISDIR(plan.fingerprint[5])),
+ (plan.trash_stage.parent, True),
+ )
+ if plan.trash_path and plan.trash_stage
+ else ()
+ ),
+ ):
+ if any(
+ target == reserved
+ or (descendants and target.is_relative_to(reserved))
+ or (include_descendants and reserved.is_relative_to(target))
+ for target in targets
+ ):
+ raise ValidationError(
+ "A library removal is pending for this file or folder. "
+ "Recover it before retrying."
+ )
diff --git a/src/pullbox/services/library_removal_cleanup.py b/src/pullbox/services/library_removal_cleanup.py
new file mode 100644
index 00000000..fc365f83
--- /dev/null
+++ b/src/pullbox/services/library_removal_cleanup.py
@@ -0,0 +1,278 @@
+"""Finish committed removal work outside database write transactions."""
+
+import asyncio
+import os
+import time
+from threading import Event
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.file_publication import rename_path_without_overwrite
+from pullbox.models.library_removal import LibraryRemoval
+from pullbox.services.archive_metadata_binding import FileFingerprint
+from pullbox.services.archive_metadata_publication import _file_work, _fingerprint
+from pullbox.services.library_conversion_files import matches, sync_directory
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+)
+from pullbox.services.library_removal import RemovalPlan, _check_locations, decode_removal
+from pullbox.services.library_removal_files import (
+ copy_payload,
+ create_backup_stage,
+ payload_digest,
+ removal_claim,
+ remove_payload,
+ require_payload,
+)
+
+
+class CleanupProof(BaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+ phase: Literal["copying", "prepared", "deleting"]
+ transfer: Literal["copy", "rename"] = "copy"
+ backup_fingerprint: FileFingerprint | None = None
+ backup_digest: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
+ trash_mtime_ns: int | None = Field(default=None, gt=0)
+
+
+def _decode_proof(value: str | None, plan: RemovalPlan) -> CleanupProof | None:
+ if value is None:
+ return None
+ if len(value.encode("utf-8")) > 4096:
+ raise ValidationError("Removal cleanup evidence exceeds its limit.")
+ proof = CleanupProof.model_validate_json(value)
+ if plan.disposition == "trash":
+ if (
+ proof.backup_fingerprint is None
+ or (
+ proof.transfer == "copy"
+ and proof.phase != "copying"
+ and proof.backup_digest is None
+ )
+ or (proof.transfer == "rename" and proof.phase == "copying")
+ ):
+ raise ValidationError("Removal backup evidence is incomplete.")
+ if proof.phase != "copying" and proof.trash_mtime_ns is None:
+ raise ValidationError("Removal backup retention evidence is incomplete.")
+ elif proof.phase != "deleting" or proof.backup_fingerprint or proof.backup_digest:
+ raise ValidationError("Removal cleanup evidence is invalid.")
+ return proof
+
+
+def _same_filesystem(plan: RemovalPlan) -> bool:
+ assert plan.trash_path is not None
+ return plan.stage.parent.stat().st_dev == plan.trash_path.parent.stat().st_dev
+
+
+async def _read(session: AsyncSession, operation_id: UUID) -> LibraryRemoval:
+ row = await session.scalar(
+ select(LibraryRemoval)
+ .where(LibraryRemoval.operation_id == str(operation_id))
+ .execution_options(populate_existing=True)
+ )
+ if row is None:
+ raise ValidationError("Removal journal was not found.")
+ return row
+
+
+async def _save(
+ session: AsyncSession,
+ plan: RemovalPlan,
+ encoded: str,
+ previous: str | None,
+ proof: CleanupProof,
+ *,
+ complete: bool = False,
+) -> str:
+ await lock_file_mutation_admission(session)
+ row = await _read(session, plan.operation_id)
+ if (
+ not row.active
+ or row.state != "detached"
+ or row.plan_json != encoded
+ or row.cleanup_json != previous
+ ):
+ raise ValidationError("Removal cleanup evidence changed.")
+ current = proof.model_dump_json()
+ row.cleanup_json = current
+ if complete:
+ row.state, row.active = "complete", False
+ await finish_short_mutation(asyncio.create_task(session.commit()))
+ return current
+
+
+async def _backup(
+ session: AsyncSession,
+ plan: RemovalPlan,
+ encoded: str,
+ previous: str | None,
+ proof: CleanupProof | None,
+) -> tuple[str, CleanupProof]:
+ assert plan.trash_stage is not None and plan.trash_path is not None
+ if proof is None and _same_filesystem(plan):
+ proof = CleanupProof(
+ phase="prepared",
+ transfer="rename",
+ backup_fingerprint=plan.fingerprint,
+ trash_mtime_ns=int(time.time()) * 1_000_000_000,
+ )
+ previous = await _save(session, plan, encoded, previous, proof)
+ if proof is None:
+ created = await finish_short_mutation(
+ asyncio.create_task(asyncio.to_thread(create_backup_stage, plan))
+ )
+ proof = CleanupProof(phase="copying", backup_fingerprint=created)
+ previous = await _save(session, plan, encoded, previous, proof)
+
+ if proof.phase == "copying":
+ expected = proof.backup_fingerprint
+ assert expected is not None
+
+ def reset_partial(stop: Event) -> FileFingerprint:
+ _check_locations(plan)
+ assert plan.trash_stage is not None
+ actual = _fingerprint(plan.trash_stage)
+ if actual is None or actual[:2] != expected[:2] or actual[5] != expected[5]:
+ raise ValidationError("Private removal backup changed.")
+ if plan.trash_stage.is_dir():
+ for child in plan.trash_stage.iterdir():
+ child_identity = _fingerprint(child)
+ if child_identity is None:
+ raise ValidationError("Private removal backup changed.")
+ remove_payload(child, child_identity, stop, partial=True)
+ return actual
+
+ # Reset only the inode we created, never an unexplained replacement.
+ created = await _file_work(reset_partial)
+ proof = CleanupProof(phase="copying", backup_fingerprint=created)
+ previous = await _save(session, plan, encoded, previous, proof)
+ fingerprint, digest = await _file_work(lambda stop: copy_payload(plan, stop))
+ proof = CleanupProof(
+ phase="prepared",
+ backup_fingerprint=fingerprint,
+ backup_digest=digest,
+ trash_mtime_ns=int(time.time()) * 1_000_000_000,
+ )
+ previous = await _save(session, plan, encoded, previous, proof)
+
+ if proof.phase == "prepared" and _fingerprint(plan.trash_path) is None:
+ stamp = int(time.time()) * 1_000_000_000
+ if proof.trash_mtime_ns != stamp:
+ proof = proof.model_copy(update={"trash_mtime_ns": stamp})
+ previous = await _save(session, plan, encoded, previous, proof)
+ assert proof.backup_fingerprint is not None
+ assert proof.trash_mtime_ns is not None
+ expected_backup, expected_digest = proof.backup_fingerprint, proof.backup_digest
+ published_backup: FileFingerprint = (
+ *expected_backup[:3],
+ proof.trash_mtime_ns,
+ expected_backup[4],
+ expected_backup[5],
+ )
+
+ def publish_and_check(stop: Event) -> None:
+ _check_locations(plan)
+ assert plan.trash_path is not None and plan.trash_stage is not None
+ if _fingerprint(plan.trash_path) is None:
+ if proof.phase == "deleting":
+ raise ValidationError("Published removal backup is missing.")
+ source = plan.stage if proof.transfer == "rename" else plan.trash_stage
+ require_payload(source, expected_backup)
+ if expected_digest and payload_digest(source, stop) != expected_digest:
+ raise ValidationError("Prepared removal backup changed.")
+ rename_path_without_overwrite(source, plan.trash_path)
+ sync_directory(plan.trash_path.parent)
+ sync_directory(source.parent)
+ actual = _fingerprint(plan.trash_path)
+ if matches(actual, expected_backup):
+ info = plan.trash_path.lstat()
+ os.utime(
+ plan.trash_path, ns=(info.st_atime_ns, published_backup[3]), follow_symlinks=False
+ )
+ fd = os.open(plan.trash_path, os.O_RDONLY | os.O_NOFOLLOW)
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+ require_payload(plan.trash_path, published_backup)
+ if expected_digest and payload_digest(plan.trash_path, stop) != expected_digest:
+ raise ValidationError("Published removal backup changed.")
+ _check_locations(plan)
+
+ await _file_work(publish_and_check)
+ assert previous is not None
+ return previous, proof.model_copy(update={"backup_fingerprint": published_backup})
+
+
+async def complete_removal(session: AsyncSession, operation_id: UUID) -> str:
+ """Finish committed work, then prune only its proven terminal workspaces."""
+ from pullbox.services.library_removal_workspaces import prune_terminal_removal
+
+ state = await _complete_removal(session, operation_id)
+ await prune_terminal_removal(session, operation_id)
+ return state
+
+
+async def _complete_removal(session: AsyncSession, operation_id: UUID) -> str:
+ """Own clean-session transactions; caller must commit detachment first.
+
+ Slow copying, verification and recursive cleanup run without a DB transaction.
+ Durable proof plus a non-expiring filesystem claim makes retries repeatable.
+ Retain-only historical intents never authorize destructive cleanup.
+ """
+ if session.new or session.dirty or session.deleted or session.in_nested_transaction():
+ raise ValidationError("Removal cleanup requires a clean, committed session.")
+ row = await _read(session, operation_id)
+ state, active, encoded = row.state, row.active, row.plan_json
+ await session.commit()
+ if state == "complete" and not active:
+ return state
+ if state != "detached" or not active:
+ raise ValidationError("Removal must have committed detachment before cleanup.")
+ plan = decode_removal(encoded, operation_id=str(operation_id))
+ if plan.disposition == "retain":
+ raise ValidationError("This retained removal does not authorize cleanup.")
+ async with removal_claim(plan):
+ try:
+ row = await _read(session, operation_id)
+ if row.plan_json != encoded or row.state != "detached" or not row.active:
+ raise ValidationError("Removal cleanup changed while acquiring its claim.")
+ previous = row.cleanup_json
+ proof = _decode_proof(previous, plan)
+ await session.commit()
+ if proof is None or proof.phase == "copying":
+ _check_locations(plan)
+ require_payload(plan.stage, plan.fingerprint)
+ if plan.disposition == "trash":
+ previous, proof = await _backup(session, plan, encoded, previous, proof)
+ if proof is None or proof.phase != "deleting":
+ if proof and proof.transfer == "copy":
+ require_payload(plan.stage, plan.fingerprint)
+ proof = (
+ proof.model_copy(update={"phase": "deleting"})
+ if proof
+ else CleanupProof(phase="deleting")
+ )
+ previous = await _save(session, plan, encoded, previous, proof)
+
+ def clean(stop: Event) -> None:
+ _check_locations(plan)
+ if plan.trash_path:
+ assert proof.backup_fingerprint is not None
+ require_payload(plan.trash_path, proof.backup_fingerprint)
+ if _fingerprint(plan.stage) is not None:
+ remove_payload(plan.stage, plan.fingerprint, stop, partial=True)
+ _check_locations(plan)
+
+ await _file_work(clean)
+ await _save(session, plan, encoded, previous, proof, complete=True)
+ return "complete"
+ except BaseException:
+ await session.rollback()
+ raise
diff --git a/src/pullbox/services/library_removal_files.py b/src/pullbox/services/library_removal_files.py
new file mode 100644
index 00000000..b2f5120c
--- /dev/null
+++ b/src/pullbox/services/library_removal_files.py
@@ -0,0 +1,239 @@
+"""Private removal work with bounded copies and cooperative cancellation."""
+
+import asyncio
+import hashlib
+import os
+import stat
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager, suppress
+from pathlib import Path
+from threading import Event
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.services.archive_metadata_binding import FileFingerprint
+from pullbox.services.archive_metadata_publication import _fingerprint
+from pullbox.services.library_conversion_files import matches, sync_directory
+from pullbox.services.library_mutation_coordination import finish_short_mutation
+from pullbox.services.library_removal import RemovalPlan, _check_locations
+
+
+def check_stop(stop: Event) -> None:
+ if stop.is_set():
+ raise InterruptedError("Library removal cleanup cancelled")
+
+
+def require_payload(path: Path, expected: FileFingerprint, *, partial: bool = False) -> None:
+ actual = _fingerprint(path)
+ if partial and stat.S_ISDIR(expected[5]):
+ valid = actual and actual[:2] == expected[:2] and actual[5] == expected[5]
+ else:
+ valid = matches(actual, expected)
+ if not valid:
+ raise ValidationError("The retained removal payload changed; review before retrying.")
+
+
+def _claim(plan: RemovalPlan) -> int:
+ # A filesystem lock spans slow work without holding SQLite's writer mutex.
+ # Failing closed is preferable to a time-based lease that can expire on a live worker.
+ if os.name != "posix":
+ raise ValidationError("Removal cleanup requires filesystem lock support.")
+ import fcntl
+
+ _check_locations(plan)
+ path = plan.stage.parent / "cleanup.lock"
+ fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
+ try:
+ info = os.fstat(fd)
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
+ raise ValidationError("Removal cleanup lock changed.")
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ _check_locations(plan)
+ if path.lstat().st_ino != info.st_ino:
+ raise ValidationError("Removal cleanup lock changed.")
+ return fd
+ except BaseException:
+ os.close(fd)
+ raise
+
+
+@asynccontextmanager
+async def removal_claim(plan: RemovalPlan) -> AsyncIterator[None]:
+ task = asyncio.create_task(asyncio.to_thread(_claim, plan))
+ fd: int | None = None
+ try:
+ try:
+ fd = await asyncio.shield(task)
+ except asyncio.CancelledError:
+ while not task.done():
+ with suppress(asyncio.CancelledError, Exception):
+ await asyncio.shield(task)
+ if not task.cancelled() and task.exception() is None:
+ fd = task.result()
+ raise
+ yield
+ finally:
+ if fd is not None:
+ await finish_short_mutation(asyncio.create_task(asyncio.to_thread(os.close, fd)))
+
+
+def create_backup_stage(plan: RemovalPlan) -> FileFingerprint:
+ _check_locations(plan)
+ assert plan.trash_stage is not None
+ if stat.S_ISDIR(plan.fingerprint[5]):
+ plan.trash_stage.mkdir(mode=0o700)
+ else:
+ fd = os.open(plan.trash_stage, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
+ os.close(fd)
+ sync_directory(plan.trash_stage.parent)
+ result = _fingerprint(plan.trash_stage)
+ assert result is not None
+ return result
+
+
+def _copy_file(source: Path, destination: Path, stop: Event, *, exists: bool = False) -> None:
+ check_stop(stop)
+ before = _fingerprint(source)
+ if before is None or not stat.S_ISREG(before[5]):
+ raise ValidationError("Removal backup source changed.")
+ source_fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW)
+ try:
+ if os.fstat(source_fd).st_ino != before[1]:
+ raise ValidationError("Removal backup source changed.")
+ target_fd = os.open(
+ destination,
+ os.O_WRONLY | os.O_NOFOLLOW | (os.O_TRUNC if exists else os.O_CREAT | os.O_EXCL),
+ stat.S_IMODE(before[5]) & 0o777,
+ )
+ with os.fdopen(target_fd, "wb") as output:
+ while chunk := os.read(source_fd, 1024 * 1024):
+ check_stop(stop)
+ output.write(chunk)
+ output.flush()
+ os.fsync(output.fileno())
+ finally:
+ os.close(source_fd)
+ if _fingerprint(source) != before:
+ raise ValidationError("Removal backup source changed during copying.")
+
+
+def copy_payload(plan: RemovalPlan, stop: Event) -> tuple[FileFingerprint, str]:
+ _check_locations(plan)
+ require_payload(plan.stage, plan.fingerprint)
+ assert plan.trash_stage is not None
+
+ def copy(source: Path, target: Path, depth: int, *, exists: bool = False) -> None:
+ check_stop(stop)
+ if depth > 128:
+ raise ValidationError("Removal backup contains too many nested folders.")
+ mode = source.lstat().st_mode
+ if source.lstat().st_dev != plan.fingerprint[0]:
+ raise ValidationError("Removal backup cannot cross a nested filesystem mount.")
+ if stat.S_ISDIR(mode):
+ if not exists:
+ target.mkdir(mode=stat.S_IMODE(mode) & 0o777)
+ with os.scandir(source) as entries:
+ for entry in entries:
+ copy(source / entry.name, target / entry.name, depth + 1)
+ sync_directory(target)
+ elif stat.S_ISREG(mode):
+ _copy_file(source, target, stop, exists=exists)
+ elif stat.S_ISLNK(mode):
+ os.symlink(os.readlink(source), target)
+ else:
+ raise ValidationError("Removal backup contains an unsupported filesystem entry.")
+
+ copy(plan.stage, plan.trash_stage, 0, exists=True)
+ digest = payload_digest(plan.stage, stop)
+ if payload_digest(plan.trash_stage, stop) != digest:
+ raise ValidationError("Removal backup does not match the retained source.")
+ require_payload(plan.stage, plan.fingerprint)
+ _check_locations(plan)
+ result = _fingerprint(plan.trash_stage)
+ assert result is not None
+ return result, digest
+
+
+def payload_digest(path: Path, stop: Event) -> str:
+ digest = hashlib.sha256()
+
+ def visit(item: Path, depth: int) -> None:
+ check_stop(stop)
+ if depth > 128:
+ raise ValidationError("Removal backup contains too many nested folders.")
+ before = _fingerprint(item)
+ if before is None:
+ raise ValidationError("Removal backup changed.")
+ mode = before[5]
+ if stat.S_ISDIR(mode):
+ digest.update(b"directory\0")
+ children: list[Path] = []
+ for child in item.iterdir():
+ check_stop(stop)
+ if len(children) >= 100_000:
+ raise ValidationError("Removal backup folder exceeds the entry limit.")
+ children.append(child)
+ for child in sorted(children):
+ name = os.fsencode(child.name)
+ digest.update(len(name).to_bytes(8, "big") + name)
+ visit(child, depth + 1)
+ digest.update(b"end\0")
+ elif stat.S_ISREG(mode):
+ digest.update(b"file\0" + before[2].to_bytes(8, "big"))
+ fd = os.open(item, os.O_RDONLY | os.O_NOFOLLOW)
+ try:
+ if os.fstat(fd).st_ino != before[1]:
+ raise ValidationError("Removal backup changed.")
+ while chunk := os.read(fd, 1024 * 1024):
+ check_stop(stop)
+ digest.update(chunk)
+ finally:
+ os.close(fd)
+ elif stat.S_ISLNK(mode):
+ link = os.fsencode(os.readlink(item))
+ digest.update(b"link\0" + len(link).to_bytes(8, "big") + link)
+ else:
+ raise ValidationError("Removal backup contains an unsupported filesystem entry.")
+ if _fingerprint(item) != before:
+ raise ValidationError("Removal backup changed while checking it.")
+
+ visit(path, 0)
+ return digest.hexdigest()
+
+
+def remove_payload(path: Path, expected: FileFingerprint, stop: Event, *, partial: bool) -> None:
+ """Unlink within directory descriptors; never follow a nested symlink."""
+ check_stop(stop)
+ require_payload(path, expected, partial=partial)
+
+ def remove(parent: int, name: str, depth: int) -> None:
+ check_stop(stop)
+ if depth > 128:
+ raise ValidationError("Removal cleanup contains too many nested folders.")
+ before = os.stat(name, dir_fd=parent, follow_symlinks=False)
+ if before.st_dev != expected[0]:
+ raise ValidationError("Removal cleanup cannot cross a nested filesystem mount.")
+ if not stat.S_ISDIR(before.st_mode):
+ os.unlink(name, dir_fd=parent)
+ return
+ fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
+ try:
+ opened = os.fstat(fd)
+ if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
+ raise ValidationError("Removal cleanup folder changed.")
+ with os.scandir(fd) as entries:
+ for entry in entries:
+ remove(fd, entry.name, depth + 1)
+ os.fsync(fd)
+ current = os.stat(name, dir_fd=parent, follow_symlinks=False)
+ if (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino):
+ raise ValidationError("Removal cleanup folder changed.")
+ os.rmdir(name, dir_fd=parent)
+ finally:
+ os.close(fd)
+
+ parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ try:
+ remove(parent, path.name, 0)
+ os.fsync(parent)
+ finally:
+ os.close(parent)
diff --git a/src/pullbox/services/library_removal_workspaces.py b/src/pullbox/services/library_removal_workspaces.py
new file mode 100644
index 00000000..8bd66abd
--- /dev/null
+++ b/src/pullbox/services/library_removal_workspaces.py
@@ -0,0 +1,168 @@
+"""Prune proven empty terminal workspaces, never payloads or public paths."""
+
+import asyncio
+import os
+import stat
+from contextlib import ExitStack
+from itertools import islice
+from uuid import UUID
+
+import structlog
+from sqlalchemy import case, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.models.library_removal import LibraryRemoval
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+)
+from pullbox.services.library_removal import RemovalPlan, decode_removal
+from pullbox.services.library_trash_files import _open_checked
+
+logger = structlog.get_logger(__name__)
+
+
+def _identity(info: os.stat_result) -> tuple[int, int, int]:
+ return info.st_dev, info.st_ino, info.st_mode
+
+
+def prune_workspaces(plan: RemovalPlan) -> None:
+ """Bounded descriptor-relative rmdir; refuse unknown contents and live locks."""
+ if os.name != "posix":
+ raise ValidationError("Safe workspace cleanup requires filesystem lock support.")
+ import fcntl
+
+ paths = [plan.stage.parent]
+ if plan.trash_stage:
+ paths.append(plan.trash_stage.parent)
+ expected = {path: (device, inode, mode) for path, device, inode, mode in plan.directories}
+ with ExitStack() as stack:
+ opened = []
+ for path in paths:
+ parent = _open_checked(path.parent, plan.directories)
+ stack.callback(os.close, parent)
+ try:
+ info = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
+ except FileNotFoundError:
+ continue
+ if _identity(info) != expected.get(path) or stat.S_IMODE(info.st_mode) != 0o700:
+ raise ValidationError("Removal workspace changed; it was left untouched.")
+ fd = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
+ stack.callback(os.close, fd)
+ if _identity(os.fstat(fd)) != _identity(info):
+ raise ValidationError("Removal workspace changed while opening it.")
+ with os.scandir(fd) as entries:
+ names = [entry.name for entry in islice(entries, 2)]
+ lock = None
+ if names:
+ if path != plan.stage.parent or names != ["cleanup.lock"]:
+ raise ValidationError(
+ "Removal workspace contains unproven files; kept for review."
+ )
+ lock = os.open("cleanup.lock", os.O_RDWR | os.O_NOFOLLOW, dir_fd=fd)
+ stack.callback(os.close, lock)
+ lock_info = os.fstat(lock)
+ if (
+ not stat.S_ISREG(lock_info.st_mode)
+ or stat.S_IMODE(lock_info.st_mode) != 0o600
+ or lock_info.st_size != 0
+ or lock_info.st_nlink != 1
+ ):
+ raise ValidationError("Removal workspace lock changed; kept for review.")
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ opened.append((path, parent, fd, info, lock))
+ # Inspect both workspaces before pruning either. rmdir never removes contents.
+ for path, parent, fd, info, lock in reversed(opened):
+ if _identity(os.stat(path.name, dir_fd=parent, follow_symlinks=False)) != _identity(
+ info
+ ):
+ raise ValidationError("Removal workspace changed before cleanup.")
+ if lock is not None:
+ actual = os.stat("cleanup.lock", dir_fd=fd, follow_symlinks=False)
+ held = os.fstat(lock)
+ if _identity(actual) != _identity(held) or actual.st_size or actual.st_nlink != 1:
+ raise ValidationError("Removal workspace lock changed before cleanup.")
+ os.unlink("cleanup.lock", dir_fd=fd)
+ os.fsync(fd)
+ os.rmdir(path.name, dir_fd=parent)
+ os.fsync(parent)
+
+
+async def prune_terminal_removal(session: AsyncSession, operation_id: UUID) -> bool:
+ """Own an idle session; cleanup failure does not undo a completed removal."""
+ if session.in_transaction() or session.new or session.dirty or session.deleted:
+ raise ValidationError("Workspace cleanup requires an idle, committed session.")
+ try:
+ async with session.begin():
+ await lock_file_mutation_admission(session)
+ row = (
+ await session.execute(
+ select(
+ LibraryRemoval.id,
+ LibraryRemoval.state,
+ LibraryRemoval.active,
+ LibraryRemoval.workspace_cleaned,
+ case(
+ (
+ func.length(LibraryRemoval.plan_json) <= 65536,
+ LibraryRemoval.plan_json,
+ ),
+ else_=None,
+ ),
+ )
+ .where(LibraryRemoval.operation_id == str(operation_id))
+ .with_for_update()
+ )
+ ).one_or_none()
+ if row is None or row.active or row.state not in {"complete", "abandoned"}:
+ return False
+ if row.workspace_cleaned:
+ return True
+ plan = decode_removal(row[4], operation_id=str(operation_id))
+ await finish_short_mutation(
+ asyncio.create_task(asyncio.to_thread(prune_workspaces, plan))
+ )
+ await session.execute(
+ update(LibraryRemoval)
+ .where(LibraryRemoval.id == row.id)
+ .values(workspace_cleaned=True)
+ )
+ return True
+ except (OSError, ValueError, TypeError, ValidationError):
+ logger.warning(
+ "library_removal_workspace_retained", operation_id=str(operation_id), exc_info=True
+ )
+ return False
+
+
+async def prune_terminal_removals(session: AsyncSession) -> None:
+ """Bounded startup repair; successful cleanup is not rescanned next startup."""
+ if session.in_transaction() or session.new or session.dirty or session.deleted:
+ raise ValidationError("Workspace recovery requires an idle, committed session.")
+ last_id = 0
+ while True:
+ rows = (
+ await session.execute(
+ select(LibraryRemoval.id, LibraryRemoval.operation_id)
+ .where(
+ LibraryRemoval.id > last_id,
+ LibraryRemoval.workspace_cleaned.is_(False),
+ LibraryRemoval.active.is_(False),
+ LibraryRemoval.state.in_(["complete", "abandoned"]),
+ )
+ .order_by(LibraryRemoval.id)
+ .limit(8)
+ )
+ ).all()
+ await session.commit()
+ if not rows:
+ return
+ for row_id, operation in rows:
+ last_id = row_id
+ try:
+ operation_id = UUID(operation)
+ except ValueError:
+ logger.warning("library_removal_workspace_invalid_operation", removal_id=row_id)
+ continue
+ await prune_terminal_removal(session, operation_id)
diff --git a/src/pullbox/services/library_rename_service.py b/src/pullbox/services/library_rename_service.py
index 06e8c765..3d4846e3 100644
--- a/src/pullbox/services/library_rename_service.py
+++ b/src/pullbox/services/library_rename_service.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
import os
from dataclasses import dataclass
from datetime import UTC, datetime
@@ -12,9 +13,15 @@
from sqlalchemy import or_, select
from pullbox.core.exceptions import ValidationError
+from pullbox.core.file_publication import rename_path_without_overwrite
from pullbox.core.library_file_ownership import require_mutable_library_target
from pullbox.models.library import LibraryFile
from pullbox.models.series import Series
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+ require_no_archive_publication,
+)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -40,13 +47,21 @@ def _rename_path(source: Path, target: Path) -> None:
if not is_case_only and target.exists():
raise ValidationError("A file or folder with that name already exists.")
- if is_case_only:
- temp_path = source.parent / f".rename_tmp_{os.urandom(8).hex()}"
- source.rename(temp_path)
- temp_path.rename(target)
- return
-
- source.rename(target)
+ try:
+ if is_case_only and target.exists():
+ if not source.samefile(target):
+ raise FileExistsError
+ temp_path = source.parent / f".rename_tmp_{os.urandom(8).hex()}"
+ rename_path_without_overwrite(source, temp_path)
+ try:
+ rename_path_without_overwrite(temp_path, target)
+ except OSError:
+ rename_path_without_overwrite(temp_path, source)
+ raise
+ else:
+ rename_path_without_overwrite(source, target)
+ except FileExistsError:
+ raise ValidationError("A file or folder with that name already exists.") from None
async def _sync_file_record(session: AsyncSession, *, before_path: str, after_path: str) -> None:
@@ -69,7 +84,11 @@ async def _sync_folder_records(session: AsyncSession, *, before_path: str, after
new_prefix = after_path.rstrip("/")
series_result = await session.execute(
- select(Series).where(or_(Series.path == old_prefix, Series.path.like(f"{old_prefix}/%")))
+ select(Series).where(
+ or_(
+ Series.path == old_prefix, Series.path.startswith(f"{old_prefix}/", autoescape=True)
+ )
+ )
)
for series in series_result.scalars().all():
if not series.path:
@@ -79,7 +98,10 @@ async def _sync_folder_records(session: AsyncSession, *, before_path: str, after
file_result = await session.execute(
select(LibraryFile).where(
- or_(LibraryFile.file_path == old_prefix, LibraryFile.file_path.like(f"{old_prefix}/%"))
+ or_(
+ LibraryFile.file_path == old_prefix,
+ LibraryFile.file_path.startswith(f"{old_prefix}/", autoescape=True),
+ )
)
)
for library_file in file_result.scalars().all():
@@ -91,6 +113,29 @@ async def _sync_folder_records(session: AsyncSession, *, before_path: str, after
library_file.file_name = Path(next_path).name
+async def _destination_is_registered(session: AsyncSession, target: Path, kind: str) -> bool:
+ prefix = str(target).rstrip("/")
+ file_clause = LibraryFile.file_path == prefix
+ if kind == "folder":
+ file_clause |= LibraryFile.file_path.startswith(f"{prefix}/", autoescape=True)
+ if await session.scalar(select(LibraryFile.id).where(file_clause).limit(1)) is not None:
+ return True
+ if kind == "folder":
+ return (
+ await session.scalar(
+ select(Series.id)
+ .where(
+ or_(
+ Series.path == prefix, Series.path.startswith(f"{prefix}/", autoescape=True)
+ )
+ )
+ .limit(1)
+ )
+ is not None
+ )
+ return False
+
+
async def rename_library_entry(
session: AsyncSession,
*,
@@ -99,46 +144,85 @@ async def rename_library_entry(
kind: str,
) -> LibraryRenameOutcome:
"""Rename a single Library browser target and sync tracked DB paths."""
- renamed = False
+ renamed_identity: tuple[int, int] | None = None
+ committed = False
+
+ def rename() -> None:
+ nonlocal renamed_identity
+ info = source.lstat()
+ _rename_path(source, target)
+ renamed_identity = info.st_dev, info.st_ino
+
+ async def commit() -> None:
+ nonlocal committed
+ await session.commit()
+ committed = True
+
+ async def restore() -> bool:
+ await session.rollback()
+ if renamed_identity is None or committed:
+ return False
+ try:
+ # Rollback may have released DB locks. Reacquire before compensation.
+ await lock_file_mutation_admission(session)
+ # A lost commit acknowledgment is not proof that the commit failed.
+ if await _destination_is_registered(session, target, kind):
+ return False
+ await require_no_archive_publication(
+ session, source, target, include_descendants=kind == "folder"
+ )
+ await require_mutable_library_target(
+ session, target, include_descendants=kind == "folder", operation="renamed"
+ )
+
+ def restore_owned_path() -> bool:
+ if source.exists() or not target.exists():
+ return False
+ info = target.lstat()
+ if (info.st_dev, info.st_ino) != renamed_identity:
+ return False
+ _rename_path(target, source)
+ return True
+
+ return await finish_short_mutation(
+ asyncio.create_task(asyncio.to_thread(restore_owned_path))
+ )
+ except Exception:
+ logger.exception(
+ "library_rename_rollback_failed", source_path=str(source), target_path=str(target)
+ )
+ return False
+ finally:
+ await session.rollback()
try:
+ await lock_file_mutation_admission(session)
+ await require_no_archive_publication(
+ session, source, target, include_descendants=kind == "folder"
+ )
await require_mutable_library_target(
session,
source,
include_descendants=kind == "folder",
operation="renamed",
)
- _rename_path(source, target)
- renamed = True
+ await finish_short_mutation(asyncio.create_task(asyncio.to_thread(rename)))
if kind == "file":
await _sync_file_record(session, before_path=str(source), after_path=str(target))
else:
await _sync_folder_records(session, before_path=str(source), after_path=str(target))
- await session.commit()
+ await finish_short_mutation(asyncio.create_task(commit()))
return LibraryRenameOutcome(
kind=kind,
source_path=str(source),
target_path=str(target),
)
- except ValidationError:
- await session.rollback()
- raise
- except Exception as exc:
- await session.rollback()
- restored = False
- if renamed and target.exists() and not source.exists():
- try:
- _rename_path(target, source)
- restored = True
- except Exception:
- logger.exception(
- "library_rename_rollback_failed",
- source_path=str(source),
- target_path=str(target),
- )
-
+ except BaseException as exc:
+ restored = await finish_short_mutation(asyncio.create_task(restore()))
+ if isinstance(exc, ValidationError) or not isinstance(exc, Exception):
+ raise
message = "Rename could not be completed."
if restored:
message += " The original name was restored."
diff --git a/src/pullbox/services/library_trash_cleanup.py b/src/pullbox/services/library_trash_cleanup.py
new file mode 100644
index 00000000..3f1a3f21
--- /dev/null
+++ b/src/pullbox/services/library_trash_cleanup.py
@@ -0,0 +1,165 @@
+"""Coordinated trash cleanup with short, explicitly owned transactions."""
+
+import asyncio
+import stat
+import time
+from dataclasses import dataclass
+from itertools import islice
+from pathlib import Path
+
+import structlog
+from sqlalchemy import case, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.models import LibraryFile, LibraryRoot
+from pullbox.models.library_removal import LibraryRemoval
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+ require_no_archive_publication,
+)
+from pullbox.services.library_removal import decode_removal, trash_path_key
+from pullbox.services.library_removal_cleanup import _decode_proof
+from pullbox.services.library_trash_files import TrashEntry, remove_entry, walk_trash
+
+logger = structlog.get_logger(__name__)
+_PAGE_SIZE = 64
+
+
+@dataclass
+class TrashCleanupResult:
+ deleted_entries: int = 0
+ retained_entries: int = 0
+
+
+async def _recent_trash_tree(session: AsyncSession, entry: TrashEntry, cutoff: int) -> bool:
+ ancestors = (entry.path, *entry.path.parents)
+ keys = [trash_path_key(path) for path in ancestors]
+ last_id = 0
+ while True:
+ rows = (
+ await session.execute(
+ select(
+ LibraryRemoval.id,
+ LibraryRemoval.trash_path_key,
+ case(
+ (func.length(LibraryRemoval.plan_json) <= 65536, LibraryRemoval.plan_json),
+ else_=None,
+ ),
+ case(
+ (
+ func.length(LibraryRemoval.cleanup_json) <= 4096,
+ LibraryRemoval.cleanup_json,
+ ),
+ else_=None,
+ ),
+ )
+ .where(
+ LibraryRemoval.id > last_id,
+ LibraryRemoval.trash_path_key.in_(keys),
+ LibraryRemoval.state == "complete",
+ LibraryRemoval.active.is_(False),
+ )
+ .order_by(LibraryRemoval.id)
+ .limit(8)
+ )
+ ).all()
+ if not rows:
+ return False
+ for row_id, key, encoded, evidence in rows:
+ last_id = row_id
+ if encoded is None:
+ raise ValidationError("Trash receipt needs review before retention cleanup.")
+ plan = decode_removal(encoded)
+ if plan.trash_path not in ancestors or trash_path_key(plan.trash_path) != key:
+ raise ValidationError("Trash receipt path changed; review it before cleanup.")
+ proof = _decode_proof(evidence, plan)
+ if proof is None or proof.trash_mtime_ns is None:
+ raise ValidationError("Trash receipt has no verified retention time.")
+ if proof.trash_mtime_ns > cutoff:
+ return True
+
+
+async def cleanup_trash(
+ session: AsyncSession, trash_dir: Path, *, retention_days: int | None = None
+) -> TrashCleanupResult:
+ """Own an idle session; commit each short deletion, never a whole tree walk.
+
+ Callers must finish their read-only setup transaction first. Refuse pending
+ transactions rather than accidentally committing unrelated request changes.
+ """
+ if session.in_transaction() or session.new or session.dirty or session.deleted:
+ raise ValidationError("Trash cleanup requires an idle session with committed setup.")
+ if retention_days is not None and not 1 <= retention_days <= 365:
+ raise ValidationError("Trash retention must be between 1 and 365 days.")
+ trash_dir = trash_dir.absolute()
+ async with session.begin():
+ if await session.scalar(select(LibraryRoot.id).where(LibraryRoot.path == str(trash_dir))):
+ raise ValidationError(
+ "A library root cannot be emptied as trash. Choose a separate trash folder."
+ )
+ cutoff = time.time_ns() - retention_days * 86400 * 1_000_000_000 if retention_days else None
+ result = TrashCleanupResult()
+ entries = walk_trash(trash_dir)
+ try:
+ while page := await finish_short_mutation(
+ asyncio.create_task(asyncio.to_thread(lambda: list(islice(entries, _PAGE_SIZE))))
+ ):
+ for entry in page:
+ if entry.protected:
+ result.retained_entries += 1
+ continue
+ try:
+ async with session.begin():
+ await lock_file_mutation_admission(session)
+ await require_no_archive_publication(
+ session, entry.path, include_descendants=True
+ )
+ if await session.scalar(
+ select(LibraryRoot.id).where(
+ LibraryRoot.path.in_(
+ [
+ str(path)
+ for path in (entry.path, *entry.path.parents)
+ if path.is_relative_to(trash_dir)
+ ]
+ )
+ )
+ ):
+ raise ValidationError(
+ "A library root inside this trash location was left untouched."
+ )
+ if await session.scalar(
+ select(LibraryFile.id).where(LibraryFile.file_path == str(entry.path))
+ ):
+ raise ValidationError(
+ "A registered library file is not disposable trash."
+ )
+ if cutoff is not None and (
+ await _recent_trash_tree(session, entry, cutoff)
+ or (
+ not stat.S_ISDIR(entry.fingerprint[5])
+ and entry.fingerprint[3] > cutoff
+ )
+ ):
+ continue
+ removed = await finish_short_mutation(
+ asyncio.create_task(asyncio.to_thread(remove_entry, entry))
+ )
+ result.deleted_entries += int(removed)
+ except (OSError, ValueError, ValidationError) as exc:
+ result.retained_entries += 1
+ logger.warning(
+ "utility_trash_entry_retained", path=str(entry.path), error=str(exc)
+ )
+ finally:
+ await finish_short_mutation(asyncio.create_task(asyncio.to_thread(entries.close)))
+ logger.info(
+ "utility_trash_cleanup_complete",
+ directory=str(trash_dir),
+ retention_days=retention_days,
+ deleted_entries=result.deleted_entries,
+ retained_entries=result.retained_entries,
+ )
+ return result
diff --git a/src/pullbox/services/library_trash_files.py b/src/pullbox/services/library_trash_files.py
new file mode 100644
index 00000000..191f7cb5
--- /dev/null
+++ b/src/pullbox/services/library_trash_files.py
@@ -0,0 +1,139 @@
+"""Bounded, non-following trash traversal and identity-checked leaf deletion."""
+
+import errno
+import os
+import stat
+from collections.abc import Generator, Iterator
+from dataclasses import dataclass
+from pathlib import Path
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.services.archive_metadata_binding import FileFingerprint
+from pullbox.services.library_conversion_files import directories
+
+_DIRECTORY_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
+_PRIVATE_PREFIXES = (".pullbox-removal-", ".pullbox-conversion-", ".pullbox-metadata-")
+type DirectoryProof = tuple[Path, int, int, int]
+
+
+def _fingerprint(info: os.stat_result) -> FileFingerprint:
+ return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns, info.st_mode
+
+
+@dataclass(frozen=True)
+class TrashEntry:
+ path: Path
+ fingerprint: FileFingerprint
+ parents: tuple[DirectoryProof, ...]
+ protected: bool = False
+
+
+def _open_checked(path: Path, parents: tuple[DirectoryProof, ...]) -> int:
+ expected = {item[0]: item[1:] for item in parents}
+ fd = os.open(path.anchor, _DIRECTORY_FLAGS)
+ current = Path(path.anchor)
+ try:
+ for part in (None, *path.parts[1:]):
+ if part is not None:
+ new_fd = os.open(part, _DIRECTORY_FLAGS, dir_fd=fd)
+ os.close(fd)
+ fd = new_fd
+ current /= part
+ info = os.fstat(fd)
+ if (info.st_dev, info.st_ino, info.st_mode) != expected.get(current):
+ raise ValidationError(
+ "Trash directory changed; recheck its location before retrying."
+ )
+ return fd
+ except BaseException:
+ os.close(fd)
+ raise
+
+
+def walk_trash(root: Path) -> Generator[TrashEntry, None, None]:
+ """Keep at most one open iterator per depth; private workspaces are never entered."""
+ if os.name != "posix":
+ raise ValidationError("Safe trash cleanup is not available on this platform yet.")
+ if root == Path(root.anchor) or root.resolve() != root:
+ raise ValidationError(
+ "Trash must be a real directory, not a filesystem root or symbolic link."
+ )
+ if not root.exists():
+ return
+ proof = directories(root / ".trash-probe")
+ root_fd = _open_checked(root, proof)
+ device = os.fstat(root_fd).st_dev
+
+ def visit(
+ fd: int, path: Path, parents: tuple[DirectoryProof, ...], depth: int
+ ) -> Iterator[TrashEntry]:
+ with os.scandir(fd) as children:
+ for child in children:
+ try:
+ info = child.stat(follow_symlinks=False)
+ except FileNotFoundError:
+ continue
+ blocked = (
+ child.name.startswith(_PRIVATE_PREFIXES) or info.st_dev != device or depth > 128
+ )
+ entry = TrashEntry(path / child.name, _fingerprint(info), parents, blocked)
+ if not blocked and stat.S_ISDIR(info.st_mode):
+ try:
+ nested = os.open(child.name, _DIRECTORY_FLAGS, dir_fd=fd)
+ except OSError:
+ yield TrashEntry(entry.path, entry.fingerprint, parents, True)
+ continue
+ try:
+ actual = os.fstat(nested)
+ if (actual.st_dev, actual.st_ino, actual.st_mode) != (
+ info.st_dev,
+ info.st_ino,
+ info.st_mode,
+ ):
+ yield TrashEntry(entry.path, entry.fingerprint, parents, True)
+ continue
+ yield from visit(
+ nested,
+ entry.path,
+ (*parents, (entry.path, info.st_dev, info.st_ino, info.st_mode)),
+ depth + 1,
+ )
+ finally:
+ os.close(nested)
+ yield entry
+
+ try:
+ yield from visit(root_fd, root, proof, 0)
+ finally:
+ os.close(root_fd)
+
+
+def remove_entry(entry: TrashEntry) -> bool:
+ """Only unlink one unchanged entry or prune one empty directory, never recurse."""
+ fd = _open_checked(entry.path.parent, entry.parents)
+ try:
+ try:
+ info = os.stat(entry.path.name, dir_fd=fd, follow_symlinks=False)
+ except FileNotFoundError:
+ return False
+ expected = entry.fingerprint
+ directory = stat.S_ISDIR(expected[5])
+ if (info.st_dev, info.st_ino, info.st_mode) != (expected[0], expected[1], expected[5]) or (
+ not directory and _fingerprint(info) != expected
+ ):
+ raise ValidationError("Trash entry changed during cleanup; it was left untouched.")
+ if directory:
+ try:
+ os.rmdir(entry.path.name, dir_fd=fd)
+ except OSError as exc:
+ if exc.errno in {errno.ENOTEMPTY, errno.EEXIST}:
+ return False
+ raise
+ elif stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
+ os.unlink(entry.path.name, dir_fd=fd)
+ else:
+ raise ValidationError("Unsupported trash entry was left untouched.")
+ os.fsync(fd)
+ return True
+ finally:
+ os.close(fd)
diff --git a/src/pullbox/services/metadata_account_admission.py b/src/pullbox/services/metadata_account_admission.py
new file mode 100644
index 00000000..5dd51938
--- /dev/null
+++ b/src/pullbox/services/metadata_account_admission.py
@@ -0,0 +1,276 @@
+"""Short independent account-state transactions around, never across, provider I/O."""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import math
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING
+
+import structlog
+from sqlalchemy import select, update
+from sqlalchemy.dialects.postgresql import insert as pg_insert
+from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
+
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.models.metadata_source_account import MetadataSourceAccount as Account
+from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncIterator
+
+ from pullbox.services.metadata_sources import SourceRuntime
+
+logger = structlog.get_logger(__name__)
+_REMOTE = {MetadataSource.COMICVINE_API, MetadataSource.METRON_API, MetadataSource.GCD_API_V2}
+_FAILURES = {
+ SourceStatus.RATE_LIMITED,
+ SourceStatus.TIMEOUT,
+ SourceStatus.UNAVAILABLE,
+ SourceStatus.AUTHENTICATION_FAILED,
+}
+
+
+def account_key(runtime: SourceRuntime) -> str:
+ # Account identity only, not password verification. Priority edits cannot reset a hold.
+ token = runtime.credential.get_secret_value() if runtime.credential else ""
+ return hashlib.sha256(f"{runtime.policy.source.value}:{token}".encode()).hexdigest()
+
+
+@dataclass
+class AccountAttempt:
+ blocked: SourceOutcome | None = None
+ key: str = ""
+ revision: int = 0
+ probe: bool = False
+ started: bool = False
+ outcome: SourceOutcome | None = None
+ authentication_probe: bool = False
+ config_key: str = ""
+
+
+def source_account_admission(
+ session: AsyncSession, *, gcd_api_enabled: bool
+) -> MetadataAccountAdmission | None:
+ if not isinstance(session.bind, AsyncEngine):
+ return None
+ return MetadataAccountAdmission(
+ async_sessionmaker(session.bind, class_=type(session), expire_on_commit=False),
+ gcd_api_enabled=gcd_api_enabled,
+ )
+
+
+@asynccontextmanager
+async def account_request(
+ runtime: SourceRuntime,
+ *,
+ slots: asyncio.Semaphore,
+ deadline: float,
+ retry_authentication: bool = False,
+) -> AsyncIterator[AccountAttempt]:
+ async with asyncio.timeout_at(deadline):
+ await slots.acquire()
+ try:
+ gate = runtime.account_admission
+ if gate is None or runtime.policy.source not in _REMOTE or runtime.credential is None:
+ yield AccountAttempt()
+ return
+ attempt = await gate.admit(runtime, retry_authentication=retry_authentication)
+ try:
+ yield attempt
+ finally:
+ if attempt.blocked is None:
+ await gate.finish(runtime, attempt)
+ finally:
+ slots.release()
+
+
+class MetadataAccountAdmission:
+ """Account transport state owns its sessions, never commits a library caller's work."""
+
+ def __init__(self, factory: async_sessionmaker[AsyncSession], *, gcd_api_enabled: bool) -> None:
+ self.factory = factory
+ self.gcd_api_enabled = gcd_api_enabled
+
+ async def admit(
+ self, runtime: SourceRuntime, *, retry_authentication: bool = False
+ ) -> AccountAttempt:
+ from pullbox.models.metadata_series_retry import MetadataSeriesRetry
+ from pullbox.services.metadata_series_retry import config_key
+ from pullbox.services.metadata_sources import load_source_runtime
+
+ source, key = runtime.policy.source, account_key(runtime)
+ try:
+ async with asyncio.timeout(2), self.factory.begin() as session:
+ # Recheck a captured registry before it can reset another credential's state.
+ current = next(
+ item
+ for item in await load_source_runtime(
+ session, gcd_api_enabled=self.gcd_api_enabled
+ )
+ if item.policy.source is source
+ )
+ if current.unavailable or account_key(current) != key:
+ return AccountAttempt(
+ blocked=SourceOutcome(
+ source=source,
+ status=current.unavailable or SourceStatus.UNAVAILABLE,
+ retry_after_seconds=60,
+ )
+ )
+ insert = (
+ pg_insert if session.get_bind().dialect.name == "postgresql" else sqlite_insert
+ )
+ await session.execute(
+ insert(Account)
+ .values(
+ source=source.value,
+ account_key=key,
+ revision=1,
+ )
+ .on_conflict_do_nothing(index_elements=[Account.source])
+ )
+ row = await session.scalar(
+ select(Account).where(Account.source == source.value).with_for_update()
+ )
+ assert row is not None
+ if row.account_key != key:
+ row.account_key = key
+ row.status = row.retry_at = row.lease_until = None
+ row.revision += 1
+ now = datetime.now(UTC)
+ if (
+ retry_authentication
+ and row.status is None
+ and await session.scalar(
+ select(MetadataSeriesRetry.id)
+ .where(
+ MetadataSeriesRetry.source == source.value,
+ MetadataSeriesRetry.config_key == config_key(current),
+ MetadataSeriesRetry.status == SourceStatus.AUTHENTICATION_FAILED.value,
+ )
+ .limit(1)
+ )
+ ):
+ # Older deferred work can predate the account-state table.
+ row.status = SourceStatus.AUTHENTICATION_FAILED.value
+ authentication_probe = row.status == SourceStatus.AUTHENTICATION_FAILED.value
+ if authentication_probe and not retry_authentication:
+ return AccountAttempt(
+ blocked=SourceOutcome(
+ source=source, status=SourceStatus.AUTHENTICATION_FAILED
+ )
+ )
+ deadline = max(
+ (item for item in (row.retry_at, row.lease_until) if item), default=None
+ )
+ if row.status is not None and deadline is not None and deadline > now:
+ return AccountAttempt(
+ blocked=SourceOutcome(
+ source=source,
+ status=SourceStatus(row.status),
+ retry_after_seconds=max(1, math.ceil((deadline - now).total_seconds())),
+ )
+ )
+ probe = row.status is not None
+ if probe:
+ row.lease_until = now + timedelta(seconds=45)
+ row.revision += 1
+ return AccountAttempt(
+ key=key,
+ revision=row.revision,
+ probe=probe,
+ authentication_probe=authentication_probe,
+ config_key=config_key(current),
+ )
+ except (SQLAlchemyError, TimeoutError):
+ logger.warning("metadata_account_admission_unavailable", source=source.value)
+ return AccountAttempt(
+ blocked=SourceOutcome(
+ source=source, status=SourceStatus.UNAVAILABLE, retry_after_seconds=5
+ )
+ )
+
+ async def finish(self, runtime: SourceRuntime, attempt: AccountAttempt) -> None:
+ from pullbox.models.metadata_series_retry import MetadataSeriesRetry
+
+ outcome = attempt.outcome if attempt.started else None
+ failed = outcome is not None and outcome.status in _FAILURES
+ if not failed and not attempt.probe:
+ return
+ wake_tasks: list[str] = []
+ try:
+ async with asyncio.timeout(2), self.factory.begin() as session:
+ due = None
+ statement = (
+ update(Account)
+ .where(
+ Account.source == runtime.policy.source.value,
+ Account.account_key == attempt.key,
+ Account.revision == attempt.revision,
+ )
+ .values(lease_until=None, revision=Account.revision + 1)
+ )
+ if failed and outcome is not None:
+ delay = outcome.retry_after_seconds or (
+ 3600 if outcome.status is SourceStatus.RATE_LIMITED else 300
+ )
+ due = (
+ None
+ if outcome.status is SourceStatus.AUTHENTICATION_FAILED
+ else (datetime.now(UTC) + timedelta(seconds=max(60, min(delay, 7 * 86400))))
+ )
+ statement = statement.values(status=outcome.status.value, retry_at=due)
+ elif outcome is not None and (
+ not attempt.authentication_probe or outcome.status is SourceStatus.OK
+ ):
+ statement = statement.values(status=None, retry_at=None)
+ # Cancellation only releases the probe lease, retaining the prior failure.
+ changed = await session.scalar(statement.returning(Account.id))
+ if (
+ changed is not None
+ and attempt.authentication_probe
+ and outcome is not None
+ and (outcome.status is SourceStatus.OK or due is not None)
+ ):
+ criteria = (
+ MetadataSeriesRetry.source == runtime.policy.source.value,
+ MetadataSeriesRetry.config_key == attempt.config_key,
+ MetadataSeriesRetry.status == SourceStatus.AUTHENTICATION_FAILED.value,
+ )
+ wake_tasks = list(
+ await session.scalars(
+ select(MetadataSeriesRetry.task_id)
+ .where(MetadataSeriesRetry.source == runtime.policy.source.value)
+ .distinct()
+ )
+ )
+ await session.execute(
+ update(MetadataSeriesRetry)
+ .where(*criteria)
+ .values(
+ status=(
+ outcome.status.value if due else SourceStatus.UNAVAILABLE.value
+ ),
+ retry_at=due or datetime.now(UTC),
+ revision=MetadataSeriesRetry.revision + 1,
+ )
+ )
+ if wake_tasks:
+ from pullbox.tasks.metadata_sweep_state import MetadataSweep, schedule_sweep
+
+ for task_id in wake_tasks:
+ try:
+ schedule_sweep(task_id, MetadataSweep(active=True))
+ except Exception:
+ # Durable retry deadlines also restore this wakeup after restart.
+ logger.warning("metadata_retry_wakeup_failed", task_id=task_id)
+ except (SQLAlchemyError, TimeoutError):
+ logger.warning(
+ "metadata_account_outcome_store_failed", source=runtime.policy.source.value
+ )
diff --git a/src/pullbox/services/metadata_arc_baselines.py b/src/pullbox/services/metadata_arc_baselines.py
new file mode 100644
index 00000000..71186503
--- /dev/null
+++ b/src/pullbox/services/metadata_arc_baselines.py
@@ -0,0 +1,229 @@
+"""Canonical provenance inside the existing Story Arc catalog transaction."""
+
+from collections.abc import Sequence
+from datetime import UTC, datetime
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.core.story_arc_identity import normalize_story_arc_name
+from pullbox.models import Issue, Series, StoryArc, StoryArcExternalIdentity
+from pullbox.models.publisher import Publisher
+from pullbox.schemas.metadata_snapshot import (
+ FieldOrigin,
+ MetadataValues,
+ field_domain,
+)
+from pullbox.services.metadata_assembly import MetadataCandidate, assemble_metadata
+from pullbox.services.metadata_baselines import (
+ MetadataBaselineWrite,
+ load_metadata_baseline,
+ save_metadata_baselines,
+)
+from pullbox.services.metadata_credits import write_issue_credits
+from pullbox.services.metadata_series_adoption import persist_adoption_series_baseline
+from pullbox.services.metadata_sources import read_source_policies
+from pullbox.services.story_arc_catalog_types import StoryArcCatalogPreview
+from pullbox.services.story_arc_service import StoryArcService
+
+
+async def persist_arc_metadata(
+ session: AsyncSession,
+ arc: StoryArc,
+ preview: StoryArcCatalogPreview,
+ *,
+ created: bool,
+ replace_managed: bool = False,
+ candidates: Sequence[MetadataCandidate] | None = None,
+) -> None:
+ """Apply descriptive fields only; membership and placements retain their own rules."""
+ evidence = preview.source_evidence
+ if evidence is None:
+ return
+ kind = MetadataEntityKind.STORY_ARC
+ saved = await load_metadata_baseline(session, kind, arc.id)
+ identities = tuple(
+ ExternalIdentityRef(IdentityNamespace(row.source), kind, row.external_id)
+ for row in await session.scalars(
+ select(StoryArcExternalIdentity).where(
+ StoryArcExternalIdentity.story_arc_id == arc.id,
+ StoryArcExternalIdentity.namespace == "story_arc",
+ StoryArcExternalIdentity.source.in_([item.value for item in IdentityNamespace]),
+ StoryArcExternalIdentity.verification_state == IdentityVerificationState.VERIFIED,
+ )
+ )
+ )
+ current = None
+ if not created:
+ # The caller has claimed the arc revision. Discard a stale identity-map
+ # view before comparing user edits with the last committed baseline.
+ await session.refresh(arc, ["name", "description", "publisher_id", "cover_url"])
+ values = saved.snapshot.values.model_dump() if saved else {}
+ values.update(
+ title=arc.name,
+ description=arc.description,
+ image_url=arc.cover_url,
+ publisher=await session.scalar(
+ select(Publisher.name).where(Publisher.id == arc.publisher_id)
+ ),
+ )
+ current = MetadataValues.model_validate(values)
+ now = datetime.now(UTC)
+ retain_name = not created and await StoryArcService._has_managed_placements(
+ session, story_arc_id=arc.id
+ )
+ snapshot = assemble_metadata(
+ kind,
+ identities,
+ candidates if candidates is not None else [evidence.arc],
+ await read_source_policies(session),
+ now=now,
+ current=current,
+ previous=saved.snapshot if saved else None,
+ replace_managed=replace_managed,
+ fields=frozenset(MetadataValues.model_fields) - {"title"} if retain_name else None,
+ )
+ if retain_name and evidence.arc.title.strip() != arc.name:
+ snapshot = snapshot.model_copy(
+ update={"diagnostics": (*snapshot.diagnostics, "title_retained_for_managed_placements")}
+ )
+ title = (snapshot.values.title or "").strip()
+ if not title:
+ raise ValueError("Story arc metadata requires a title")
+ if title != snapshot.values.title:
+ snapshot = snapshot.model_copy(
+ update={
+ "values": snapshot.values.model_copy(update={"title": title}),
+ "origins": (
+ *(item for item in snapshot.origins if item.field != "title"),
+ FieldOrigin(
+ field="title",
+ domain=field_domain(kind, "title"),
+ observed_at=now,
+ derivation="normalization",
+ ),
+ ),
+ }
+ )
+ arc.name = title
+ arc.normalized_name = normalize_story_arc_name(title)
+ arc.description = snapshot.values.description
+ arc.cover_url = snapshot.values.image_url
+ # Reuse the catalog's publisher creation and caller-owned transaction.
+ from pullbox.services.story_arc_catalog_persistence import publisher_id
+
+ arc.publisher_id = await publisher_id(session, snapshot.values.publisher)
+ await save_metadata_baselines(
+ session, [MetadataBaselineWrite(arc.id, snapshot, saved.revision if saved else 0)]
+ )
+
+
+async def persist_seeded_metadata(
+ session: AsyncSession,
+ preview: StoryArcCatalogPreview,
+ parents: Sequence[Series],
+ issues: Sequence[Issue],
+) -> None:
+ """Only newly seeded rows receive provider baselines; Add is not Refresh."""
+ evidence = preview.source_evidence
+ if evidence is None or not (parents or issues):
+ return
+ policies = await read_source_policies(session)
+ now = datetime.now(UTC)
+ parent_profiles = {row.external_id: row for row in evidence.series}
+ issue_profiles = {row.external_id: row for row in evidence.issues}
+ # Caller-created rows have just acquired their one native provider identity.
+ from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity
+
+ parent_keys = (
+ dict(
+ (
+ await session.execute(
+ select(
+ SeriesExternalIdentity.series_id, SeriesExternalIdentity.external_id
+ ).where(
+ SeriesExternalIdentity.series_id.in_([row.id for row in parents]),
+ SeriesExternalIdentity.identity_namespace
+ == preview.source.identity_namespace,
+ )
+ )
+ )
+ .tuples()
+ .all()
+ )
+ if parents
+ else {}
+ )
+ issue_keys = (
+ dict(
+ (
+ await session.execute(
+ select(IssueExternalIdentity.issue_id, IssueExternalIdentity.external_id).where(
+ IssueExternalIdentity.issue_id.in_([row.id for row in issues]),
+ IssueExternalIdentity.identity_namespace
+ == preview.source.identity_namespace,
+ )
+ )
+ )
+ .tuples()
+ .all()
+ )
+ if issues
+ else {}
+ )
+ for parent in parents:
+ profile = parent_profiles[parent_keys[parent.id]]
+ snapshot = assemble_metadata(
+ MetadataEntityKind.SERIES,
+ [
+ ExternalIdentityRef(
+ profile.identity_namespace, MetadataEntityKind.SERIES, profile.external_id
+ )
+ ],
+ [profile],
+ policies,
+ now=now,
+ )
+ parent.cover_url = snapshot.values.image_url
+ await persist_adoption_series_baseline(session, parent, snapshot)
+ writes = []
+ for issue in issues:
+ issue_profile = issue_profiles[issue_keys[issue.id]]
+ snapshot = assemble_metadata(
+ MetadataEntityKind.ISSUE,
+ [
+ ExternalIdentityRef(
+ issue_profile.identity_namespace,
+ MetadataEntityKind.ISSUE,
+ issue_profile.external_id,
+ )
+ ],
+ [
+ issue_profile.model_copy(
+ update={"issue_number_text": issue.effective_issue_number_text}
+ )
+ ],
+ policies,
+ now=now,
+ parent_identities=[
+ ExternalIdentityRef(
+ issue_profile.identity_namespace,
+ MetadataEntityKind.SERIES,
+ issue_profile.series_external_id,
+ )
+ ],
+ )
+ issue.cover_url = snapshot.values.image_url
+ writes.append(MetadataBaselineWrite(issue.id, snapshot))
+ for offset in range(0, len(writes), 200):
+ await write_issue_credits(
+ session,
+ {item.local_id: item.snapshot.values.credits for item in writes[offset : offset + 200]},
+ )
+ await save_metadata_baselines(session, writes[offset : offset + 200])
diff --git a/src/pullbox/services/metadata_arc_catalog.py b/src/pullbox/services/metadata_arc_catalog.py
new file mode 100644
index 00000000..d6bdb55a
--- /dev/null
+++ b/src/pullbox/services/metadata_arc_catalog.py
@@ -0,0 +1,281 @@
+"""Complete server-owned Story Arc snapshots for the existing catalog saver."""
+
+import asyncio
+from dataclasses import replace
+
+from pullbox.core.issue_numbers import parse_issue_number_text
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.providers.base import IssueMetadata, SeriesMetadata
+from pullbox.providers.story_arcs import StoryArcMetadata
+from pullbox.schemas.metadata_sources import (
+ MetadataFetch,
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ SourceStatus,
+)
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_source_reads import source_id
+from pullbox.services.story_arc_catalog_types import (
+ SourceArcCatalogEvidence,
+ StoryArcCatalogError,
+ StoryArcCatalogPreview,
+ catalog_provider_id,
+ snapshot_fingerprint,
+)
+
+# These are command limits, not the larger browse-only API limit.
+MAX_CATALOG_MEMBERS = 2000
+MAX_CATALOG_PARENTS = 200
+
+
+class StoryArcSourceError(StoryArcCatalogError):
+ """Safe typed source failure, retaining retry advice without provider text."""
+
+ def __init__(self, status: SourceStatus, retry_after_seconds: int | None = None) -> None:
+ self.source_status = status
+ self.retry_after_seconds = retry_after_seconds
+ super().__init__(
+ "source_unavailable",
+ f"Could not finish the story arc: {status.value}. Retry the preview.",
+ )
+
+
+def _required[T](result: MetadataFetch[T]) -> T:
+ if result.status is not SourceStatus.OK or result.data is None:
+ raise StoryArcSourceError(result.status, result.retry_after_seconds)
+ return result.data
+
+
+async def fetch_source_arc_catalog(
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ source_revision: int,
+ timeout: float = 120,
+) -> StoryArcCatalogPreview:
+ """Fetch all members and parents without opening a database transaction."""
+ identifier = source_id(source, MetadataEntityKind.STORY_ARC, external_id)
+ runtime = registry.runtime.get(source)
+ if (
+ type(source_revision) is not int
+ or runtime is None
+ or runtime.policy.revision != source_revision
+ ):
+ raise StoryArcCatalogError(
+ "source_changed", "Metadata source settings changed; preview the story arc again"
+ )
+ if not 0 < timeout <= 120:
+ raise ValueError("Invalid catalog timeout")
+ try:
+ async with asyncio.timeout(timeout):
+ arc = _required(await registry.story_arc(source, identifier))
+ if arc.issue_external_ids is not None and not arc.membership_complete:
+ raise StoryArcCatalogError(
+ "incomplete_membership", "Provider membership is incomplete; retry the preview"
+ )
+ if (
+ arc.declared_issue_count is not None
+ and arc.declared_issue_count > MAX_CATALOG_MEMBERS
+ ):
+ raise StoryArcCatalogError(
+ "catalog_limit_exceeded", "This arc exceeds the supported membership limit"
+ )
+ members: list[ProviderIssueRead] = []
+ seen: set[str] = set()
+ page = 1
+ total: int | None = None
+ while True:
+ current = _required(await registry.story_arc_issues(source, identifier, page=page))
+ if total is None:
+ total = current.total
+ if current.total != total or (
+ arc.declared_issue_count is not None and arc.declared_issue_count != total
+ ):
+ raise StoryArcCatalogError(
+ "incomplete_membership",
+ "The arc membership count changed; retry the preview",
+ )
+ if total > MAX_CATALOG_MEMBERS or current.truncated:
+ raise StoryArcCatalogError(
+ "catalog_limit_exceeded", "This arc exceeds the supported membership limit"
+ )
+ for issue in current.results:
+ if issue.external_id in seen:
+ raise StoryArcCatalogError(
+ "identity_conflict",
+ "The arc repeats a member identity; retry the preview",
+ )
+ seen.add(issue.external_id)
+ members.append(issue.model_copy(deep=True))
+ if current.next_page is None:
+ break
+ page = current.next_page
+ if len(members) != total or (
+ arc.issue_external_ids is not None
+ and arc.issue_external_ids != [row.external_id for row in members]
+ ):
+ raise StoryArcCatalogError(
+ "incomplete_membership",
+ "The arc membership changed or is incomplete; retry the preview",
+ )
+ parent_ids = tuple(dict.fromkeys(issue.series_external_id for issue in members))
+ if len(parent_ids) > MAX_CATALOG_PARENTS:
+ raise StoryArcCatalogError(
+ "catalog_limit_exceeded", "This arc exceeds the supported parent-series limit"
+ )
+ parents = []
+ for parent_id in parent_ids:
+ parent = _required(await registry.series(source, parent_id))
+ parents.append(parent.model_copy(deep=True))
+ return project_source_arc_catalog(
+ SourceArcCatalogEvidence(arc.model_copy(deep=True), tuple(members), tuple(parents)),
+ source_revision,
+ )
+ except TimeoutError as exc:
+ raise StoryArcCatalogError(
+ "source_timeout", "Story arc preview timed out; retry the preview"
+ ) from exc
+
+
+def source_record_identities(
+ record: ProviderStoryArcRead | ProviderSeriesRead | ProviderIssueRead,
+ kind: MetadataEntityKind,
+) -> tuple[ExternalIdentityRef, ...]:
+ native = ExternalIdentityRef(record.source.identity_namespace, kind, record.external_id)
+ catalog_provider_id(record.external_id, record.source)
+ if native.namespace != record.identity_namespace:
+ raise StoryArcCatalogError(
+ "identity_conflict", "Source metadata uses a different namespace"
+ )
+ identities = {native.namespace: native}
+ for identity in record.cross_identities:
+ if identity.entity_kind is not kind or (
+ identity.namespace in identities and identities[identity.namespace] != identity
+ ):
+ raise StoryArcCatalogError(
+ "identity_conflict", "Provider cross-identities disagree; review the match"
+ )
+ if identity.namespace is IdentityNamespace.COMICVINE and int(identity.external_id) >= 2**63:
+ raise StoryArcCatalogError(
+ "identity_conflict", "ComicVine identity exceeds its supported range"
+ )
+ identities[identity.namespace] = identity
+ return tuple(identities.values())
+
+
+def project_source_arc_catalog(
+ evidence: SourceArcCatalogEvidence, revision: int
+) -> StoryArcCatalogPreview:
+ """Project without losing the normalized evidence needed by later consumers."""
+ arc = evidence.arc
+ source_record_identities(arc, MetadataEntityKind.STORY_ARC)
+ ids = tuple(issue.external_id for issue in evidence.issues)
+ if (
+ len(ids) != len(set(ids))
+ or len(ids) > MAX_CATALOG_MEMBERS
+ or (arc.declared_issue_count is not None and arc.declared_issue_count != len(ids))
+ or (
+ arc.issue_external_ids is not None
+ and (not arc.membership_complete or tuple(arc.issue_external_ids) != ids)
+ )
+ ):
+ raise StoryArcCatalogError(
+ "incomplete_membership", "Source evidence does not contain one complete member set"
+ )
+ parents = {parent.external_id: parent for parent in evidence.series}
+ if (
+ len(parents) != len(evidence.series)
+ or len(parents) > MAX_CATALOG_PARENTS
+ or set(parents) != {issue.series_external_id for issue in evidence.issues}
+ ):
+ raise StoryArcCatalogError(
+ "parent_metadata_missing", "Source evidence does not contain the exact parent set"
+ )
+ issues = []
+ numbers: set[tuple[str, str]] = set()
+ for issue in evidence.issues:
+ source_record_identities(issue, MetadataEntityKind.ISSUE)
+ if issue.source is not arc.source:
+ raise StoryArcCatalogError("identity_conflict", "Arc members belong to another source")
+ try:
+ number, exact = parse_issue_number_text(issue.issue_number_text)
+ except ValueError as exc:
+ raise StoryArcCatalogError(
+ "invalid_issue_number", "Arc member designation needs review"
+ ) from exc
+ key = (issue.series_external_id, exact)
+ if key in numbers:
+ raise StoryArcCatalogError(
+ "identity_conflict", "Two arc members use the same issue designation"
+ )
+ numbers.add(key)
+ issues.append(
+ IssueMetadata(
+ issue.external_id,
+ issue.series_external_id,
+ number,
+ issue.title,
+ issue.description,
+ issue.cover_date.isoformat() if issue.cover_date else None,
+ issue.store_date.isoformat() if issue.store_date else None,
+ issue.image_url,
+ issue.page_count,
+ issue.resource_url
+ if arc.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
+ issue_number_text=issue.issue_number_text,
+ )
+ )
+ series = []
+ for parent in evidence.series:
+ source_record_identities(parent, MetadataEntityKind.SERIES)
+ if parent.source is not arc.source or not parent.title.strip():
+ raise StoryArcCatalogError("identity_conflict", "Arc parents belong to another source")
+ series.append(
+ SeriesMetadata(
+ parent.external_id,
+ parent.title,
+ parent.sort_title,
+ parent.year_start,
+ parent.year_end,
+ parent.status,
+ parent.publisher,
+ parent.description,
+ parent.image_url,
+ parent.issue_count,
+ parent.resource_url
+ if arc.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
+ )
+ )
+ preview = StoryArcCatalogPreview(
+ metadata=StoryArcMetadata(
+ provider_id=arc.external_id,
+ title=arc.title,
+ issue_provider_ids=ids,
+ description=arc.description,
+ publisher=arc.publisher,
+ cover_url=arc.image_url,
+ comicvine_url=arc.resource_url
+ if arc.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
+ declared_issue_count=len(ids),
+ membership_complete=True,
+ order_basis=arc.order_basis,
+ warnings=tuple(arc.warnings),
+ ),
+ issues=tuple(issues),
+ series=tuple(series),
+ fingerprint="",
+ source=arc.source,
+ source_revision=revision,
+ source_evidence=evidence,
+ )
+ return replace(preview, fingerprint=snapshot_fingerprint(preview))
diff --git a/src/pullbox/services/metadata_arc_commands.py b/src/pullbox/services/metadata_arc_commands.py
new file mode 100644
index 00000000..6cf0508f
--- /dev/null
+++ b/src/pullbox/services/metadata_arc_commands.py
@@ -0,0 +1,178 @@
+"""Source catalog command boundaries shared by API and browser adapters."""
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.library_policy import load_search_on_add_default
+from pullbox.models.story_arc import StoryArc
+from pullbox.schemas.metadata_arc_catalog import (
+ ArcCatalogAdd,
+ ArcCatalogChanges,
+ ArcCatalogPreviewRead,
+ ArcCatalogRefresh,
+ ArcCatalogSelection,
+)
+from pullbox.schemas.metadata_sources import StoryArcPreviewQuery
+from pullbox.services.metadata_arc_catalog import fetch_source_arc_catalog
+from pullbox.services.metadata_arc_refresh import prepare_arc_metadata
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_sources import load_source_runtime
+from pullbox.services.story_arc_catalog import StoryArcCatalogService
+from pullbox.services.story_arc_catalog_identity import require_catalog_source_revision
+from pullbox.services.story_arc_catalog_types import StoryArcCatalogError, StoryArcCatalogPreview
+from pullbox.services.story_arc_file_defaults import load_story_arc_file_defaults
+
+
+@dataclass(frozen=True, slots=True)
+class ArcCommandResult:
+ arc: StoryArc
+ search_on_add: bool
+ initial_placements: bool = False
+
+
+def saved_arc_source(arc: StoryArc) -> StoryArcPreviewQuery | None:
+ """Only a saved source-bound catalog selects the refresh transport."""
+ from pydantic import ValidationError
+
+ catalog = arc.diagnostics.get("provider_catalog")
+ snapshot = catalog.get("snapshot") if isinstance(catalog, dict) else None
+ if not isinstance(snapshot, dict) or "source" not in snapshot:
+ return None
+ try:
+ return StoryArcPreviewQuery.model_validate(
+ {
+ "source": snapshot.get("source"),
+ "external_id": snapshot.get("provider_id"),
+ }
+ )
+ except ValidationError:
+ return None
+
+
+async def fetch_current_arc_catalog(
+ session: AsyncSession, selection: ArcCatalogSelection, *, gcd_api_enabled: bool
+) -> StoryArcCatalogPreview:
+ """Release the request's read snapshot before bounded provider work."""
+ runtime = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled)
+ await session.rollback()
+ return await fetch_source_arc_catalog(
+ MetadataSourceRegistry(
+ runtime,
+ gcd_api_enabled=gcd_api_enabled,
+ read_cache=source_read_cache(session),
+ revalidate_reads=True,
+ ),
+ selection.source,
+ selection.external_id,
+ source_revision=selection.source_revision,
+ )
+
+
+def catalog_writer(preview: StoryArcCatalogPreview) -> StoryArcCatalogService:
+ return StoryArcCatalogService(source=preview.source, source_revision=preview.source_revision)
+
+
+async def describe_arc_catalog(
+ session: AsyncSession, preview: StoryArcCatalogPreview, *, story_arc_id: int | None = None
+) -> ArcCatalogPreviewRead:
+ await require_catalog_source_revision(session, preview)
+ evidence = preview.source_evidence
+ if evidence is None or preview.source_revision is None:
+ raise StoryArcCatalogError("snapshot_changed", "Preview this source again")
+ result = ArcCatalogPreviewRead(
+ source=preview.source,
+ external_id=preview.metadata.provider_id,
+ source_revision=preview.source_revision,
+ fingerprint=preview.fingerprint,
+ arc=evidence.arc,
+ issues=list(evidence.issues),
+ series=list(evidence.series),
+ order_basis=preview.order_basis,
+ )
+ if story_arc_id is None:
+ defaults = await load_story_arc_file_defaults(session)
+ result.file_defaults_fingerprint = defaults.fingerprint
+ result.file_summary = defaults.summary_label
+ else:
+ delta = await catalog_writer(preview).preview_refresh(session, story_arc_id, preview)
+ result.changes = ArcCatalogChanges(
+ revision=delta.revision,
+ added_issue_ids=list(delta.added_issue_provider_ids),
+ removed_issue_ids=list(delta.removed_issue_provider_ids),
+ )
+ return result
+
+
+def _require_decision(
+ session: AsyncSession,
+ preview: StoryArcCatalogPreview,
+ decision: ArcCatalogAdd | ArcCatalogRefresh,
+) -> None:
+ if session.in_transaction():
+ raise ValueError("Source arc command requires a session without an active transaction")
+ if (
+ preview.source is not decision.source
+ or preview.metadata.provider_id != decision.external_id
+ or preview.source_revision != decision.source_revision
+ or preview.fingerprint != decision.fingerprint
+ or preview.source_evidence is None
+ ):
+ raise StoryArcCatalogError("snapshot_changed", "Source metadata changed; preview again")
+
+
+@asynccontextmanager
+async def source_arc_add_transaction(
+ session: AsyncSession, preview: StoryArcCatalogPreview, decision: ArcCatalogAdd
+) -> AsyncIterator[ArcCommandResult]:
+ """Commit one graph only after the caller has successfully built its response."""
+ _require_decision(session, preview, decision)
+ async with session.begin():
+ defaults = await load_story_arc_file_defaults(session)
+ if defaults.fingerprint != decision.file_defaults_fingerprint:
+ raise StoryArcCatalogError("file_defaults_changed", "Review current file defaults")
+ arc = await catalog_writer(preview).add(
+ session,
+ preview,
+ ordered_issue_provider_ids=decision.ordered_issue_ids,
+ skipped_issue_provider_ids=decision.skipped_issue_ids,
+ library_root_id=decision.library_root_id,
+ monitored=decision.monitored,
+ search_missing=decision.monitored,
+ include_upcoming=decision.monitored,
+ placement_policy=defaults.proposal(),
+ )
+ marker = arc.diagnostics.get("catalog_initial_placements")
+ pending = isinstance(marker, dict) and any(
+ type(value := marker.get(key)) is int and value > 0 for key in ("pending", "failed")
+ )
+ yield ArcCommandResult(
+ arc, arc.monitored and await load_search_on_add_default(session), pending
+ )
+
+
+@asynccontextmanager
+async def source_arc_refresh_transaction(
+ session: AsyncSession,
+ story_arc_id: int,
+ preview: StoryArcCatalogPreview,
+ decision: ArcCatalogRefresh,
+) -> AsyncIterator[ArcCommandResult]:
+ _require_decision(session, preview, decision)
+ metadata_refresh = await prepare_arc_metadata(session, story_arc_id, preview)
+ async with session.begin():
+ result = await catalog_writer(preview).refresh(
+ session,
+ story_arc_id,
+ preview,
+ expected_revision=decision.expected_revision,
+ library_root_id=decision.library_root_id,
+ metadata_refresh=metadata_refresh,
+ )
+ yield ArcCommandResult(
+ result.story_arc,
+ result.story_arc.monitored and await load_search_on_add_default(session),
+ )
diff --git a/src/pullbox/services/metadata_arc_preview.py b/src/pullbox/services/metadata_arc_preview.py
new file mode 100644
index 00000000..f58e75c5
--- /dev/null
+++ b/src/pullbox/services/metadata_arc_preview.py
@@ -0,0 +1,49 @@
+"""Source-bound arc previews, never trusted adoption or curated order proofs."""
+
+import asyncio
+
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.schemas.metadata_sources import MetadataFetch, SourceStatus, StoryArcPreviewRead
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_source_reads import source_id
+
+
+async def preview_source_arc(
+ registry: MetadataSourceRegistry, source: MetadataSource, external_id: str
+) -> StoryArcPreviewRead:
+ identifier = source_id(source, MetadataEntityKind.STORY_ARC, external_id)
+ runtime = registry.runtime.get(source)
+ preview = StoryArcPreviewRead(
+ source=source,
+ external_id=identifier,
+ source_revision=runtime.policy.revision if runtime else 0,
+ arc=MetadataFetch(status=SourceStatus.NOT_QUERIED),
+ issues=MetadataFetch(status=SourceStatus.NOT_QUERIED),
+ )
+ try:
+ async with asyncio.timeout(registry.total_timeout):
+ preview.arc = await registry.story_arc(source, identifier)
+ if preview.arc.status is SourceStatus.OK:
+ arc = preview.arc.data
+ assert arc is not None
+ if arc.issue_external_ids is not None and not arc.membership_complete:
+ preview.issues = MetadataFetch(status=SourceStatus.INCOMPATIBLE_RESPONSE)
+ else:
+ preview.issues = await registry.story_arc_issues(source, identifier)
+ page = preview.issues.data
+ if (
+ page is not None
+ and arc.issue_external_ids is not None
+ and (
+ page.total != len(arc.issue_external_ids)
+ or [row.external_id for row in page.results]
+ != arc.issue_external_ids[:100]
+ )
+ ):
+ preview.issues = MetadataFetch(status=SourceStatus.INCOMPATIBLE_RESPONSE)
+ except TimeoutError:
+ if preview.arc.status is SourceStatus.NOT_QUERIED:
+ preview.arc = MetadataFetch(status=SourceStatus.TIMEOUT)
+ else:
+ preview.issues = MetadataFetch(status=SourceStatus.TIMEOUT)
+ return preview
diff --git a/src/pullbox/services/metadata_arc_refresh.py b/src/pullbox/services/metadata_arc_refresh.py
new file mode 100644
index 00000000..31c1fd4c
--- /dev/null
+++ b/src/pullbox/services/metadata_arc_refresh.py
@@ -0,0 +1,211 @@
+"""Exact-identity descriptive reads separated from Story Arc catalog writes."""
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.config import get_settings
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.models import StoryArc, StoryArcExternalIdentity
+from pullbox.models.metadata_identity import StoryArcIdentityEvent
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.models.publisher import Publisher
+from pullbox.schemas.metadata_snapshot import MetadataValues
+from pullbox.schemas.metadata_sources import SourcePolicyRead
+from pullbox.services.metadata_assembly import MetadataAssemblyError
+from pullbox.services.metadata_baselines import (
+ MetadataBaselineConflictError,
+ load_metadata_baseline,
+)
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_refresh_snapshot import (
+ MetadataRefreshSnapshot,
+ fetch_metadata_snapshot,
+)
+from pullbox.services.metadata_series_refresh_state import RefreshEntityState
+from pullbox.services.metadata_sources import load_source_runtime, read_source_policies
+from pullbox.services.story_arc_catalog_types import StoryArcCatalogError, StoryArcCatalogPreview
+from pullbox.services.story_arc_service import StoryArcService
+
+ARC_FIELDS = frozenset({"title", "description", "publisher", "image_url"})
+
+
+@dataclass(frozen=True)
+class ArcRefreshState:
+ entity: RefreshEntityState
+ revision: int
+ lifecycle: str
+ monitored: bool
+ placed: bool
+ policies: tuple[SourcePolicyRead, ...]
+
+
+@dataclass(frozen=True)
+class ArcMetadataRefresh:
+ before: ArcRefreshState
+ fetched: MetadataRefreshSnapshot
+
+
+async def read_arc_refresh_state(session: AsyncSession, arc_id: int) -> ArcRefreshState:
+ arc = await session.scalar(
+ select(StoryArc).where(StoryArc.id == arc_id).execution_options(populate_existing=True)
+ )
+ if arc is None:
+ raise StoryArcCatalogError("arc_unavailable", "Story arc is unavailable")
+ try:
+ saved = await load_metadata_baseline(session, MetadataEntityKind.STORY_ARC, arc_id)
+ except MetadataBaselineConflictError as exc:
+ raise StoryArcCatalogError(
+ "metadata_baseline_conflict", "Metadata baseline needs review; preview the arc again"
+ ) from exc
+ values = saved.snapshot.values.model_dump() if saved else {}
+ values.update(
+ title=arc.name,
+ description=arc.description,
+ image_url=arc.cover_url,
+ publisher=await session.scalar(
+ select(Publisher.name).where(Publisher.id == arc.publisher_id)
+ ),
+ )
+ claims = tuple(
+ (
+ ExternalIdentityRef(
+ IdentityNamespace(row.source), MetadataEntityKind.STORY_ARC, row.external_id
+ ),
+ row.verification_state,
+ row.revision,
+ )
+ for row in await session.scalars(
+ select(StoryArcExternalIdentity)
+ .where(
+ StoryArcExternalIdentity.story_arc_id == arc_id,
+ StoryArcExternalIdentity.namespace == "story_arc",
+ StoryArcExternalIdentity.source.in_([item.value for item in IdentityNamespace]),
+ )
+ .order_by(StoryArcExternalIdentity.source)
+ .execution_options(populate_existing=True)
+ )
+ )
+ if arc.comicvine_id and not any(
+ ref.namespace is IdentityNamespace.COMICVINE for ref, _, _ in claims
+ ):
+ claims += (
+ (
+ ExternalIdentityRef(
+ IdentityNamespace.COMICVINE, MetadataEntityKind.STORY_ARC, str(arc.comicvine_id)
+ ),
+ IdentityVerificationState.VERIFIED,
+ 0,
+ ),
+ )
+ event_id = await session.scalar(
+ select(func.max(StoryArcIdentityEvent.id)).where(
+ StoryArcIdentityEvent.story_arc_id == arc_id
+ )
+ )
+ policies = tuple(
+ item.model_copy(
+ update={
+ "last_tested_at": None,
+ "last_success_at": None,
+ "last_status": None,
+ }
+ )
+ for item in await read_source_policies(session)
+ )
+ return ArcRefreshState(
+ RefreshEntityState(
+ arc_id,
+ MetadataValues.model_validate(values),
+ saved.snapshot if saved else None,
+ saved.revision if saved else 0,
+ claims,
+ event_id or 0,
+ arc.comicvine_id,
+ ),
+ arc.revision,
+ arc.lifecycle.value,
+ arc.monitored,
+ await StoryArcService._has_managed_placements(session, story_arc_id=arc_id),
+ policies,
+ )
+
+
+async def fetch_arc_metadata(
+ registry: MetadataSourceRegistry,
+ before: ArcRefreshState,
+ preview: StoryArcCatalogPreview,
+ *,
+ replace_managed: bool,
+) -> ArcMetadataRefresh:
+ evidence = preview.source_evidence
+ if evidence is None:
+ raise StoryArcCatalogError("snapshot_changed", "Preview current source metadata again")
+ try:
+ fetched = await fetch_metadata_snapshot(
+ registry,
+ MetadataEntityKind.STORY_ARC,
+ before.entity.identities,
+ now=datetime.now(UTC),
+ requested_fields=ARC_FIELDS - {"title"} if before.placed else ARC_FIELDS,
+ current=before.entity.values,
+ previous=before.entity.baseline,
+ replace_managed=replace_managed,
+ initial_candidates=[evidence.arc],
+ )
+ except MetadataAssemblyError as exc:
+ raise StoryArcCatalogError(
+ "metadata_conflict", "Source metadata needs review before refresh"
+ ) from exc
+ return ArcMetadataRefresh(before, fetched)
+
+
+async def prepare_arc_metadata(
+ session: AsyncSession,
+ arc_id: int,
+ preview: StoryArcCatalogPreview,
+ *,
+ replace_managed: bool = True,
+) -> ArcMetadataRefresh:
+ if session.new or session.dirty or session.deleted:
+ raise ValueError("Finish pending edits before refreshing Story Arc metadata")
+ try:
+ before = await read_arc_refresh_state(session, arc_id)
+ enabled = get_settings().metadata_gcd_api_v2_enabled
+ runtime = await load_source_runtime(session, gcd_api_enabled=enabled)
+ registry = MetadataSourceRegistry(
+ runtime,
+ gcd_api_enabled=enabled,
+ read_cache=source_read_cache(session),
+ revalidate_reads=True,
+ total_timeout=60,
+ )
+ finally:
+ await session.rollback()
+ return await fetch_arc_metadata(registry, before, preview, replace_managed=replace_managed)
+
+
+async def require_arc_refresh_state(session: AsyncSession, refresh: ArcMetadataRefresh) -> None:
+ # Match the shared policy-first, then parent-first writer lock order.
+ await session.execute(
+ select(MetadataSourceConfig)
+ .order_by(MetadataSourceConfig.source)
+ .with_for_update(read=True)
+ )
+ await session.execute(
+ select(StoryArc.id).where(StoryArc.id == refresh.before.entity.local_id).with_for_update()
+ )
+ current = await read_arc_refresh_state(session, refresh.before.entity.local_id)
+ if current != refresh.before:
+ raise StoryArcCatalogError(
+ "metadata_changed",
+ "Story arc metadata, identities or source settings changed; refresh the review",
+ )
diff --git a/src/pullbox/services/metadata_arc_search.py b/src/pullbox/services/metadata_arc_search.py
new file mode 100644
index 00000000..dccae589
--- /dev/null
+++ b/src/pullbox/services/metadata_arc_search.py
@@ -0,0 +1,75 @@
+"""Bounded complete candidate collection for interactive Story Arc pagination."""
+
+import asyncio
+
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.schemas.metadata_sources import (
+ MetadataDomain,
+ ProviderStoryArcRead,
+ SourceStatus,
+ StoryArcDiscoveryQuery,
+ StoryArcDiscoveryRead,
+ StoryArcSourceOutcome,
+)
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_source_reads import read_story_arcs
+
+
+async def collect_arc_candidates(
+ registry: MetadataSourceRegistry, query: StoryArcDiscoveryQuery
+) -> StoryArcDiscoveryRead:
+ if query.mode != "interactive" or query.pages:
+ raise ValueError("Interactive collection starts at the first page")
+ deadline = asyncio.get_running_loop().time() + registry.total_timeout
+
+ async def collect(
+ source: MetadataSource,
+ ) -> tuple[list[ProviderStoryArcRead], StoryArcSourceOutcome]:
+ rows: list[ProviderStoryArcRead] = []
+ seen: set[str] = set()
+ outcome = StoryArcSourceOutcome(source=source, status=SourceStatus.EMPTY)
+ for page_number in range(1, 11):
+ result = await read_story_arcs(
+ registry, source, query.query, page=page_number, deadline=deadline
+ )
+ page = result.data
+ outcome.status = result.status
+ outcome.retry_after_seconds = result.retry_after_seconds
+ if page is None:
+ outcome.truncated = bool(rows)
+ break
+ outcome.total, outcome.next_page = page.total, page.next_page
+ if any(row.external_id in seen for row in page.results):
+ outcome.status, outcome.truncated = SourceStatus.INCOMPATIBLE_RESPONSE, True
+ break
+ rows.extend(page.results)
+ seen.update(row.external_id for row in page.results)
+ outcome.status = SourceStatus.OK if rows else SourceStatus.EMPTY
+ outcome.truncated = page.truncated or (page_number == 10 and page.next_page is not None)
+ if page.next_page is None or outcome.truncated:
+ break
+ if page.next_page != page_number + 1:
+ outcome.status, outcome.truncated = SourceStatus.INCOMPATIBLE_RESPONSE, True
+ break
+ return rows, outcome
+
+ tasks = [
+ asyncio.create_task(collect(source))
+ for source in registry._ordered_sources(query, domain=MetadataDomain.STORY_ARCS)
+ ]
+ try:
+ pages = await asyncio.gather(*tasks)
+ finally:
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ grouped = {}
+ for rows, _ in pages:
+ for row in rows:
+ key = (row.identity_namespace, row.external_id)
+ if key not in grouped:
+ grouped[key] = row.model_copy(deep=True)
+ elif row.source not in grouped[key].also_from and row.source != grouped[key].source:
+ grouped[key].also_from.append(row.source)
+ return StoryArcDiscoveryRead(results=list(grouped.values()), sources=[o for _, o in pages])
diff --git a/src/pullbox/services/metadata_assembly.py b/src/pullbox/services/metadata_assembly.py
new file mode 100644
index 00000000..23c68a7e
--- /dev/null
+++ b/src/pullbox/services/metadata_assembly.py
@@ -0,0 +1,313 @@
+"""Assemble one identity-bound snapshot before any database or archive mutation."""
+
+from collections.abc import Sequence
+from datetime import UTC, datetime
+
+from pullbox.core.issue_numbers import parse_issue_number_text
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource
+from pullbox.schemas.metadata_snapshot import (
+ EmbeddedDocument,
+ FieldOrigin,
+ MetadataSnapshot,
+ MetadataValues,
+ field_domain,
+)
+from pullbox.schemas.metadata_sources import (
+ MetadataDomain,
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ SourcePolicyRead,
+)
+from pullbox.services.archive_metadata_reconciliation import ArchiveMetadataReconciliation
+from pullbox.services.provider_artwork import allowed_artwork_url
+
+type MetadataCandidate = ProviderSeriesRead | ProviderIssueRead | ProviderStoryArcRead
+
+
+class MetadataAssemblyError(ValueError):
+ """Evidence cannot safely describe one canonical entity."""
+
+
+def missing_metadata_value(value: object) -> bool:
+ return value is None or (isinstance(value, str) and not value.strip()) or value == ()
+
+
+def _identity_map(
+ identities: Sequence[ExternalIdentityRef], kind: MetadataEntityKind
+) -> dict[str, ExternalIdentityRef]:
+ result: dict[str, ExternalIdentityRef] = {}
+ for identity in identities:
+ if identity.entity_kind is not kind or (
+ identity.namespace in result and result[identity.namespace] != identity
+ ):
+ raise MetadataAssemblyError("Exact identities disagree. Review the match.")
+ result[identity.namespace] = identity
+ return result
+
+
+def _candidate_values(
+ kind: MetadataEntityKind, candidate: MetadataCandidate, diagnostics: set[str]
+) -> MetadataValues:
+ expected = {
+ MetadataEntityKind.SERIES: ProviderSeriesRead,
+ MetadataEntityKind.ISSUE: ProviderIssueRead,
+ MetadataEntityKind.STORY_ARC: ProviderStoryArcRead,
+ }[kind]
+ if not isinstance(candidate, expected):
+ raise MetadataAssemblyError("Metadata belongs to a different entity kind.")
+ values = {
+ key: value
+ for key, value in candidate.model_dump().items()
+ if key in MetadataValues.model_fields
+ }
+ if candidate.image_url and (
+ candidate.source in {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2}
+ or not allowed_artwork_url(candidate.image_url)
+ ):
+ values["image_url"] = None
+ diagnostics.add(f"{candidate.source.value}:invalid_artwork_url")
+ return MetadataValues.model_validate(values)
+
+
+def assemble_metadata(
+ kind: MetadataEntityKind,
+ identities: Sequence[ExternalIdentityRef],
+ candidates: Sequence[MetadataCandidate],
+ policies: Sequence[SourcePolicyRead],
+ *,
+ now: datetime,
+ current: MetadataValues | None = None,
+ previous: MetadataSnapshot | None = None,
+ overrides: frozenset[str] = frozenset(),
+ replace_managed: bool = False,
+ parent_identities: Sequence[ExternalIdentityRef] = (),
+ fields: frozenset[str] | None = None,
+ archive: ArchiveMetadataReconciliation | None = None,
+) -> MetadataSnapshot:
+ """Apply authority only after identity agreement; never attach observed crosswalks.
+
+ Unknown existing values are local, not implicitly provider-managed. A changed
+ or cleared value relative to the last snapshot becomes a durable override.
+ Callers own proof of the supplied identities, independent file-to-target and
+ issue-to-parent matching for archive input, and persistence of the result.
+ Archive input supplies local values, never proof of those bindings or write
+ permission. Writers must still preserve unsupported XML and validate output.
+ """
+ if now.tzinfo is None or now.utcoffset() is None:
+ raise MetadataAssemblyError("Assembly requires an aware observation timestamp.")
+ now = now.astimezone(UTC)
+ if (overrides | (fields or frozenset())) - MetadataValues.model_fields.keys():
+ raise MetadataAssemblyError("Unknown metadata override field.")
+ verified = _identity_map(identities, kind)
+ parents = _identity_map(parent_identities, MetadataEntityKind.SERIES)
+ if previous is not None and (
+ previous.entity_kind is not kind or not set(previous.identities) <= set(identities)
+ ):
+ raise MetadataAssemblyError("The previous snapshot belongs to different identities.")
+ policy = {item.source: item for item in policies}
+ if len(policy) != len(policies):
+ raise MetadataAssemblyError("Metadata source policies repeat a source.")
+ current = current or (previous.values if previous else MetadataValues())
+ diagnostics = {
+ item
+ for item in (previous.diagnostics if previous else ())
+ if item.startswith("archive:") and archive is None
+ }
+ evidence = _identity_map(
+ (*identities, *(previous.observed_identities if previous else ())), kind
+ )
+ normalized: dict[MetadataSource, tuple[MetadataCandidate, MetadataValues]] = {}
+ designations: set[str] = set()
+ archive_values = _archive_values(
+ archive, kind, evidence, parents, current, diagnostics, designations
+ )
+ for candidate in candidates:
+ source = candidate.source
+ native = ExternalIdentityRef(source.identity_namespace, kind, candidate.external_id)
+ if (
+ candidate.identity_namespace is not native.namespace
+ or candidate.external_id != native.external_id
+ or verified.get(native.namespace) != native
+ ):
+ raise MetadataAssemblyError("Metadata requires a verified exact identity.")
+ if source not in policy or not policy[source].enabled or source in normalized:
+ raise MetadataAssemblyError("Metadata requires one enabled policy per source.")
+ for claim in candidate.cross_identities:
+ if claim.entity_kind is not kind or (
+ claim.namespace in evidence and evidence[claim.namespace] != claim
+ ):
+ raise MetadataAssemblyError("Exact identities disagree. Review the match.")
+ evidence[claim.namespace] = claim
+ if isinstance(candidate, ProviderIssueRead):
+ parent = ExternalIdentityRef(
+ source.identity_namespace, MetadataEntityKind.SERIES, candidate.series_external_id
+ )
+ if parents.get(parent.namespace) != parent:
+ raise MetadataAssemblyError("Issue metadata requires independent parent proof.")
+ try:
+ _, designation = parse_issue_number_text(candidate.issue_number_text)
+ except ValueError as exc:
+ raise MetadataAssemblyError("Unsupported issue designation.") from exc
+ designations.add(designation.casefold())
+ normalized[source] = (candidate, _candidate_values(kind, candidate, diagnostics))
+ if len(designations) > 1:
+ raise MetadataAssemblyError("Issue designations disagree. Review the issue match.")
+
+ if current.image_url and not allowed_artwork_url(current.image_url):
+ raise MetadataAssemblyError("Existing artwork requires a supported public provider URL.")
+ origins = {item.field: item for item in previous.origins} if previous else {}
+ values = {field: getattr(current, field) for field in MetadataValues.model_fields}
+ assembled: list[FieldOrigin] = []
+
+ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]:
+ config = policy.get(source)
+ return (
+ (config.domain_priorities.get(domain, config.priority), source.value)
+ if (config and config.enabled)
+ else (1001, source.value)
+ )
+
+ for field, existing in values.items():
+ domain = field_domain(kind, field)
+ prior = origins.get(field)
+ edited = previous is not None and existing != getattr(previous.values, field)
+ protected = field in overrides or edited or (prior is not None and prior.user_override)
+ chosen = prior or FieldOrigin(field=field, domain=domain, observed_at=now)
+ if protected:
+ chosen = FieldOrigin(field=field, domain=domain, observed_at=now, user_override=True)
+ elif (fields is None or field in fields) and not any(
+ f"archive:{kind.value}:{field}:{reason}" in diagnostics
+ for reason in ("disagreement", "current_disagreement")
+ ):
+ local_value = getattr(archive_values, field)
+ has_local_value = local_value is not None and (
+ local_value == () or not missing_metadata_value(local_value)
+ )
+ if archive is not None and has_local_value:
+ if missing_metadata_value(existing) and not (
+ prior is not None and prior.embedded_documents
+ ):
+ values[field] = local_value
+ chosen = FieldOrigin(
+ field=field,
+ domain=domain,
+ observed_at=now,
+ embedded_documents=_archive_documents(archive, kind, field, local_value),
+ )
+ elif existing != local_value:
+ diagnostics.add(f"archive:{kind.value}:{field}:current_disagreement")
+ unchanged_managed = (
+ prior is not None
+ and (prior.source is not None or prior.derivation is not None)
+ and existing == local_value
+ )
+ if chosen.embedded_documents or (has_local_value and not unchanged_managed):
+ if not missing_metadata_value(values[field]) or chosen.embedded_documents:
+ assembled.append(chosen)
+ continue
+ for source in sorted(normalized, key=lambda source: rank(source, domain)):
+ candidate, incoming = normalized[source]
+ value = getattr(incoming, field)
+ if missing_metadata_value(value):
+ continue
+ may_replace = (
+ replace_managed
+ and prior is not None
+ and (
+ prior.derivation is not None
+ or (
+ prior.source is not None
+ and rank(source, domain) <= rank(prior.source, domain)
+ )
+ )
+ )
+ if missing_metadata_value(existing) or may_replace:
+ values[field] = value
+ chosen = FieldOrigin(
+ field=field,
+ domain=domain,
+ source=source,
+ source_updated_at=candidate.source_updated_at,
+ observed_at=now,
+ )
+ break
+ if not missing_metadata_value(values[field]) or protected or prior is not None:
+ assembled.append(chosen)
+ return MetadataSnapshot(
+ entity_kind=kind,
+ identities=tuple(
+ sorted(set(identities), key=lambda item: (item.namespace, item.external_id))
+ ),
+ values=MetadataValues.model_validate(values),
+ origins=tuple(assembled),
+ observed_identities=tuple(
+ sorted(
+ set(evidence.values()) - set(identities),
+ key=lambda item: (item.namespace, item.external_id),
+ )
+ ),
+ diagnostics=tuple(sorted(diagnostics)),
+ )
+
+
+def _archive_values(
+ archive: ArchiveMetadataReconciliation | None,
+ kind: MetadataEntityKind,
+ evidence: dict[str, ExternalIdentityRef],
+ parents: dict[str, ExternalIdentityRef],
+ current: MetadataValues,
+ diagnostics: set[str],
+ designations: set[str],
+) -> MetadataValues:
+ if archive is None:
+ return MetadataValues()
+ if kind is MetadataEntityKind.STORY_ARC:
+ raise MetadataAssemblyError("Issue archive metadata cannot assign a story arc.")
+ if archive.identity_conflicts:
+ raise MetadataAssemblyError("Exact identities disagree. Review the match.")
+ if any(item.code != "unmapped_content" for item in archive.diagnostics):
+ raise MetadataAssemblyError("Embedded metadata needs review before adoption.")
+ for observation in archive.evidence:
+ claim = observation.identity
+ target = evidence if claim.entity_kind is kind else parents
+ if claim.namespace in target and target[claim.namespace] != claim:
+ raise MetadataAssemblyError("Exact identities disagree. Review the match.")
+ if claim.entity_kind is kind:
+ evidence[claim.namespace] = claim
+ diagnostics.update(f"archive:{item.document}:{item.code}" for item in archive.diagnostics)
+ diagnostics.update(
+ f"archive:{item.entity}:{item.field}:disagreement" for item in archive.differences
+ )
+ if kind is MetadataEntityKind.ISSUE:
+ if current.cover_date is not None:
+ saved_date = current.cover_date
+ if any(
+ part is not None and part != saved
+ for part, saved in zip(
+ archive.comicinfo.publication_date_parts,
+ (saved_date.year, saved_date.month, saved_date.day),
+ strict=True,
+ )
+ ):
+ diagnostics.add("archive:issue:cover_date:current_disagreement")
+ for values in (current, archive.comicinfo.issue, archive.metroninfo.issue):
+ number = values.issue_number_text
+ if number is not None:
+ try:
+ _, designation = parse_issue_number_text(number)
+ except ValueError as exc:
+ raise MetadataAssemblyError("Unsupported issue designation.") from exc
+ designations.add(designation.casefold())
+ return archive.issue
+ return archive.series
+
+
+def _archive_documents(
+ archive: ArchiveMetadataReconciliation, kind: MetadataEntityKind, field: str, value: object
+) -> tuple[EmbeddedDocument, ...]:
+ sources: tuple[tuple[EmbeddedDocument, MetadataValues], ...] = (
+ ("ComicInfo.xml", getattr(archive.comicinfo, kind.value)),
+ ("MetronInfo.xml", getattr(archive.metroninfo, kind.value)),
+ )
+ return tuple(name for name, fields in sources if getattr(fields, field) == value)
diff --git a/src/pullbox/services/metadata_baselines.py b/src/pullbox/services/metadata_baselines.py
new file mode 100644
index 00000000..c0350e23
--- /dev/null
+++ b/src/pullbox/services/metadata_baselines.py
@@ -0,0 +1,229 @@
+"""Durable canonical metadata baselines with caller-owned transactions."""
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+from pydantic import ValidationError
+from sqlalchemy import insert, select, update
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.models import Base, Issue, Series, StoryArc, StoryArcExternalIdentity
+from pullbox.models.metadata_baseline import (
+ IssueMetadataBaseline,
+ SeriesMetadataBaseline,
+ StoryArcMetadataBaseline,
+)
+from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+_MODELS: dict[
+ MetadataEntityKind,
+ tuple[
+ type[Series] | type[Issue] | type[StoryArc],
+ type[SeriesMetadataBaseline] | type[IssueMetadataBaseline] | type[StoryArcMetadataBaseline],
+ type[SeriesExternalIdentity] | type[IssueExternalIdentity] | type[StoryArcExternalIdentity],
+ ],
+] = {
+ MetadataEntityKind.SERIES: (Series, SeriesMetadataBaseline, SeriesExternalIdentity),
+ MetadataEntityKind.ISSUE: (Issue, IssueMetadataBaseline, IssueExternalIdentity),
+ MetadataEntityKind.STORY_ARC: (StoryArc, StoryArcMetadataBaseline, StoryArcExternalIdentity),
+}
+
+
+class MetadataBaselineConflictError(ValueError):
+ """The baseline or its identity ownership changed before persistence."""
+
+
+@dataclass(frozen=True)
+class MetadataBaselineWrite:
+ local_id: int
+ snapshot: MetadataSnapshot
+ expected_revision: int = 0
+
+
+@dataclass(frozen=True)
+class SavedMetadataBaseline:
+ local_id: int
+ revision: int
+ snapshot: MetadataSnapshot
+
+
+async def load_metadata_baseline(
+ session: AsyncSession, kind: MetadataEntityKind, local_id: int
+) -> SavedMetadataBaseline | None:
+ _, model, _ = _MODELS[kind]
+ table = Base.metadata.tables[model.__tablename__]
+ row = (
+ (await session.execute(select(table).where(table.c[f"{kind.value}_id"] == local_id)))
+ .mappings()
+ .one_or_none()
+ )
+ if row is None:
+ return None
+ try:
+ snapshot = MetadataSnapshot.model_validate_json(row.snapshot_json)
+ if snapshot.entity_kind is not kind:
+ raise ValueError("Wrong entity kind")
+ except (ValueError, ValidationError) as exc:
+ raise MetadataBaselineConflictError(
+ "Stored metadata baseline is invalid. Review before refreshing."
+ ) from exc
+ return SavedMetadataBaseline(local_id, row.revision, snapshot)
+
+
+async def save_metadata_baselines(
+ session: AsyncSession, writes: Sequence[MetadataBaselineWrite]
+) -> tuple[SavedMetadataBaseline, ...]:
+ if not writes:
+ return ()
+ if len(writes) > 200:
+ raise ValueError("Metadata baseline batches are limited to 200 entities")
+ keys = set()
+ payloads = []
+ for item in writes:
+ key = (item.snapshot.entity_kind, item.local_id)
+ if (
+ type(item.local_id) is not int
+ or item.local_id <= 0
+ or type(item.expected_revision) is not int
+ or item.expected_revision < 0
+ or key in keys
+ ):
+ raise ValueError("Invalid or repeated metadata baseline target")
+ keys.add(key)
+ payload = item.snapshot.model_dump_json()
+ MetadataSnapshot.model_validate_json(payload)
+ if len(payload.encode("utf-8")) > 1048576:
+ raise ValueError("Metadata baseline exceeds its storage limit")
+ payloads.append(payload)
+ try:
+ async with metadata_write_scope(session):
+ await _lock_targets(session, writes)
+ results = []
+ for kind in (
+ MetadataEntityKind.STORY_ARC,
+ MetadataEntityKind.SERIES,
+ MetadataEntityKind.ISSUE,
+ ):
+ group = [
+ (item, payload)
+ for item, payload in zip(writes, payloads, strict=True)
+ if item.snapshot.entity_kind is kind
+ ]
+ if group:
+ results.extend(await _save_group(session, kind, group))
+ await session.flush()
+ except IntegrityError as exc:
+ raise MetadataBaselineConflictError(
+ "Metadata baseline changed. Reload before retrying."
+ ) from exc
+ by_key = {(result.snapshot.entity_kind, result.local_id): result for result in results}
+ return tuple(by_key[(item.snapshot.entity_kind, item.local_id)] for item in writes)
+
+
+async def _lock_targets(session: AsyncSession, writes: Sequence[MetadataBaselineWrite]) -> None:
+ ids = {
+ kind: {item.local_id for item in writes if item.snapshot.entity_kind is kind}
+ for kind in MetadataEntityKind
+ }
+ parents = dict(
+ (
+ await session.execute(
+ select(Issue.id, Issue.series_id).where(Issue.id.in_(ids[MetadataEntityKind.ISSUE]))
+ )
+ )
+ .tuples()
+ .all()
+ )
+ series_ids = ids[MetadataEntityKind.SERIES] | set(parents.values())
+ # Match identity writers: arcs, series parents, then issues, each in ID order.
+ for kind, targets in (
+ (MetadataEntityKind.STORY_ARC, ids[MetadataEntityKind.STORY_ARC]),
+ (MetadataEntityKind.SERIES, series_ids),
+ (MetadataEntityKind.ISSUE, ids[MetadataEntityKind.ISSUE]),
+ ):
+ model, _, _ = _MODELS[kind]
+ found = set(
+ await session.scalars(
+ select(model.id).where(model.id.in_(targets)).order_by(model.id).with_for_update()
+ )
+ )
+ if found != targets:
+ raise MetadataBaselineConflictError("Metadata target no longer exists.")
+ actual = dict(
+ (await session.execute(select(Issue.id, Issue.series_id).where(Issue.id.in_(parents))))
+ .tuples()
+ .all()
+ )
+ if actual != parents:
+ raise MetadataBaselineConflictError("Issue parent changed. Reload before retrying.")
+
+
+async def _save_group(
+ session: AsyncSession, kind: MetadataEntityKind, group: list[tuple[MetadataBaselineWrite, str]]
+) -> list[SavedMetadataBaseline]:
+ _, model, claim_model = _MODELS[kind]
+ table = Base.metadata.tables[model.__tablename__]
+ claims = Base.metadata.tables[claim_model.__tablename__]
+ key = f"{kind.value}_id"
+ ids = [item.local_id for item, _ in group]
+ saved = {
+ row[key]: row
+ for row in (
+ await session.execute(select(table).where(table.c[key].in_(ids)).with_for_update())
+ ).mappings()
+ }
+ verified: dict[int, set[ExternalIdentityRef]] = {local_id: set() for local_id in ids}
+ for claim in (
+ await session.execute(
+ select(claims).where(claims.c[key].in_(ids)).with_for_update(read=True)
+ )
+ ).mappings():
+ namespace = (
+ claim.source if kind is MetadataEntityKind.STORY_ARC else claim.identity_namespace
+ )
+ if kind is MetadataEntityKind.STORY_ARC and (
+ claim.namespace != "story_arc" or namespace not in IdentityNamespace
+ ):
+ continue
+ if claim.verification_state == IdentityVerificationState.VERIFIED:
+ verified[claim[key]].add(
+ ExternalIdentityRef(IdentityNamespace(namespace), kind, claim.external_id)
+ )
+ additions = []
+ results = []
+ for item, payload in group:
+ row = saved.get(item.local_id)
+ if (row.revision if row else 0) != item.expected_revision or set(
+ item.snapshot.identities
+ ) != verified[item.local_id]:
+ raise MetadataBaselineConflictError(
+ "Metadata baseline or verified identities changed. Reload before retrying."
+ )
+ values = {
+ key: item.local_id,
+ "revision": item.expected_revision + 1,
+ "snapshot_json": payload,
+ }
+ if row is None:
+ additions.append(values)
+ else:
+ await session.execute(
+ update(table)
+ .where(table.c.id == row.id, table.c.revision == item.expected_revision)
+ .values(**values)
+ )
+ results.append(
+ SavedMetadataBaseline(item.local_id, item.expected_revision + 1, item.snapshot)
+ )
+ if additions:
+ await session.execute(insert(table), additions)
+ return results
diff --git a/src/pullbox/services/metadata_catalog_checkpoints.py b/src/pullbox/services/metadata_catalog_checkpoints.py
new file mode 100644
index 00000000..f371cb1e
--- /dev/null
+++ b/src/pullbox/services/metadata_catalog_checkpoints.py
@@ -0,0 +1,266 @@
+"""Source-bound progress for catalog synchronization, separate from provenance."""
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.models import Series, SeriesCatalogCheckpoint
+from pullbox.models.metadata_identity import SeriesExternalIdentity
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+
+class CatalogCheckpointConflictError(ValueError):
+ """The source, identity or previous catalog checkpoint changed."""
+
+
+@dataclass(frozen=True)
+class CatalogCheckpoint:
+ series_id: int
+ source: MetadataSource
+ source_revision: int
+ identity_id: int
+ identity_revision: int
+ external_id: str
+ revision: int
+ checked_at: datetime
+ full_synced_at: datetime
+ source_updated_at: datetime | None
+
+
+def _snapshot(row: SeriesCatalogCheckpoint) -> CatalogCheckpoint:
+ return CatalogCheckpoint(
+ row.series_id,
+ MetadataSource(row.source),
+ row.source_revision,
+ row.identity_id,
+ row.identity_revision,
+ row.external_id,
+ row.revision,
+ row.checked_at,
+ row.full_synced_at,
+ row.source_updated_at,
+ )
+
+
+async def read_catalog_checkpoints(
+ session: AsyncSession, series_id: int
+) -> tuple[CatalogCheckpoint, ...]:
+ """Include invalidated rows so optimistic refresh read sets detect changes."""
+ return tuple(
+ _snapshot(row)
+ for row in await session.scalars(
+ select(SeriesCatalogCheckpoint)
+ .where(SeriesCatalogCheckpoint.series_id == series_id)
+ .order_by(SeriesCatalogCheckpoint.source)
+ .execution_options(populate_existing=True)
+ )
+ )
+
+
+async def load_catalog_checkpoint(
+ session: AsyncSession, series_id: int, source: MetadataSource
+) -> CatalogCheckpoint | None:
+ """Return progress only while its exact source and identity binding is current.
+
+ Local readers must additionally compare the window's generation before
+ applying it. A changed generation requires another full synchronization.
+ """
+ row = await session.scalar(
+ select(SeriesCatalogCheckpoint)
+ .join(MetadataSourceConfig, MetadataSourceConfig.source == SeriesCatalogCheckpoint.source)
+ .join(
+ SeriesExternalIdentity, SeriesExternalIdentity.id == SeriesCatalogCheckpoint.identity_id
+ )
+ .where(
+ SeriesCatalogCheckpoint.series_id == series_id,
+ SeriesCatalogCheckpoint.source == source.value,
+ MetadataSourceConfig.enabled.is_(True),
+ MetadataSourceConfig.revision == SeriesCatalogCheckpoint.source_revision,
+ SeriesExternalIdentity.series_id == series_id,
+ SeriesExternalIdentity.identity_namespace == source.identity_namespace,
+ SeriesExternalIdentity.external_id == SeriesCatalogCheckpoint.external_id,
+ SeriesExternalIdentity.revision == SeriesCatalogCheckpoint.identity_revision,
+ SeriesExternalIdentity.verification_state == IdentityVerificationState.VERIFIED,
+ )
+ .execution_options(populate_existing=True)
+ )
+ return _snapshot(row) if row else None
+
+
+def _utc(value: datetime) -> datetime:
+ if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
+ raise CatalogCheckpointConflictError(
+ "Catalog progress requires an aware request timestamp."
+ )
+ return value.astimezone(UTC)
+
+
+async def save_full_catalog_checkpoint(
+ session: AsyncSession,
+ series_id: int,
+ *,
+ source: MetadataSource,
+ source_revision: int,
+ identity_revision: int,
+ external_id: str,
+ started_at: datetime,
+ source_updated_at: datetime | None = None,
+ expected_revision: int = 0,
+) -> CatalogCheckpoint:
+ """Save only after a complete catalog is applied, in the caller's transaction.
+
+ Never call with a publication slice or truncated modification window. The
+ boundary is the live request start, not the time the final page was saved.
+ """
+ return await _save_catalog_checkpoint(
+ session,
+ series_id,
+ source=source,
+ source_revision=source_revision,
+ identity_revision=identity_revision,
+ external_id=external_id,
+ started_at=started_at,
+ source_updated_at=source_updated_at,
+ expected_revision=expected_revision,
+ )
+
+
+async def advance_catalog_checkpoint(
+ session: AsyncSession,
+ previous: CatalogCheckpoint,
+ *,
+ started_at: datetime,
+ source_updated_at: datetime | None = None,
+) -> CatalogCheckpoint:
+ """Advance a fully applied bounded window without resetting its full-sync date.
+
+ The caller must fall back to full reconciliation for truncated modification
+ windows or changed local generations, and commit its issue writes atomically.
+ """
+ return await _save_catalog_checkpoint(
+ session,
+ previous.series_id,
+ source=previous.source,
+ source_revision=previous.source_revision,
+ identity_revision=previous.identity_revision,
+ external_id=previous.external_id,
+ started_at=started_at,
+ source_updated_at=source_updated_at,
+ expected_revision=previous.revision,
+ previous=previous,
+ )
+
+
+async def _save_catalog_checkpoint(
+ session: AsyncSession,
+ series_id: int,
+ *,
+ source: MetadataSource,
+ source_revision: int,
+ identity_revision: int,
+ external_id: str,
+ started_at: datetime,
+ source_updated_at: datetime | None,
+ expected_revision: int,
+ previous: CatalogCheckpoint | None = None,
+) -> CatalogCheckpoint:
+ if (
+ not isinstance(source, MetadataSource)
+ or type(source_revision) is not int
+ or source_revision <= 0
+ or type(identity_revision) is not int
+ or identity_revision <= 0
+ or type(expected_revision) is not int
+ or expected_revision < 0
+ ):
+ raise CatalogCheckpointConflictError("Invalid catalog source or revision.")
+ identity = ExternalIdentityRef(
+ source.identity_namespace, MetadataEntityKind.SERIES, external_id
+ )
+ if identity.external_id != external_id:
+ raise CatalogCheckpointConflictError("Catalog progress requires an exact identity.")
+ started_at = _utc(started_at)
+ if started_at > datetime.now(UTC):
+ raise CatalogCheckpointConflictError("Catalog progress cannot start in the future.")
+ local = source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL}
+ if local and source_updated_at is None:
+ raise CatalogCheckpointConflictError("Local catalog progress requires its generation.")
+ generation = _utc(source_updated_at) if local and source_updated_at else None
+ try:
+ async with metadata_write_scope(session):
+ config = await session.scalar(
+ select(MetadataSourceConfig)
+ .where(MetadataSourceConfig.source == source.value)
+ .with_for_update(read=True)
+ .execution_options(populate_existing=True)
+ )
+ parent = await session.scalar(
+ select(Series.id).where(Series.id == series_id).with_for_update()
+ )
+ claim = await session.scalar(
+ select(SeriesExternalIdentity)
+ .where(
+ SeriesExternalIdentity.series_id == series_id,
+ SeriesExternalIdentity.identity_namespace == source.identity_namespace,
+ )
+ .with_for_update(read=True)
+ .execution_options(populate_existing=True)
+ )
+ if (
+ parent is None
+ or config is None
+ or not config.enabled
+ or config.revision != source_revision
+ or claim is None
+ or claim.external_id != external_id
+ or claim.revision != identity_revision
+ or claim.verification_state is not IdentityVerificationState.VERIFIED
+ ):
+ raise CatalogCheckpointConflictError("Catalog source or series identity changed.")
+ row = await session.scalar(
+ select(SeriesCatalogCheckpoint)
+ .where(
+ SeriesCatalogCheckpoint.series_id == series_id,
+ SeriesCatalogCheckpoint.source == source.value,
+ )
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if (row.revision if row else 0) != expected_revision:
+ raise CatalogCheckpointConflictError(
+ "Catalog progress changed. Read the catalog again."
+ )
+ if row is not None and started_at < row.checked_at:
+ raise CatalogCheckpointConflictError(
+ "An older catalog cannot replace newer progress."
+ )
+ if previous is not None and (
+ row is None
+ or _snapshot(row) != previous
+ or generation != previous.source_updated_at
+ ):
+ raise CatalogCheckpointConflictError(
+ "Catalog progress or generation changed. Read the full catalog again."
+ )
+ if row is None:
+ row = SeriesCatalogCheckpoint(series_id=series_id, source=source.value)
+ session.add(row)
+ row.source_revision = source_revision
+ row.identity_id, row.identity_revision = claim.id, claim.revision
+ row.external_id = external_id
+ row.revision = expected_revision + 1
+ row.checked_at = started_at
+ row.full_synced_at = previous.full_synced_at if previous else started_at
+ row.source_updated_at = generation
+ await session.flush()
+ return _snapshot(row)
+ except IntegrityError as exc:
+ raise CatalogCheckpointConflictError(
+ "Catalog progress changed. Read the catalog again."
+ ) from exc
diff --git a/src/pullbox/services/metadata_catalog_review.py b/src/pullbox/services/metadata_catalog_review.py
new file mode 100644
index 00000000..b9fa1821
--- /dev/null
+++ b/src/pullbox/services/metadata_catalog_review.py
@@ -0,0 +1,114 @@
+"""Explicit Add exclusions for unsupported GCD designations, not identity guessing."""
+
+import hashlib
+import json
+from dataclasses import replace
+
+from pullbox.core.issue_numbers import parse_issue_number_text
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.schemas.metadata_sources import (
+ CatalogExcludedIssue,
+ CatalogReviewRead,
+ ProviderIssueRead,
+)
+from pullbox.services.metadata_series_adoption import (
+ SeriesAdoptionError,
+ SourceIssueBatch,
+ SourceSeriesBundle,
+ _validate_bundle,
+ _validate_issue_batch,
+)
+
+
+def _entry(issue: ProviderIssueRead) -> CatalogExcludedIssue:
+ return CatalogExcludedIssue(
+ source=MetadataSource.GCD_LOCAL,
+ series_external_id=issue.series_external_id,
+ external_id=issue.external_id,
+ issue_number_text=issue.issue_number_text,
+ )
+
+
+def catalog_review(bundle: SourceSeriesBundle) -> CatalogReviewRead | None:
+ if bundle.series.source is not MetadataSource.GCD_LOCAL:
+ return None
+ supported, excluded = [], []
+ for issue in bundle.issues:
+ try:
+ parse_issue_number_text(issue.issue_number_text)
+ except ValueError:
+ excluded.append(issue)
+ else:
+ supported.append(issue)
+ if not excluded:
+ _validate_bundle(bundle)
+ return None
+ # Counts, identity, parent and duplicate checks still apply to every entry.
+ if supported:
+ _validate_bundle(replace(bundle, issues=tuple(supported), excluded_issues=tuple(excluded)))
+ else:
+ _validate_issue_batch(
+ SourceIssueBatch(
+ bundle.series.source,
+ bundle.series.external_id,
+ bundle.issues,
+ bundle.source_revision,
+ ),
+ allow_unsupported=True,
+ )
+ payload = {
+ "series": bundle.series.model_dump(mode="json"),
+ "source_revision": bundle.source_revision,
+ "issues": [item.model_dump(mode="json") for item in bundle.issues],
+ }
+ # Content fingerprint binds consent to the fetched catalog, not authentication.
+ token = hashlib.sha256(
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
+ ).hexdigest()
+ return CatalogReviewRead(
+ token=token,
+ total=bundle.catalog_total,
+ supported_count=len(supported),
+ excluded=tuple(_entry(item) for item in excluded),
+ )
+
+
+def approve_catalog_review(bundle: SourceSeriesBundle, token: str | None) -> SourceSeriesBundle:
+ review = catalog_review(bundle)
+ if review is None:
+ if token is not None:
+ raise SeriesAdoptionError("The catalog changed. Preview the series again.")
+ return bundle
+ if token != review.token or not review.supported_count:
+ raise SeriesAdoptionError(
+ "Review the unsupported catalog entries before adding this series."
+ )
+ return apply_catalog_exclusions(bundle, review.excluded)
+
+
+def apply_catalog_exclusions(
+ bundle: SourceSeriesBundle, saved: tuple[CatalogExcludedIssue, ...]
+) -> SourceSeriesBundle:
+ relevant = tuple(
+ item
+ for item in saved
+ if item.source is bundle.series.source
+ and item.series_external_id == bundle.series.external_id
+ )
+ if not relevant:
+ return bundle
+ by_id = {item.external_id: item for item in relevant}
+ excluded = tuple(item for item in bundle.issues if item.external_id in by_id)
+ if len(excluded) != len(relevant) or any(
+ _entry(item) != by_id[item.external_id] for item in excluded
+ ):
+ raise SeriesAdoptionError(
+ "Previously reviewed GCD entries changed. Review the catalog before retrying."
+ )
+ result = replace(
+ bundle,
+ issues=tuple(item for item in bundle.issues if item.external_id not in by_id),
+ excluded_issues=excluded,
+ )
+ _validate_bundle(result)
+ return result
diff --git a/src/pullbox/services/metadata_credits.py b/src/pullbox/services/metadata_credits.py
new file mode 100644
index 00000000..5c114933
--- /dev/null
+++ b/src/pullbox/services/metadata_credits.py
@@ -0,0 +1,109 @@
+"""Batch descriptive credits inside the caller's locked metadata transaction."""
+
+from collections.abc import Mapping, Sequence
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.models.creator import Creator, IssueCreator
+from pullbox.schemas.metadata_credits import MAX_CREDITS, MetadataCredit, parse_credits
+
+_BATCH = 200
+
+
+async def _rows(session: AsyncSession, issue_ids: Sequence[int]) -> list[tuple[int, int, str, str]]:
+ rows = list(
+ (
+ await session.execute(
+ select(IssueCreator.issue_id, Creator.id, Creator.name, IssueCreator.role)
+ .join(Creator, Creator.id == IssueCreator.creator_id)
+ .where(IssueCreator.issue_id.in_(issue_ids))
+ .order_by(IssueCreator.issue_id, Creator.id)
+ .limit(len(issue_ids) * MAX_CREDITS + 1)
+ )
+ )
+ .tuples()
+ .all()
+ )
+ if len(rows) > len(issue_ids) * MAX_CREDITS:
+ raise ValueError("Stored creator credits exceed the bounded metadata limit.")
+ return rows
+
+
+def _group(rows: Sequence[tuple[int, int, str, str]]) -> dict[int, tuple[MetadataCredit, ...]]:
+ grouped: dict[int, list[MetadataCredit]] = {}
+ for issue_id, _, name, role in rows:
+ grouped.setdefault(issue_id, []).append(MetadataCredit(name=name, role=role))
+ return {issue_id: parse_credits(values) for issue_id, values in grouped.items()}
+
+
+async def read_issue_credits(
+ session: AsyncSession, issue_ids: Sequence[int]
+) -> dict[int, tuple[MetadataCredit, ...]]:
+ result = {}
+ for offset in range(0, len(issue_ids), _BATCH):
+ result.update(_group(await _rows(session, issue_ids[offset : offset + _BATCH])))
+ return result
+
+
+async def write_issue_credits(
+ session: AsyncSession, values: Mapping[int, tuple[MetadataCredit, ...] | None]
+) -> None:
+ """Persist assembled values, never assign foreign creator IDs by name.
+
+ Existing issue links retain their creator IDs where possible. Generic
+ descriptive names can be reused, but a name alone cannot adopt another
+ issue's ComicVine-identified creator or rename a shared creator row.
+ None means no evidence; an empty tuple is an intentional local clear.
+ """
+ if len(values) > _BATCH:
+ raise ValueError("Write creator credits in batches of at most 200 issues.")
+ incoming = {key: value for key, value in values.items() if value is not None}
+ if not incoming:
+ return
+ rows = await _rows(session, list(incoming))
+ current = _group(rows)
+ changed = {key: value for key, value in incoming.items() if current.get(key, ()) != value}
+ if not changed:
+ return
+ existing = {(issue_id, name): creator_id for issue_id, creator_id, name, _ in reversed(rows)}
+ names = sorted({credit.name for credits in changed.values() for credit in credits})
+ generic: dict[str, int] = {}
+ for offset in range(0, len(names), _BATCH):
+ generic.update(
+ (
+ await session.execute(
+ select(Creator.name, func.min(Creator.id))
+ .where(
+ Creator.name.in_(names[offset : offset + _BATCH]),
+ Creator.comicvine_id.is_(None),
+ )
+ .group_by(Creator.name)
+ )
+ )
+ .tuples()
+ .all()
+ )
+ needed = {
+ credit.name
+ for issue_id, credits in changed.items()
+ for credit in credits
+ if credit.name not in generic and (issue_id, credit.name) not in existing
+ }
+ pending = {name: Creator(name=name) for name in sorted(needed)}
+ session.add_all(pending.values())
+ await session.flush()
+ generic.update({name: creator.id for name, creator in pending.items()})
+ await session.execute(delete(IssueCreator).where(IssueCreator.issue_id.in_(changed)))
+ session.add_all(
+ [
+ IssueCreator(
+ issue_id=issue_id,
+ creator_id=existing.get((issue_id, credit.name)) or generic[credit.name],
+ role=credit.role,
+ )
+ for issue_id, credits in changed.items()
+ for credit in credits
+ ]
+ )
+ await session.flush()
diff --git a/src/pullbox/services/metadata_daily_sync.py b/src/pullbox/services/metadata_daily_sync.py
new file mode 100644
index 00000000..873b4c9c
--- /dev/null
+++ b/src/pullbox/services/metadata_daily_sync.py
@@ -0,0 +1,302 @@
+"""Bounded source-aware daily catalog checks with caller-owned progress."""
+
+import asyncio
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.config import get_settings
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.models import Issue, Series
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.models.series import IssueCatalogState, SeriesStatus
+from pullbox.schemas.metadata_sources import (
+ MetadataDomain,
+ RecentIssueWindow,
+ SourceCapability,
+ SourceOutcome,
+ SourceStatus,
+)
+from pullbox.services.cover_resolver import resolve_covers_dir
+from pullbox.services.metadata_assembly import assemble_metadata
+from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines
+from pullbox.services.metadata_catalog_checkpoints import (
+ CatalogCheckpoint,
+ advance_catalog_checkpoint,
+ load_catalog_checkpoint,
+)
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_issue_catalog import SourceIssueBatch, apply_issue_batch
+from pullbox.services.metadata_scheduled_refresh import ScheduledSeriesRefresh
+from pullbox.services.metadata_series_refresh import (
+ SeriesRefreshError,
+ _with_derived,
+ refresh_series_catalog_from_sources,
+)
+from pullbox.services.metadata_series_refresh_state import (
+ SERIES_FIELDS,
+ SeriesRefreshState,
+ read_series_refresh_state,
+)
+from pullbox.services.metadata_sources import load_source_runtime
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+
+@dataclass(frozen=True)
+class _Cadence:
+ monitored: bool
+ catalog_state: IssueCatalogState
+ metadata_refreshed: datetime | None
+ checked: datetime | None
+ synced: datetime | None
+
+
+def _cadence(series: Series) -> _Cadence:
+ return _Cadence(
+ series.monitored,
+ series.issue_catalog_state,
+ series.metadata_last_refreshed,
+ series.issue_catalog_last_checked_at,
+ series.issue_catalog_last_synced_at,
+ )
+
+
+def _full_due(
+ cadence: _Cadence, checkpoint: CatalogCheckpoint | None, now: datetime, days: int
+) -> bool:
+ return (
+ checkpoint is None
+ or cadence.catalog_state is not IssueCatalogState.COMPLETE
+ or days <= 0
+ or now - checkpoint.full_synced_at >= timedelta(days=days)
+ or cadence.metadata_refreshed is None
+ or now - cadence.metadata_refreshed >= timedelta(days=days)
+ )
+
+
+def _window_requires_full(
+ window: RecentIssueWindow, checkpoint: CatalogCheckpoint, state: SeriesRefreshState
+) -> bool:
+ if (
+ checkpoint.source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL}
+ and window.source_updated_at != checkpoint.source_updated_at
+ ):
+ return True
+ if window.scope == "modified_since":
+ return window.truncated
+ # Publication slices cannot hide a gap larger than the returned window.
+ known = {
+ ref.external_id
+ for item in state.issues
+ for ref in item.identities
+ if ref.namespace is checkpoint.source.identity_namespace
+ }
+ additions = {item.external_id for item in window.results} - known
+ return window.matched_total != len(known) + len(additions)
+
+
+async def sync_scheduled_issue_catalog(
+ session: AsyncSession,
+ series_id: int,
+ *,
+ refresh_days: int,
+ registry: MetadataSourceRegistry | None = None,
+) -> ScheduledSeriesRefresh:
+ """Read outside transactions, then save issues and progress for the task to commit."""
+ if session.new or session.dirty or session.deleted:
+ raise SeriesRefreshError("Finish pending library changes before synchronizing issues.")
+ try:
+ async with asyncio.timeout(120):
+ return await _sync(session, series_id, refresh_days=refresh_days, registry=registry)
+ except SeriesRefreshError:
+ raise
+ except (ValueError, IntegrityError, ValidationError) as exc:
+ raise SeriesRefreshError(
+ "The issue catalog could not be synchronized safely. Review its match or retry."
+ ) from exc
+ except TimeoutError as exc:
+ raise SeriesRefreshError(
+ "Issue synchronization timed out. Retry later.", retry_after_seconds=300
+ ) from exc
+
+
+async def _sync(
+ session: AsyncSession,
+ series_id: int,
+ *,
+ refresh_days: int,
+ registry: MetadataSourceRegistry | None = None,
+) -> ScheduledSeriesRefresh:
+ before = await read_series_refresh_state(session, series_id)
+ series = await session.get(Series, series_id)
+ assert series is not None
+ cadence = _cadence(series)
+ covers = await resolve_covers_dir(session)
+ enabled = get_settings().metadata_gcd_api_v2_enabled
+ registry = registry or MetadataSourceRegistry(
+ await load_source_runtime(session, gcd_api_enabled=enabled),
+ gcd_api_enabled=enabled,
+ revalidate_reads=True,
+ total_timeout=60,
+ )
+ known = {ref.namespace: ref.external_id for ref in before.series.identities}
+ sources = sorted(
+ (
+ source
+ for source in registry.runtime
+ if source.identity_namespace in known
+ and registry.source_availability(source, SourceCapability.ISSUE_LIST) is None
+ and registry.source_availability(source, SourceCapability.SERIES_DETAILS) is None
+ ),
+ key=lambda source: (
+ registry.runtime[source].policy.domain_priorities.get(
+ MetadataDomain.ISSUES, registry.runtime[source].policy.priority
+ ),
+ source.value,
+ ),
+ )
+ checkpoints = {
+ source: await load_catalog_checkpoint(session, series_id, source) for source in sources
+ }
+ await session.rollback()
+ now = datetime.now(UTC)
+ interval = timedelta(
+ days=(14 if cadence.monitored else 30)
+ if before.series.values.status == SeriesStatus.ENDED.value
+ else 1
+ )
+ failures: list[SourceOutcome] = []
+ for source in sources:
+ checkpoint = checkpoints[source]
+ full = _full_due(cadence, checkpoint, now, refresh_days)
+ if not full and checkpoint is not None and now - checkpoint.checked_at < interval:
+ return ScheduledSeriesRefresh(0, False, None, covers, tuple(failures))
+ full = (
+ full or registry.source_availability(source, SourceCapability.RECENT_ISSUES) is not None
+ )
+ if not full:
+ assert checkpoint is not None
+ started_at = datetime.now(UTC)
+ # Overlap the strict modified_gt boundary, including second-resolution providers.
+ since = checkpoint.checked_at.replace(microsecond=0) - timedelta(minutes=2)
+ fetched = await registry.recent_issues(
+ source, known[source.identity_namespace], since=since
+ )
+ if fetched.status is not SourceStatus.OK or fetched.data is None:
+ outcome = SourceOutcome(
+ source=source,
+ status=fetched.status,
+ retry_after_seconds=fetched.retry_after_seconds,
+ )
+ if fetched.status is SourceStatus.INCOMPATIBLE_RESPONSE:
+ raise SeriesRefreshError(
+ "The provider issue window is inconsistent. Review its match.",
+ outcomes=(outcome,),
+ )
+ failures.append(outcome)
+ registry.runtime.pop(source)
+ continue
+ full = _window_requires_full(fetched.data, checkpoint, before)
+ if not full:
+ created = await _apply_window(
+ session, before, cadence, checkpoint, fetched.data, started_at
+ )
+ return ScheduledSeriesRefresh(
+ len(created), cadence.monitored and bool(created), None, covers, tuple(failures)
+ )
+ if full:
+ try:
+ result = await refresh_series_catalog_from_sources(
+ session, series_id, registry=registry, replace_managed=False
+ )
+ except SeriesRefreshError as exc:
+ if not failures:
+ raise
+ # A failed fallback must not discard an earlier source's retry window.
+ raise SeriesRefreshError(
+ str(exc),
+ outcomes=(*failures, *exc.outcomes),
+ retry_after_seconds=exc.retry_after_seconds,
+ ) from exc
+ return ScheduledSeriesRefresh(
+ len(result.created_issue_ids),
+ result.series.monitored and bool(result.created_issue_ids),
+ result.series.cover_url,
+ covers,
+ (*failures, *result.outcomes),
+ )
+ raise SeriesRefreshError(
+ "No configured source could supply the issue catalog. Check source status and retry.",
+ outcomes=tuple(failures),
+ )
+
+
+async def _apply_window(
+ session: AsyncSession,
+ before: SeriesRefreshState,
+ cadence: _Cadence,
+ checkpoint: CatalogCheckpoint,
+ window: RecentIssueWindow,
+ started_at: datetime,
+) -> tuple[int, ...]:
+ series_id = before.series.local_id
+ async with metadata_write_scope(session):
+ await session.execute(
+ select(MetadataSourceConfig)
+ .order_by(MetadataSourceConfig.source)
+ .with_for_update(read=True)
+ )
+ await session.execute(select(Series.id).where(Series.id == series_id).with_for_update())
+ await session.execute(
+ select(Issue.id)
+ .where(Issue.series_id == series_id)
+ .order_by(Issue.id)
+ .with_for_update()
+ )
+ current = await read_series_refresh_state(session, series_id)
+ series = await session.get(Series, series_id)
+ if current != before or series is None or _cadence(series) != cadence:
+ raise SeriesRefreshError(
+ "Library metadata or catalog progress changed. Retry synchronization."
+ )
+ created = await apply_issue_batch(
+ session,
+ current,
+ SourceIssueBatch(
+ checkpoint.source,
+ checkpoint.external_id,
+ tuple(window.results),
+ checkpoint.source_revision,
+ ),
+ started_at,
+ )
+ if created:
+ series.issue_count = len(current.issues) + len(created)
+ snapshot = assemble_metadata(
+ MetadataEntityKind.SERIES,
+ current.series.identities,
+ [],
+ current.policies,
+ now=started_at,
+ current=current.series.values,
+ previous=current.series.baseline,
+ overrides=current.series.overrides,
+ replace_managed=False,
+ fields=SERIES_FIELDS,
+ )
+ snapshot = _with_derived(snapshot, "issue_count", series.issue_count, started_at)
+ await save_metadata_baselines(
+ session,
+ [MetadataBaselineWrite(series_id, snapshot, current.series.baseline_revision)],
+ )
+ series.issue_catalog_last_checked_at = started_at
+ series.issue_catalog_error = None
+ await advance_catalog_checkpoint(
+ session, checkpoint, started_at=started_at, source_updated_at=window.source_updated_at
+ )
+ await session.flush()
+ return created
diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py
new file mode 100644
index 00000000..86ed8e28
--- /dev/null
+++ b/src/pullbox/services/metadata_discovery.py
@@ -0,0 +1,570 @@
+"""Source-aware metadata discovery; identity attachment is a separate operation."""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Protocol
+
+import structlog
+
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.schemas.metadata_sources import (
+ MetadataDomain,
+ MetadataFetch,
+ MetadataPage,
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ RecentIssueWindow,
+ SeriesDiscoveryQuery,
+ SeriesDiscoveryRead,
+ SourceCapability,
+ SourceDescriptor,
+ SourceOutcome,
+ SourcePolicyRead,
+ SourceStatus,
+ StoryArcDiscoveryQuery,
+ StoryArcDiscoveryRead,
+ StoryArcSourceOutcome,
+)
+from pullbox.services.metadata_account_admission import AccountAttempt, account_request
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Mapping, Sequence
+ from datetime import datetime
+
+ from pullbox.services.metadata_read_cache import MetadataReadCache
+ from pullbox.services.metadata_sources import SourceRuntime
+
+logger = structlog.get_logger(__name__)
+
+
+def describe_source_policies(
+ policies: Sequence[SourcePolicyRead], *, gcd_api_enabled: bool
+) -> list[SourceDescriptor]:
+ """Expose capability/configuration snapshots without constructing clients."""
+ from pullbox.providers.metadata.sources import metadata_sources
+
+ registrations = metadata_sources()
+ result = []
+ for policy in policies:
+ registration = registrations.get(policy.source)
+ availability = policy.configuration_status
+ if policy.source is MetadataSource.GCD_API_V2 and not gcd_api_enabled:
+ availability = SourceStatus.FEATURE_DISABLED
+ elif not policy.enabled:
+ availability = availability or SourceStatus.DISABLED
+ elif registration is None:
+ availability = SourceStatus.NOT_IMPLEMENTED
+ elif (
+ policy.source in {MetadataSource.COMICVINE_API, MetadataSource.METRON_API}
+ and not policy.credential_configured
+ ):
+ availability = SourceStatus.UNCONFIGURED
+ result.append(
+ SourceDescriptor(
+ **policy.model_dump(),
+ capabilities=sorted(registration.capabilities) if registration else [],
+ availability=availability,
+ )
+ )
+ return result
+
+
+@dataclass(frozen=True)
+class SourcePage:
+ results: list[ProviderSeriesRead]
+ total: int | None = None
+ next_offset: int | None = None
+ rejected_results: int = 0
+ truncated: bool = False
+
+
+class MetadataSourceAdapter(Protocol):
+ async def search(self, query: SeriesDiscoveryQuery, offset: int) -> SourcePage: ...
+ async def check(self) -> None: ...
+ async def close(self) -> None: ...
+
+
+@dataclass
+class _SourceHandle:
+ adapter: MetadataSourceAdapter | None = None
+
+
+class MetadataSourceError(Exception):
+ def __init__(self, status: SourceStatus, retry_after_seconds: int | None = None) -> None:
+ self.status = status
+ self.retry_after_seconds = retry_after_seconds
+ super().__init__(status.value)
+
+
+@dataclass(frozen=True)
+class SourceRegistration:
+ capabilities: frozenset[SourceCapability]
+ factory: Callable[[SourceRuntime], MetadataSourceAdapter]
+
+
+class MetadataSourceRegistry:
+ def __init__(
+ self,
+ runtime: Sequence[SourceRuntime],
+ *,
+ factories: Mapping[MetadataSource, SourceRegistration] | None = None,
+ gcd_api_enabled: bool = False,
+ per_source_timeout: float = 8,
+ total_timeout: float = 15,
+ concurrency: int = 3,
+ read_cache: MetadataReadCache | None = None,
+ revalidate_reads: bool = False,
+ ) -> None:
+ if factories is None:
+ from pullbox.providers.metadata.sources import metadata_sources
+
+ factories = metadata_sources()
+ self.runtime = {item.policy.source: item for item in runtime}
+ self.factories = factories
+ self.gcd_api_enabled = gcd_api_enabled
+ if not 0 < per_source_timeout <= 30 or not 0 < total_timeout <= 60:
+ raise ValueError("Metadata discovery deadlines must be bounded")
+ if not 1 <= concurrency <= 5:
+ raise ValueError("Metadata discovery concurrency must be between one and five")
+ self.per_source_timeout = per_source_timeout
+ self.total_timeout = total_timeout
+ self.concurrency = concurrency
+ self.read_slots = asyncio.Semaphore(concurrency)
+ self.read_cache = read_cache
+ self.revalidate_reads = revalidate_reads
+
+ def _unavailable(
+ self,
+ source: MetadataSource,
+ *,
+ search: bool = False,
+ capability: SourceCapability | None = None,
+ ) -> SourceStatus | None:
+ runtime = self.runtime.get(source)
+ if source is MetadataSource.GCD_API_V2 and not self.gcd_api_enabled:
+ return SourceStatus.FEATURE_DISABLED
+ if runtime is None:
+ return SourceStatus.UNCONFIGURED
+ if runtime.unavailable is not None:
+ return runtime.unavailable
+ if not runtime.policy.enabled:
+ return SourceStatus.DISABLED
+ registration = self.factories.get(source)
+ if registration is None:
+ return SourceStatus.NOT_IMPLEMENTED
+ if search and SourceCapability.SERIES_SEARCH not in registration.capabilities:
+ return SourceStatus.UNSUPPORTED
+ if capability is not None and capability not in registration.capabilities:
+ return SourceStatus.UNSUPPORTED
+ return None
+
+ async def _run(
+ self,
+ source: MetadataSource,
+ query: SeriesDiscoveryQuery | None,
+ semaphore: asyncio.Semaphore,
+ deadline: float,
+ handle: _SourceHandle | None = None,
+ *,
+ retry_authentication: bool = False,
+ ) -> tuple[SourcePage, SourceOutcome]:
+ unavailable = self._unavailable(source, search=query is not None)
+ if unavailable is not None:
+ return SourcePage([]), SourceOutcome(source=source, status=unavailable)
+ if asyncio.get_running_loop().time() >= deadline:
+ return SourcePage([]), SourceOutcome(source=source, status=SourceStatus.TIMEOUT)
+ try:
+ async with account_request(
+ self.runtime[source],
+ slots=semaphore,
+ deadline=deadline,
+ retry_authentication=retry_authentication,
+ ) as attempt:
+ if attempt.blocked is not None:
+ return SourcePage([]), attempt.blocked
+ page, outcome = await self._run_admitted(source, query, deadline, handle, attempt)
+ attempt.outcome = outcome
+ return page, outcome
+ except TimeoutError:
+ return SourcePage([]), SourceOutcome(source=source, status=SourceStatus.TIMEOUT)
+
+ async def _run_admitted(
+ self,
+ source: MetadataSource,
+ query: SeriesDiscoveryQuery | None,
+ deadline: float,
+ handle: _SourceHandle | None,
+ attempt: AccountAttempt,
+ ) -> tuple[SourcePage, SourceOutcome]:
+ owns_handle = handle is None
+ handle = handle or _SourceHandle()
+ page = SourcePage([])
+ try:
+ async with asyncio.timeout_at(deadline):
+ async with asyncio.timeout(self.per_source_timeout):
+ if handle.adapter is None:
+ handle.adapter = self.factories[source].factory(self.runtime[source])
+ adapter = handle.adapter
+ attempt.started = True
+ if query is None:
+ await adapter.check()
+ else:
+ page = await adapter.search(query, query.offsets.get(source, 0))
+ if any(
+ item.source != source
+ or item.identity_namespace != source.identity_namespace
+ for item in page.results
+ ):
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE)
+ if query is not None and len(page.results) > query.limit_per_source:
+ raise MetadataSourceError(SourceStatus.INCOMPATIBLE_RESPONSE)
+ status = (
+ SourceStatus.INCOMPATIBLE_RESPONSE
+ if page.rejected_results
+ else SourceStatus.OK
+ if page.results or query is None
+ else SourceStatus.EMPTY
+ )
+ outcome = SourceOutcome(
+ source=source,
+ status=status,
+ total=page.total,
+ next_offset=page.next_offset,
+ rejected_results=page.rejected_results,
+ truncated=page.truncated,
+ )
+ except TimeoutError:
+ outcome = SourceOutcome(source=source, status=SourceStatus.TIMEOUT)
+ page = SourcePage([])
+ except MetadataSourceError as exc:
+ outcome = SourceOutcome(
+ source=source, status=exc.status, retry_after_seconds=exc.retry_after_seconds
+ )
+ page = SourcePage([])
+ except Exception:
+ # A provider fault must not leak credentials or hide other source results.
+ logger.warning("metadata_source_operation_failed", source=source.value)
+ outcome = SourceOutcome(source=source, status=SourceStatus.UNAVAILABLE)
+ page = SourcePage([])
+ finally:
+ if owns_handle:
+ await self._close_source(source, handle)
+ return page, outcome
+
+ @staticmethod
+ async def _close_source(source: MetadataSource, handle: _SourceHandle) -> None:
+ if handle.adapter is not None:
+ try:
+ async with asyncio.timeout(2):
+ await handle.adapter.close()
+ except Exception:
+ logger.warning("metadata_source_close_failed", source=source.value)
+
+ def _ordered_sources(
+ self,
+ query: SeriesDiscoveryQuery | StoryArcDiscoveryQuery,
+ *,
+ domain: MetadataDomain = MetadataDomain.CORE,
+ ) -> list[MetadataSource]:
+ return self.ordered_sources(sources=query.sources, domain=domain)
+
+ def ordered_sources(
+ self,
+ *,
+ sources: Sequence[MetadataSource] | None = None,
+ domain: MetadataDomain = MetadataDomain.CORE,
+ ) -> list[MetadataSource]:
+ """Use the same configured authority for discovery and exact-ID refresh."""
+ return sorted(
+ sources if sources is not None else self.runtime,
+ key=lambda source: (
+ self.runtime[source].policy.domain_priorities.get(
+ domain, self.runtime[source].policy.priority
+ )
+ if source in self.runtime
+ else 1001,
+ source.value,
+ ),
+ )
+
+ def source_availability(
+ self, source: MetadataSource, capability: SourceCapability
+ ) -> SourceStatus | None:
+ """Inspect configuration and capabilities without constructing a client."""
+ return self._unavailable(source, capability=capability)
+
+ @staticmethod
+ def _group_pages(pages: Sequence[tuple[SourcePage, SourceOutcome]]) -> SeriesDiscoveryRead:
+ grouped = {}
+ for page, _ in pages:
+ for item in page.results:
+ key = (item.identity_namespace, item.external_id)
+ if key not in grouped:
+ grouped[key] = item.model_copy(deep=True)
+ elif (
+ item.source != grouped[key].source and item.source not in grouped[key].also_from
+ ):
+ grouped[key].also_from.append(item.source)
+ return SeriesDiscoveryRead(
+ results=list(grouped.values()), sources=[outcome for _, outcome in pages]
+ )
+
+ async def discover(
+ self,
+ query: SeriesDiscoveryQuery,
+ *,
+ satisfied_by: Callable[[SourcePage], bool] | None = None,
+ ) -> SeriesDiscoveryRead:
+ """Cascade only stops when the caller proves its requirements are met."""
+ sources = self._ordered_sources(query)
+ semaphore = asyncio.Semaphore(self.concurrency)
+ deadline = asyncio.get_running_loop().time() + self.total_timeout
+ pages = []
+ if query.mode == "automatic":
+ satisfied = False
+ for source in sources:
+ if satisfied:
+ status = self._unavailable(source, search=True) or SourceStatus.NOT_QUERIED
+ pages.append((SourcePage([]), SourceOutcome(source=source, status=status)))
+ else:
+ page, outcome = await self._run(source, query, semaphore, deadline)
+ pages.append((page, outcome))
+ satisfied = (
+ bool(page.results) and satisfied_by is not None and satisfied_by(page)
+ )
+ else:
+ tasks = [
+ asyncio.create_task(self._run(source, query, semaphore, deadline))
+ for source in sources
+ ]
+ try:
+ pages = list(await asyncio.gather(*tasks))
+ finally:
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ return self._group_pages(pages)
+
+ async def check(
+ self, source: MetadataSource, *, retry_authentication: bool = False
+ ) -> SourceOutcome:
+ _, outcome = await self._run(
+ source,
+ None,
+ asyncio.Semaphore(1),
+ asyncio.get_running_loop().time() + self.total_timeout,
+ retry_authentication=retry_authentication,
+ )
+ return outcome
+
+ async def discover_all(
+ self, query: SeriesDiscoveryQuery, *, max_results_per_source: int = 1000
+ ) -> SeriesDiscoveryRead:
+ """Collect a bounded candidate set for interactive sorting and pagination."""
+ if query.mode != "interactive" or query.offsets:
+ raise ValueError("Full candidate collection starts at the first interactive page")
+ if (
+ type(max_results_per_source) is not int
+ or not query.limit_per_source <= max_results_per_source <= 1000
+ or max_results_per_source % query.limit_per_source
+ ):
+ raise ValueError("Candidate limits must be complete source pages, at most 1000 rows")
+ semaphore = asyncio.Semaphore(self.concurrency)
+ deadline = asyncio.get_running_loop().time() + self.total_timeout
+
+ async def collect(source: MetadataSource) -> tuple[SourcePage, SourceOutcome]:
+ handle = _SourceHandle()
+ try:
+ return await self._collect_source(
+ source, query, semaphore, deadline, max_results_per_source, handle
+ )
+ finally:
+ await self._close_source(source, handle)
+
+ tasks = [asyncio.create_task(collect(source)) for source in self._ordered_sources(query)]
+ try:
+ pages = await asyncio.gather(*tasks)
+ finally:
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ # Group only after collecting each source: a preferred source's later
+ # page must still win over the same identity on another source's first.
+ return self._group_pages(pages)
+
+ async def _collect_source(
+ self,
+ source: MetadataSource,
+ query: SeriesDiscoveryQuery,
+ semaphore: asyncio.Semaphore,
+ deadline: float,
+ max_results: int,
+ handle: _SourceHandle,
+ ) -> tuple[SourcePage, SourceOutcome]:
+ rows: list[ProviderSeriesRead] = []
+ offset = rejected = 0
+ total = None
+ while True:
+ request = query.model_copy(
+ update={"sources": [source], "offsets": {source: offset}, "search_mode": "full"}
+ )
+ page, outcome = await self._run(source, request, semaphore, deadline, handle)
+ rows.extend(page.results)
+ total = page.total if page.total is not None else total
+ rejected += page.rejected_results
+ outcome.total, outcome.rejected_results = total, rejected
+ readable_page = outcome.status in {SourceStatus.OK, SourceStatus.EMPTY} or (
+ outcome.status is SourceStatus.INCOMPATIBLE_RESPONSE and page.rejected_results > 0
+ )
+ if not readable_page:
+ if offset:
+ outcome.next_offset, outcome.truncated = offset, True
+ break
+ if rejected:
+ outcome.status = SourceStatus.INCOMPATIBLE_RESPONSE
+ elif rows:
+ outcome.status = SourceStatus.OK
+ cursor = page.next_offset
+ if cursor is None:
+ break
+ if type(cursor) is not int or cursor != offset + query.limit_per_source:
+ outcome.status = SourceStatus.INCOMPATIBLE_RESPONSE
+ outcome.next_offset, outcome.truncated = None, True
+ break
+ if cursor >= max_results or page.truncated:
+ outcome.truncated = True
+ break
+ offset = cursor
+ return SourcePage(rows), outcome
+
+ async def series(
+ self, source: MetadataSource, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderSeriesRead]:
+ from pullbox.services.metadata_source_reads import read_series
+
+ return await read_series(self, source, external_id, validator=validator)
+
+ async def issue(
+ self, source: MetadataSource, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderIssueRead]:
+ from pullbox.services.metadata_source_reads import read_issue
+
+ return await read_issue(self, source, external_id, validator=validator)
+
+ async def issues(
+ self,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ page: int = 1,
+ validator: str | None = None,
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ from pullbox.services.metadata_source_reads import read_issues
+
+ return await read_issues(self, source, external_id, page=page, validator=validator)
+
+ async def recent_issues(
+ self, source: MetadataSource, external_id: str, *, since: datetime
+ ) -> MetadataFetch[RecentIssueWindow]:
+ from pullbox.services.metadata_source_reads import read_recent_issues
+
+ return await read_recent_issues(self, source, external_id, since=since)
+
+ async def story_arc(
+ self, source: MetadataSource, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderStoryArcRead]:
+ from pullbox.services.metadata_source_reads import read_story_arc
+
+ return await read_story_arc(self, source, external_id, validator=validator)
+
+ async def story_arc_issues(
+ self,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ page: int = 1,
+ validator: str | None = None,
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ from pullbox.services.metadata_source_reads import read_story_arc_issues
+
+ return await read_story_arc_issues(
+ self, source, external_id, page=page, validator=validator
+ )
+
+ async def discover_arcs(
+ self,
+ query: StoryArcDiscoveryQuery,
+ *,
+ satisfied_by: Callable[[MetadataPage[ProviderStoryArcRead]], bool] | None = None,
+ ) -> StoryArcDiscoveryRead:
+ from pullbox.services.metadata_source_reads import read_story_arcs
+
+ sources = self._ordered_sources(query, domain=MetadataDomain.STORY_ARCS)
+ deadline = asyncio.get_running_loop().time() + self.total_timeout
+
+ async def read(source: MetadataSource) -> MetadataFetch[MetadataPage[ProviderStoryArcRead]]:
+ return await read_story_arcs(
+ self, source, query.query, page=query.pages.get(source, 1), deadline=deadline
+ )
+
+ pages = []
+ if query.mode == "automatic":
+ satisfied = False
+ for source in sources:
+ if satisfied:
+ result: MetadataFetch[MetadataPage[ProviderStoryArcRead]] = MetadataFetch(
+ status=self._unavailable(
+ source, capability=SourceCapability.STORY_ARC_SEARCH
+ )
+ or SourceStatus.NOT_QUERIED
+ )
+ else:
+ result = await read(source)
+ satisfied = bool(
+ result.data
+ and result.data.results
+ and satisfied_by
+ and satisfied_by(result.data)
+ )
+ pages.append(result)
+ else:
+ tasks = [asyncio.create_task(read(source)) for source in sources]
+ try:
+ pages = list(await asyncio.gather(*tasks))
+ finally:
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ grouped = {}
+ outcomes = []
+ for source, result in zip(sources, pages, strict=True):
+ page = result.data
+ outcomes.append(
+ StoryArcSourceOutcome(
+ source=source,
+ status=SourceStatus.EMPTY
+ if page is not None and not page.results
+ else result.status,
+ total=page.total if page is not None else None,
+ next_page=page.next_page if page is not None else None,
+ truncated=page.truncated if page is not None else False,
+ retry_after_seconds=result.retry_after_seconds,
+ )
+ )
+ if page is not None:
+ for row in page.results:
+ key = (row.identity_namespace, row.external_id)
+ if key not in grouped:
+ grouped[key] = row.model_copy(deep=True)
+ elif (
+ row.source != grouped[key].source
+ and row.source not in grouped[key].also_from
+ ):
+ grouped[key].also_from.append(row.source)
+ return StoryArcDiscoveryRead(results=list(grouped.values()), sources=outcomes)
diff --git a/src/pullbox/services/metadata_entity_values.py b/src/pullbox/services/metadata_entity_values.py
new file mode 100644
index 00000000..5119172a
--- /dev/null
+++ b/src/pullbox/services/metadata_entity_values.py
@@ -0,0 +1,36 @@
+"""Shared descriptive projection, separate from identity and catalog lifecycle."""
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.models import Issue, Series
+from pullbox.models.series import SeriesStatus, SeriesType
+from pullbox.schemas.metadata_snapshot import MetadataValues
+from pullbox.services.metadata_service import MetadataService
+
+
+async def apply_series_metadata_values(
+ session: AsyncSession, series: Series, values: MetadataValues
+) -> None:
+ series.title = values.title or series.title
+ series.sort_title = values.sort_title or series.sort_title
+ series.description, series.year_start, series.year_end = (
+ values.description,
+ values.year_start,
+ values.year_end,
+ )
+ series.cover_url = values.image_url
+ series.publisher_id = (
+ await MetadataService._ensure_publisher(session, values.publisher)
+ if values.publisher
+ else None
+ )
+ if values.series_type is not None:
+ series.series_type = SeriesType(values.series_type)
+ if values.status is not None:
+ series.status = SeriesStatus(values.status)
+
+
+def apply_issue_metadata_values(issue: Issue, values: MetadataValues) -> None:
+ issue.title, issue.description = values.title, values.description
+ issue.release_date, issue.store_date = values.cover_date, values.store_date
+ issue.page_count, issue.cover_url = values.page_count, values.image_url
diff --git a/src/pullbox/services/metadata_identity_attachment.py b/src/pullbox/services/metadata_identity_attachment.py
new file mode 100644
index 00000000..132125b1
--- /dev/null
+++ b/src/pullbox/services/metadata_identity_attachment.py
@@ -0,0 +1,539 @@
+"""Transactional verified-identity attachment for validated internal adapters."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from itertools import groupby
+from typing import TYPE_CHECKING
+
+from sqlalchemy import false, func, insert, or_, select, true, tuple_, update
+from sqlalchemy.exc import IntegrityError
+
+from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind
+from pullbox.core.metadata_identity_events import (
+ IdentityEventActor,
+ IdentityEventReplayConflictError,
+ prepare_identity_event,
+ validate_identity_event_replay,
+)
+from pullbox.core.metadata_identity_state import (
+ IdentityReviewRequiredError,
+ IdentityVerificationAction,
+ IdentityVerificationState,
+)
+from pullbox.models import Base, Issue, Series, StoryArc, StoryArcExternalIdentity
+from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from sqlalchemy import RowMapping
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ from pullbox.core.metadata_identity_events import IdentityEventRequest
+
+_BATCH_SIZE = 200
+_TARGET_MODELS: dict[MetadataEntityKind, type[Series] | type[Issue] | type[StoryArc]] = {
+ MetadataEntityKind.SERIES: Series,
+ MetadataEntityKind.ISSUE: Issue,
+ MetadataEntityKind.STORY_ARC: StoryArc,
+}
+_OWNER_MODELS: dict[
+ MetadataEntityKind,
+ type[SeriesExternalIdentity] | type[IssueExternalIdentity] | type[StoryArcExternalIdentity],
+] = {
+ MetadataEntityKind.SERIES: SeriesExternalIdentity,
+ MetadataEntityKind.ISSUE: IssueExternalIdentity,
+ MetadataEntityKind.STORY_ARC: StoryArcExternalIdentity,
+}
+_LOCK_ORDER = {
+ MetadataEntityKind.STORY_ARC: 0,
+ MetadataEntityKind.SERIES: 1,
+ MetadataEntityKind.ISSUE: 2,
+}
+
+
+class IdentityAttachmentConflictError(ValueError):
+ """Identity ownership or parent evidence disagrees with the local target."""
+
+
+@dataclass(frozen=True)
+class IdentityAttachmentReceipt:
+ event_id: int
+ replayed: bool
+
+
+async def attach_verified_identities(
+ session: AsyncSession,
+ requests: Sequence[IdentityEventRequest],
+ *,
+ require_current_ownership: bool = False,
+) -> list[IdentityAttachmentReceipt]:
+ """Attach adapter-validated exact evidence in the caller's transaction.
+
+ This is not a transport API or an authorization boundary. Adapters must
+ establish evidence trust before calling it. Explicit user decisions use a
+ separate review path; this path must never override their retained history.
+ Consumers that depend on present ownership must opt in to replay validation;
+ an ordinary receipt proves only that the historical operation succeeded.
+ """
+ if not requests:
+ return []
+ unique: dict[str, IdentityEventRequest] = {}
+ for request in requests:
+ identity = request.evidence.claim.identity
+ if (
+ request.action is not IdentityVerificationAction.VERIFY
+ or request.actor is not IdentityEventActor.AUTOMATION
+ or identity.entity_kind not in _TARGET_MODELS
+ ):
+ raise ValueError("This attachment path accepts validated automatic verification only")
+ if (
+ identity.entity_kind is MetadataEntityKind.ISSUE
+ and request.evidence.parent_identity is None
+ ):
+ raise IdentityAttachmentConflictError(
+ "Verified issue attachment requires exact parent evidence"
+ )
+ if identity.namespace is IdentityNamespace.COMICVINE and int(identity.external_id) >= 2**63:
+ raise IdentityAttachmentConflictError(
+ "ComicVine identity exceeds the compatibility ID range"
+ )
+ prepared = prepare_identity_event(request)
+ previous = unique.setdefault(prepared.event_key, request)
+ if prepare_identity_event(previous).request_json != prepared.request_json:
+ raise IdentityEventReplayConflictError(
+ "One batch reuses an identity operation inconsistently"
+ )
+
+ # SQLite's legacy deferred BEGIN must enclose SAVEPOINT, even for existing
+ # parents with no pending ORM writes. This also serializes SQLite writers.
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(
+ update(Series).where(false()).values(comicvine_id=Series.comicvine_id)
+ )
+ receipts: dict[str, IdentityAttachmentReceipt] = {}
+ ordered = sorted(unique.values(), key=_sort_key)
+ try:
+ async with session.begin_nested():
+ await _lock_parent_graph(session, ordered)
+ for kind, members in groupby(
+ ordered, key=lambda item: item.evidence.claim.identity.entity_kind
+ ):
+ group = list(members)
+ for offset in range(0, len(group), _BATCH_SIZE):
+ receipts.update(
+ await _attach_batch(
+ session,
+ kind,
+ group[offset : offset + _BATCH_SIZE],
+ require_current_ownership=require_current_ownership,
+ )
+ )
+ except IntegrityError as exc:
+ raise IdentityAttachmentConflictError(
+ "Identity ownership changed; reload before retrying"
+ ) from exc
+ return [receipts[prepare_identity_event(request).event_key] for request in requests]
+
+
+def _sort_key(request: IdentityEventRequest) -> tuple[int, int, str, str]:
+ identity = request.evidence.claim.identity
+ return (
+ _LOCK_ORDER[identity.entity_kind],
+ request.local_id,
+ identity.namespace.value,
+ identity.external_id,
+ )
+
+
+async def _lock_parent_graph(session: AsyncSession, requests: list[IdentityEventRequest]) -> None:
+ """Acquire every parent before any child, across the whole batch sequence."""
+ # Catalog refresh owns its arc before touching member series/issues.
+ arc_ids = sorted(
+ {
+ request.local_id
+ for request in requests
+ if request.evidence.claim.identity.entity_kind is MetadataEntityKind.STORY_ARC
+ }
+ )
+ for offset in range(0, len(arc_ids), _BATCH_SIZE):
+ await session.execute(
+ select(StoryArc.id)
+ .where(StoryArc.id.in_(arc_ids[offset : offset + _BATCH_SIZE]))
+ .order_by(StoryArc.id)
+ .with_for_update()
+ )
+ series_ids = {
+ request.local_id
+ for request in requests
+ if request.evidence.claim.identity.entity_kind is MetadataEntityKind.SERIES
+ }
+ issue_ids = sorted(
+ {
+ request.local_id
+ for request in requests
+ if request.evidence.claim.identity.entity_kind is MetadataEntityKind.ISSUE
+ }
+ )
+ expected_parents: dict[int, int] = {}
+ for offset in range(0, len(issue_ids), _BATCH_SIZE):
+ expected_parents.update(
+ (
+ await session.execute(
+ select(Issue.id, Issue.series_id).where(
+ Issue.id.in_(issue_ids[offset : offset + _BATCH_SIZE])
+ )
+ )
+ )
+ .tuples()
+ .all()
+ )
+ series_ids.update(expected_parents.values())
+ ordered_parents = sorted(series_ids)
+ for offset in range(0, len(ordered_parents), _BATCH_SIZE):
+ await session.execute(
+ select(Series.id)
+ .where(Series.id.in_(ordered_parents[offset : offset + _BATCH_SIZE]))
+ .order_by(Series.id)
+ .with_for_update()
+ )
+ for offset in range(0, len(issue_ids), _BATCH_SIZE):
+ rows = await session.execute(
+ select(Issue.id, Issue.series_id)
+ .where(Issue.id.in_(issue_ids[offset : offset + _BATCH_SIZE]))
+ .order_by(Issue.id)
+ .with_for_update()
+ )
+ if any(expected_parents.get(local_id) != parent_id for local_id, parent_id in rows):
+ raise IdentityAttachmentConflictError("Issue parent changed during verification")
+
+
+async def _locked_targets(
+ session: AsyncSession, kind: MetadataEntityKind, target_ids: list[int]
+) -> dict[int, RowMapping]:
+ model = _TARGET_MODELS[kind]
+ table = Base.metadata.tables[model.__tablename__]
+ columns = [table.c.id, table.c.comicvine_id]
+ if kind is MetadataEntityKind.ISSUE:
+ columns.append(table.c.series_id)
+ rows = (
+ (
+ await session.execute(
+ select(*columns).where(table.c.id.in_(target_ids)).order_by(table.c.id)
+ )
+ )
+ .mappings()
+ .all()
+ )
+ targets = {int(row.id): row for row in rows}
+ if len(targets) != len(set(target_ids)):
+ raise IdentityAttachmentConflictError("Identity target no longer exists")
+ return targets
+
+
+async def _attach_batch(
+ session: AsyncSession,
+ kind: MetadataEntityKind,
+ requests: list[IdentityEventRequest],
+ *,
+ require_current_ownership: bool,
+) -> dict[str, IdentityAttachmentReceipt]:
+ target_key = f"{kind.value}_id"
+ active = Base.metadata.tables[f"{kind.value}_external_identities"]
+ history = Base.metadata.tables[f"{kind.value}_identity_events"]
+ is_arc = kind is MetadataEntityKind.STORY_ARC
+ namespace_column = active.c.source if is_arc else active.c.identity_namespace
+ active_scope = (
+ (active.c.namespace == "story_arc") & active.c.source.in_(list(IdentityNamespace))
+ if is_arc
+ else true()
+ )
+ active_select = (
+ select(active, namespace_column.label("identity_namespace")) if is_arc else select(active)
+ )
+ targets = await _locked_targets(
+ session, kind, sorted({request.local_id for request in requests})
+ )
+ prepared = {
+ prepare_identity_event(request).event_key: prepare_identity_event(request)
+ for request in requests
+ }
+ stored = (
+ (
+ await session.execute(
+ select(history).where(
+ tuple_(history.c[target_key], history.c.event_key).in_(
+ [
+ (request.local_id, prepare_identity_event(request).event_key)
+ for request in requests
+ ]
+ )
+ )
+ )
+ )
+ .mappings()
+ .all()
+ )
+ receipts: dict[str, IdentityAttachmentReceipt] = {}
+ for row in stored:
+ payload = prepared[row.event_key]
+ validate_identity_event_replay(
+ payload, event_key=row.event_key, request_fingerprint=row.request_fingerprint
+ )
+ if (
+ row.request_json != payload.request_json
+ or row.verification_state is not IdentityVerificationState.VERIFIED
+ ):
+ raise IdentityEventReplayConflictError("Stored identity request is inconsistent")
+ receipts[row.event_key] = IdentityAttachmentReceipt(row.id, True)
+ fresh = [
+ request for request in requests if prepare_identity_event(request).event_key not in receipts
+ ]
+ checked = requests if require_current_ownership else fresh
+ if not checked:
+ return receipts
+
+ active_rows = (
+ (
+ await session.execute(
+ active_select.where(
+ active_scope,
+ or_(
+ active.c[target_key].in_([request.local_id for request in checked]),
+ tuple_(namespace_column, active.c.external_id).in_(
+ [
+ (
+ request.evidence.claim.identity.namespace,
+ request.evidence.claim.identity.external_id,
+ )
+ for request in checked
+ ]
+ ),
+ ),
+ )
+ )
+ )
+ .mappings()
+ .all()
+ )
+ by_target = {(row[target_key], row.identity_namespace): row for row in active_rows}
+ by_external = {(row.identity_namespace, row.external_id): row for row in active_rows}
+ claim_columns = (history.c[target_key], history.c.identity_namespace, history.c.external_id)
+ latest_ids = (
+ select(func.max(history.c.id))
+ .where(
+ tuple_(*claim_columns).in_(
+ [
+ (
+ request.local_id,
+ request.evidence.claim.identity.namespace,
+ request.evidence.claim.identity.external_id,
+ )
+ for request in checked
+ ]
+ )
+ )
+ .group_by(*claim_columns)
+ )
+ decisions = {
+ (row[target_key], row.identity_namespace, row.external_id): row.verification_state
+ for row in (
+ await session.execute(select(history).where(history.c.id.in_(latest_ids)))
+ ).mappings()
+ }
+ await _validate_legacy_owners(session, kind, checked, targets)
+ if kind is MetadataEntityKind.ISSUE:
+ await _validate_issue_parents(session, checked, targets)
+
+ if require_current_ownership:
+ for request in requests:
+ if prepare_identity_event(request).event_key not in receipts:
+ continue
+ identity = request.evidence.claim.identity
+ target = (request.local_id, identity.namespace)
+ existing = by_target.get(target)
+ if (
+ existing is None
+ or existing.external_id != identity.external_id
+ or existing.verification_state is not IdentityVerificationState.VERIFIED
+ or decisions.get((*target, identity.external_id))
+ is not IdentityVerificationState.VERIFIED
+ or (
+ identity.namespace is IdentityNamespace.COMICVINE
+ and targets[request.local_id].comicvine_id != int(identity.external_id)
+ )
+ ):
+ raise IdentityReviewRequiredError(
+ "Historical identity receipt no longer proves current ownership"
+ )
+ if not fresh:
+ return receipts
+
+ now = datetime.now(UTC)
+ creates: dict[tuple[int, IdentityNamespace], dict[str, object]] = {}
+ changes: dict[tuple[int, IdentityNamespace], dict[str, object]] = {}
+ revisions: dict[tuple[int, IdentityNamespace], int] = {}
+ events: list[dict[str, object]] = []
+ legacy_changes: dict[int, dict[str, int]] = {}
+ seen_targets: dict[tuple[int, IdentityNamespace], str] = {}
+ seen_owners: dict[tuple[IdentityNamespace, str], int] = {}
+ for request in fresh:
+ identity = request.evidence.claim.identity
+ namespace, external_id = identity.namespace, identity.external_id
+ target = (request.local_id, namespace)
+ external = (namespace, external_id)
+ if (
+ seen_targets.setdefault(target, external_id) != external_id
+ or seen_owners.setdefault(external, request.local_id) != request.local_id
+ ):
+ raise IdentityAttachmentConflictError("Batch contains conflicting identity ownership")
+ existing, owner = by_target.get(target), by_external.get(external)
+ if (existing is not None and existing.external_id != external_id) or (
+ owner is not None and owner[target_key] != request.local_id
+ ):
+ raise IdentityAttachmentConflictError("Provider identity already has a different owner")
+ if (
+ existing is not None
+ and existing.verification_state is IdentityVerificationState.CONFLICTED
+ ) or decisions.get((*target, external_id)) in {
+ IdentityVerificationState.CONFLICTED,
+ IdentityVerificationState.REJECTED,
+ }:
+ raise IdentityReviewRequiredError("This identity requires explicit review")
+ payload = prepare_identity_event(request)
+ values: dict[str, object] = {
+ "verification_state": IdentityVerificationState.VERIFIED,
+ "evidence_kind": request.evidence.claim.evidence_kind,
+ "evidence_locator": payload.request_json,
+ "verified_at": now,
+ "last_seen_at": now,
+ }
+ revisions[target] = (
+ revisions.get(target, existing.revision if existing is not None else 0) + 1
+ )
+ values["revision"] = revisions[target]
+ if existing is None:
+ creates[target] = {
+ **values,
+ target_key: request.local_id,
+ **(
+ {"source": namespace.value, "namespace": "story_arc"}
+ if is_arc
+ else {"identity_namespace": namespace}
+ ),
+ "external_id": external_id,
+ }
+ else:
+ changes[target] = {**values, "id": existing.id}
+ if (
+ namespace is IdentityNamespace.COMICVINE
+ and targets[request.local_id].comicvine_id is None
+ ):
+ legacy_changes[request.local_id] = {
+ "id": request.local_id,
+ "comicvine_id": int(external_id),
+ }
+ events.append(
+ {
+ target_key: request.local_id,
+ "identity_namespace": namespace,
+ "external_id": external_id,
+ "verification_state": IdentityVerificationState.VERIFIED,
+ "evidence_kind": request.evidence.claim.evidence_kind,
+ "event_key": payload.event_key,
+ "request_fingerprint": payload.request_fingerprint,
+ "request_json": payload.request_json,
+ }
+ )
+ if creates:
+ await session.execute(insert(active), list(creates.values()))
+ if changes:
+ await session.execute(
+ update(_OWNER_MODELS[kind]),
+ list(changes.values()),
+ )
+ if legacy_changes:
+ await session.execute(
+ update(_TARGET_MODELS[kind]),
+ list(legacy_changes.values()),
+ )
+ rows = await session.execute(
+ insert(history).returning(history.c.id, history.c.event_key),
+ events,
+ execution_options={"insertmanyvalues_page_size": 50},
+ )
+ for event_id, event_key in rows:
+ receipts[event_key] = IdentityAttachmentReceipt(event_id, False)
+ return receipts
+
+
+async def _validate_legacy_owners(
+ session: AsyncSession,
+ kind: MetadataEntityKind,
+ requests: list[IdentityEventRequest],
+ targets: dict[int, RowMapping],
+) -> None:
+ model = _TARGET_MODELS[kind]
+ legacy_claims = [
+ (request.local_id, int(request.evidence.claim.identity.external_id))
+ for request in requests
+ if request.evidence.claim.identity.namespace is IdentityNamespace.COMICVINE
+ ]
+ if not legacy_claims:
+ return
+ owners = {
+ external_id: local_id
+ for local_id, external_id in (
+ await session.execute(
+ select(model.id, model.comicvine_id).where(
+ model.comicvine_id.in_([value for _, value in legacy_claims])
+ )
+ )
+ ).all()
+ }
+ for local_id, external_id in legacy_claims:
+ if (
+ targets[local_id].comicvine_id not in (None, external_id)
+ or owners.get(external_id, local_id) != local_id
+ ):
+ raise IdentityAttachmentConflictError(
+ "ComicVine compatibility identity disagrees with attachment"
+ )
+
+
+async def _validate_issue_parents(
+ session: AsyncSession, requests: list[IdentityEventRequest], targets: dict[int, RowMapping]
+) -> None:
+ table = Base.metadata.tables["series_external_identities"]
+ rows = (
+ (
+ await session.execute(
+ select(table, Series.comicvine_id)
+ .join(Series, Series.id == table.c.series_id)
+ .where(table.c.series_id.in_({row.series_id for row in targets.values()}))
+ )
+ )
+ .mappings()
+ .all()
+ )
+ parents = {(row.series_id, row.identity_namespace): row for row in rows}
+ for request in requests:
+ proof = request.evidence.parent_identity
+ assert proof is not None
+ parent = parents.get((targets[request.local_id].series_id, proof.namespace))
+ if (
+ parent is None
+ or parent.external_id != proof.external_id
+ or parent.verification_state is not IdentityVerificationState.VERIFIED
+ ):
+ raise IdentityAttachmentConflictError(
+ "Issue evidence disagrees with its verified local series"
+ )
+ if proof.namespace is IdentityNamespace.COMICVINE and parent.comicvine_id != int(
+ proof.external_id
+ ):
+ raise IdentityAttachmentConflictError(
+ "Issue parent has inconsistent ComicVine ownership"
+ )
diff --git a/src/pullbox/services/metadata_identity_audit.py b/src/pullbox/services/metadata_identity_audit.py
new file mode 100644
index 00000000..3f663794
--- /dev/null
+++ b/src/pullbox/services/metadata_identity_audit.py
@@ -0,0 +1,180 @@
+"""Bounded, read-only ownership preflight for legacy identity migration rehearsals."""
+
+from __future__ import annotations
+
+import enum
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from pullbox.core.metadata_identity import MetadataEntityKind
+from pullbox.services.metadata_identity_inventory import (
+ MAX_INVENTORY_PAGE_SIZE,
+ IdentityInventoryLimitError,
+ LegacyIdentityDisagreement,
+ LegacyIdentityObservation,
+ LegacyIdentityProblem,
+ LegacyIdentityStorage,
+ read_legacy_identity_page,
+)
+
+if TYPE_CHECKING:
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ from pullbox.core.metadata_identity import ExternalIdentityRef
+
+MAX_AUDIT_TARGETS = 1_000_000
+MAX_AUDIT_EVIDENCE_ROWS = 2_000_000
+
+
+class IdentityAuditStopReason(enum.StrEnum):
+ TARGET_LIMIT = "target_limit"
+ EVIDENCE_LIMIT = "evidence_limit"
+ PAGE_EVIDENCE_LIMIT = "page_evidence_limit"
+
+
+@dataclass(frozen=True)
+class LegacyIdentityOwnershipCollision:
+ identity: ExternalIdentityRef
+ claims: tuple[LegacyIdentityObservation, ...]
+
+
+@dataclass(frozen=True)
+class LegacyIdentityDuplicateRelations:
+ identity: ExternalIdentityRef
+ local_id: int
+ storage_row_ids: tuple[int, ...]
+
+
+@dataclass(frozen=True)
+class LegacyIdentityAudit:
+ """Checked-prefix evidence; completion is scoped to this one entity kind."""
+
+ entity_kind: MetadataEntityKind
+ targets_checked: int
+ observations_checked: int
+ collisions: tuple[LegacyIdentityOwnershipCollision, ...] = ()
+ duplicate_relations: tuple[LegacyIdentityDuplicateRelations, ...] = ()
+ problems: tuple[LegacyIdentityProblem, ...] = ()
+ disagreements: tuple[LegacyIdentityDisagreement, ...] = ()
+ stop_reason: IdentityAuditStopReason | None = None
+
+ @property
+ def complete(self) -> bool:
+ return self.stop_reason is None
+
+ @property
+ def evidence_rows_checked(self) -> int:
+ return self.observations_checked + len(self.problems)
+
+ @property
+ def has_blockers(self) -> bool:
+ return bool(
+ not self.complete
+ or self.collisions
+ or self.duplicate_relations
+ or self.problems
+ or self.disagreements
+ )
+
+
+async def audit_legacy_identities(
+ session: AsyncSession,
+ entity_kind: MetadataEntityKind,
+ *,
+ page_size: int = 100,
+ max_targets: int = 250_000,
+ max_evidence_rows: int = 500_000,
+) -> LegacyIdentityAudit:
+ """Audit one entity kind on a quiescent copy, not a live migration approval.
+
+ No repair, attachment, provider call, commit, or snapshot isolation is implied.
+ Scan every kind separately on the same stable copy before planning backfill.
+ Findings are deterministic across page sizes when the traversal completes.
+ An over-budget page is excluded entirely; retained counts/findings describe
+ only fully checked pages. Incomplete reports must never authorize a backfill.
+ """
+ if not isinstance(entity_kind, MetadataEntityKind):
+ raise ValueError("Unknown metadata entity kind")
+ for value, maximum in (
+ (page_size, MAX_INVENTORY_PAGE_SIZE),
+ (max_targets, MAX_AUDIT_TARGETS),
+ (max_evidence_rows, MAX_AUDIT_EVIDENCE_ROWS),
+ ):
+ if type(value) is not int or not 1 <= value <= maximum:
+ raise ValueError("Invalid identity audit bounds")
+
+ claims: dict[ExternalIdentityRef, list[LegacyIdentityObservation]] = defaultdict(list)
+ problems: list[LegacyIdentityProblem] = []
+ disagreements: list[LegacyIdentityDisagreement] = []
+ targets_checked = observations_checked = after_id = 0
+ stop_reason = None
+ while True:
+ try:
+ page = await read_legacy_identity_page(
+ session,
+ entity_kind,
+ after_id=after_id,
+ limit=min(page_size, max_targets - targets_checked),
+ )
+ except IdentityInventoryLimitError:
+ stop_reason = IdentityAuditStopReason.PAGE_EVIDENCE_LIMIT
+ break
+ evidence_rows = (
+ observations_checked + len(problems) + len(page.observations) + len(page.problems)
+ )
+ if evidence_rows > max_evidence_rows:
+ stop_reason = IdentityAuditStopReason.EVIDENCE_LIMIT
+ break
+ targets_checked += len(page.local_ids)
+ observations_checked += len(page.observations)
+ problems.extend(page.problems)
+ disagreements.extend(page.disagreements)
+ for observation in page.observations:
+ claims[observation.identity].append(observation)
+ if not page.has_more:
+ break
+ if targets_checked == max_targets:
+ stop_reason = IdentityAuditStopReason.TARGET_LIMIT
+ break
+ if page.next_after_id is None or page.next_after_id <= after_id:
+ raise ValueError("Identity inventory cursor did not advance")
+ after_id = page.next_after_id
+
+ collisions, duplicates = _ownership_findings(claims)
+ return LegacyIdentityAudit(
+ entity_kind=entity_kind,
+ targets_checked=targets_checked,
+ observations_checked=observations_checked,
+ collisions=collisions,
+ duplicate_relations=duplicates,
+ problems=tuple(sorted(problems, key=lambda p: (p.local_id, p.storage, p.storage_row_id))),
+ disagreements=tuple(disagreements),
+ stop_reason=stop_reason,
+ )
+
+
+def _ownership_findings(
+ claims: dict[ExternalIdentityRef, list[LegacyIdentityObservation]],
+) -> tuple[
+ tuple[LegacyIdentityOwnershipCollision, ...], tuple[LegacyIdentityDuplicateRelations, ...]
+]:
+ collisions = []
+ duplicates = []
+ for identity in sorted(claims, key=lambda key: (key.namespace, key.external_id)):
+ observations = sorted(
+ claims[identity],
+ key=lambda claim: (claim.local_id, claim.storage, claim.storage_row_id),
+ )
+ if len({claim.local_id for claim in observations}) > 1:
+ collisions.append(LegacyIdentityOwnershipCollision(identity, tuple(observations)))
+ relations: dict[int, list[int]] = defaultdict(list)
+ for claim in observations:
+ if claim.storage == LegacyIdentityStorage.STORY_ARC_RELATION:
+ relations[claim.local_id].append(claim.storage_row_id)
+ for local_id, row_ids in relations.items():
+ if len(row_ids) > 1:
+ duplicates.append(
+ LegacyIdentityDuplicateRelations(identity, local_id, tuple(row_ids))
+ )
+ return tuple(collisions), tuple(duplicates)
diff --git a/src/pullbox/services/metadata_identity_inventory.py b/src/pullbox/services/metadata_identity_inventory.py
new file mode 100644
index 00000000..d9980291
--- /dev/null
+++ b/src/pullbox/services/metadata_identity_inventory.py
@@ -0,0 +1,191 @@
+"""Read-only inventory for rehearsing the provider identity migration."""
+
+from __future__ import annotations
+
+import enum
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from sqlalchemy import select
+
+from pullbox.core.comicvine_arc_identity import normalize_comicvine_arc_id
+from pullbox.core.metadata_identity import (
+ ExactIdentityConflict,
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+ find_exact_identity_conflicts,
+)
+from pullbox.models.issue import Issue
+from pullbox.models.series import Series
+from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity
+
+if TYPE_CHECKING:
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+MAX_INVENTORY_PAGE_SIZE = 500
+MAX_ARC_IDENTITIES_PER_PAGE = 2000
+
+
+class LegacyIdentityStorage(enum.StrEnum):
+ COMICVINE_COLUMN = "comicvine_column"
+ STORY_ARC_RELATION = "story_arc_relation"
+
+
+@dataclass(frozen=True)
+class LegacyIdentityObservation:
+ local_id: int
+ identity: ExternalIdentityRef
+ storage: LegacyIdentityStorage
+ storage_row_id: int
+
+
+@dataclass(frozen=True)
+class LegacyIdentityProblem:
+ local_id: int
+ storage: LegacyIdentityStorage
+ storage_row_id: int
+ code: str
+
+
+@dataclass(frozen=True)
+class LegacyIdentityDisagreement:
+ local_id: int
+ conflict: ExactIdentityConflict
+
+
+@dataclass(frozen=True)
+class LegacyIdentityPage:
+ entity_kind: MetadataEntityKind
+ local_ids: tuple[int, ...]
+ observations: tuple[LegacyIdentityObservation, ...]
+ problems: tuple[LegacyIdentityProblem, ...]
+ next_after_id: int | None
+ has_more: bool
+ disagreements: tuple[LegacyIdentityDisagreement, ...] = ()
+
+
+class IdentityInventoryLimitError(ValueError):
+ """The page cannot be reported completely within its identity-row budget."""
+
+
+async def read_legacy_identity_page(
+ session: AsyncSession,
+ entity_kind: MetadataEntityKind,
+ *,
+ after_id: int = 0,
+ limit: int = 100,
+) -> LegacyIdentityPage:
+ """Inventory persisted evidence, never grant trust or repair either store.
+
+ This is a keyset page, not a transactionally frozen whole-library snapshot.
+ Migration rehearsals must use a quiescent copy and check external ownership
+ across all pages separately. Disagreements here are scoped to one target.
+ """
+ if not isinstance(entity_kind, MetadataEntityKind):
+ raise ValueError("Unknown metadata entity kind")
+ if (
+ type(limit) is not int
+ or not 1 <= limit <= MAX_INVENTORY_PAGE_SIZE
+ or type(after_id) is not int
+ or after_id < 0
+ ):
+ raise ValueError("Invalid identity inventory page bounds")
+ models: dict[MetadataEntityKind, type[Series] | type[Issue] | type[StoryArc]] = {
+ MetadataEntityKind.SERIES: Series,
+ MetadataEntityKind.ISSUE: Issue,
+ MetadataEntityKind.STORY_ARC: StoryArc,
+ }
+ model = models[entity_kind]
+ observations: list[LegacyIdentityObservation] = []
+ problems: list[LegacyIdentityProblem] = []
+ # ORM SELECTs can autoflush unrelated caller mutations, even in a read helper.
+ with session.no_autoflush:
+ rows = (
+ await session.execute(
+ select(model.id, model.comicvine_id)
+ .where(model.id > after_id)
+ .order_by(model.id)
+ .limit(limit + 1)
+ )
+ ).all()
+ page_rows = rows[:limit]
+ local_ids = tuple(row.id for row in page_rows)
+ for local_id, value in page_rows:
+ if value is None:
+ continue
+ storage = LegacyIdentityStorage.COMICVINE_COLUMN
+ if type(value) is not int or value <= 0:
+ problems.append(
+ LegacyIdentityProblem(local_id, storage, local_id, "invalid_external_id")
+ )
+ continue
+ observations.append(
+ LegacyIdentityObservation(
+ local_id,
+ ExternalIdentityRef(IdentityNamespace.COMICVINE, entity_kind, str(value)),
+ storage,
+ local_id,
+ )
+ )
+
+ if entity_kind == MetadataEntityKind.STORY_ARC and local_ids:
+ arc_rows = (
+ await session.execute(
+ select(
+ StoryArcExternalIdentity.id,
+ StoryArcExternalIdentity.story_arc_id,
+ StoryArcExternalIdentity.source,
+ StoryArcExternalIdentity.external_id,
+ )
+ .where(
+ StoryArcExternalIdentity.story_arc_id.in_(local_ids),
+ StoryArcExternalIdentity.source.in_(tuple(IdentityNamespace)),
+ StoryArcExternalIdentity.namespace == "story_arc",
+ )
+ .order_by(StoryArcExternalIdentity.story_arc_id, StoryArcExternalIdentity.id)
+ .limit(MAX_ARC_IDENTITIES_PER_PAGE + 1)
+ )
+ ).all()
+ if len(arc_rows) > MAX_ARC_IDENTITIES_PER_PAGE:
+ raise IdentityInventoryLimitError(
+ "Story arc identity row budget exceeded. Smaller pages may help; "
+ "an arc that exceeds the budget alone needs a separate inspection."
+ )
+ for row_id, local_id, source, value in arc_rows:
+ storage = LegacyIdentityStorage.STORY_ARC_RELATION
+ try:
+ if source == IdentityNamespace.COMICVINE:
+ value = normalize_comicvine_arc_id(value)
+ identity = ExternalIdentityRef(IdentityNamespace(source), entity_kind, value)
+ except ValueError:
+ problems.append(
+ LegacyIdentityProblem(local_id, storage, row_id, "invalid_external_id")
+ )
+ else:
+ observations.append(
+ LegacyIdentityObservation(local_id, identity, storage, row_id)
+ )
+
+ grouped: dict[int, list[ExactIdentityEvidence]] = defaultdict(list)
+ for observation in observations:
+ grouped[observation.local_id].append(
+ ExactIdentityEvidence(observation.identity, IdentityEvidenceKind.LEGACY_BACKFILL)
+ )
+ disagreements = tuple(
+ LegacyIdentityDisagreement(local_id, conflict)
+ for local_id, evidence in sorted(grouped.items())
+ for conflict in find_exact_identity_conflicts(evidence)
+ )
+ return LegacyIdentityPage(
+ entity_kind,
+ local_ids,
+ tuple(observations),
+ tuple(problems),
+ local_ids[-1] if local_ids else None,
+ len(rows) > limit,
+ disagreements,
+ )
diff --git a/src/pullbox/services/metadata_identity_review.py b/src/pullbox/services/metadata_identity_review.py
new file mode 100644
index 00000000..f9a75db6
--- /dev/null
+++ b/src/pullbox/services/metadata_identity_review.py
@@ -0,0 +1,624 @@
+"""Saved identity evidence and explicit operator review."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+from uuid import UUID, uuid5
+
+from sqlalchemy import case, delete, false, func, insert, or_, select, true, update
+from sqlalchemy.exc import IntegrityError
+
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_identity_events import (
+ IdentityEventActor,
+ IdentityEventEvidence,
+ IdentityEventReplayConflictError,
+ IdentityEventRequest,
+ prepare_identity_event,
+ validate_identity_event_replay,
+)
+from pullbox.core.metadata_identity_state import (
+ IdentityReviewRequiredError,
+ transition_identity_state,
+)
+from pullbox.core.metadata_identity_state import (
+ IdentityVerificationAction as Action,
+)
+from pullbox.core.metadata_identity_state import (
+ IdentityVerificationState as State,
+)
+from pullbox.models import Base, Issue, Series, User
+from pullbox.services.metadata_identity_attachment import (
+ IdentityAttachmentConflictError,
+ IdentityAttachmentReceipt,
+ _lock_parent_graph,
+ _locked_targets,
+ _validate_issue_parents,
+ _validate_legacy_owners,
+)
+
+if TYPE_CHECKING:
+ from sqlalchemy import RowMapping, Table
+ from sqlalchemy.ext.asyncio import AsyncSession
+ from sqlalchemy.sql.elements import ColumnElement
+
+
+_REVIEW_NAMESPACE = UUID("0d7b2c0d-1515-43b2-a9dd-c9a6d516d60c")
+
+
+class IdentityReviewNotFoundError(ValueError):
+ """The requested saved claim is not part of this local target."""
+
+
+def _tables(kind: MetadataEntityKind) -> tuple[Table, Table, str]:
+ return (
+ Base.metadata.tables[f"{kind.value}_external_identities"],
+ Base.metadata.tables[f"{kind.value}_identity_events"],
+ f"{kind.value}_id",
+ )
+
+
+def _scope(active: Table, kind: MetadataEntityKind) -> ColumnElement[bool]:
+ return active.c.namespace == "story_arc" if kind is MetadataEntityKind.STORY_ARC else true()
+
+
+def _namespace(active: Table, kind: MetadataEntityKind) -> ColumnElement[str]:
+ return active.c.source if kind is MetadataEntityKind.STORY_ARC else active.c.identity_namespace
+
+
+async def _begin_write(session: AsyncSession) -> None:
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(
+ update(Series).where(false()).values(comicvine_id=Series.comicvine_id)
+ )
+
+
+async def _replay(
+ session: AsyncSession, request: IdentityEventRequest
+) -> IdentityAttachmentReceipt | None:
+ _, history, target_key = _tables(request.evidence.claim.identity.entity_kind)
+ payload = prepare_identity_event(request)
+ row = (
+ (
+ await session.execute(
+ select(history).where(
+ history.c[target_key] == request.local_id,
+ history.c.event_key == payload.event_key,
+ )
+ )
+ )
+ .mappings()
+ .one_or_none()
+ )
+ if row is None:
+ return None
+ validate_identity_event_replay(
+ payload, event_key=row.event_key, request_fingerprint=row.request_fingerprint
+ )
+ if row.request_json != payload.request_json:
+ raise IdentityEventReplayConflictError("Stored identity request is inconsistent")
+ return IdentityAttachmentReceipt(row.id, True)
+
+
+async def _append(
+ session: AsyncSession, request: IdentityEventRequest, state: State
+) -> IdentityAttachmentReceipt:
+ identity = request.evidence.claim.identity
+ _, history, target_key = _tables(identity.entity_kind)
+ payload = prepare_identity_event(request)
+ event_id = await session.scalar(
+ insert(history)
+ .values(
+ **{
+ target_key: request.local_id,
+ "identity_namespace": identity.namespace,
+ "external_id": identity.external_id,
+ "verification_state": state,
+ "evidence_kind": request.evidence.claim.evidence_kind,
+ "event_key": payload.event_key,
+ "request_fingerprint": payload.request_fingerprint,
+ "request_json": payload.request_json,
+ }
+ )
+ .returning(history.c.id)
+ )
+ assert event_id is not None
+ return IdentityAttachmentReceipt(event_id, False)
+
+
+async def _owner(
+ session: AsyncSession, kind: MetadataEntityKind, local_id: int, namespace: IdentityNamespace
+) -> RowMapping | None:
+ active, _, key = _tables(kind)
+ return (
+ (
+ await session.execute(
+ select(active).where(
+ active.c[key] == local_id,
+ _scope(active, kind),
+ _namespace(active, kind) == namespace,
+ )
+ )
+ )
+ .mappings()
+ .one_or_none()
+ )
+
+
+async def record_identity_observation(
+ session: AsyncSession, request: IdentityEventRequest
+) -> IdentityAttachmentReceipt:
+ """Record adapter evidence without acquiring ownership or clearing decisions.
+
+ Adapters establish provenance before calling. This is not an HTTP boundary;
+ user confirmation and rejection are accepted only by the review service.
+ """
+ if request.actor is not IdentityEventActor.AUTOMATION or request.action not in {
+ Action.OBSERVE,
+ Action.REPORT_CONFLICT,
+ Action.MARK_STALE,
+ }:
+ raise ValueError("Only automatic non-verifying observations are accepted")
+ identity = request.evidence.claim.identity
+ kind, namespace = identity.entity_kind, identity.namespace
+ active, history, key = _tables(kind)
+ await _begin_write(session)
+ async with session.begin_nested():
+ await _lock_parent_graph(session, [request])
+ await _locked_targets(session, kind, [request.local_id])
+ replay = await _replay(session, request)
+ if replay:
+ return replay
+ owner = await _owner(session, kind, request.local_id, namespace)
+ latest = await session.scalar(
+ select(history.c.verification_state)
+ .where(
+ history.c[key] == request.local_id,
+ history.c.identity_namespace == namespace,
+ history.c.external_id == identity.external_id,
+ )
+ .order_by(history.c.id.desc())
+ .limit(1)
+ )
+ same_owner = owner is not None and owner.external_id == identity.external_id
+ previous = latest or (
+ owner.verification_state if owner is not None and same_owner else State.OBSERVED
+ )
+ # Retained rejection always wins; an active conflict cannot be cleared by
+ # observing an older, previously verified claim again.
+ if previous is not State.REJECTED and same_owner and owner is not None:
+ previous = owner.verification_state
+ state = transition_identity_state(previous, request.action)
+ if owner is not None and (
+ same_owner or (request.action is Action.REPORT_CONFLICT and state is not State.REJECTED)
+ ):
+ values: dict[str, object] = {"revision": owner.revision + 1}
+ if same_owner:
+ values["verification_state"] = state
+ if request.action is Action.OBSERVE:
+ values["last_seen_at"] = datetime.now(UTC)
+ else:
+ values["verification_state"] = State.CONFLICTED
+ await session.execute(update(active).where(active.c.id == owner.id).values(**values))
+ return await _append(session, request, state)
+
+
+async def _candidate(
+ session: AsyncSession, kind: MetadataEntityKind, local_id: int, event_id: int
+) -> RowMapping:
+ _, history, key = _tables(kind)
+ row = (
+ (
+ await session.execute(
+ select(history).where(
+ history.c[key] == local_id,
+ history.c.id == event_id,
+ )
+ )
+ )
+ .mappings()
+ .one_or_none()
+ )
+ if row is None:
+ raise IdentityReviewNotFoundError("Saved identity evidence was not found for this record")
+ return row
+
+
+def _parent(row: RowMapping) -> ExternalIdentityRef | None:
+ """Read only bounded, canonical parent proof from saved internal evidence."""
+ try:
+ if hashlib.sha256(row.request_json.encode()).hexdigest() != row.request_fingerprint:
+ raise ValueError
+ payload = json.loads(row.request_json)
+ if not isinstance(payload, dict):
+ raise ValueError
+ parent = payload.get("parent_identity")
+ if parent is None:
+ return None
+ if not isinstance(parent, dict):
+ raise ValueError
+ result = ExternalIdentityRef(
+ IdentityNamespace(parent["namespace"]),
+ MetadataEntityKind(parent["entity_kind"]),
+ parent["external_id"],
+ )
+ if (
+ result.entity_kind is not MetadataEntityKind.SERIES
+ or result.namespace != row.identity_namespace
+ ):
+ raise ValueError
+ return result
+ except (KeyError, TypeError, ValueError) as exc:
+ raise IdentityReviewRequiredError(
+ "Saved parent evidence is invalid; recheck its source"
+ ) from exc
+
+
+def _request(
+ kind: MetadataEntityKind,
+ local_id: int,
+ row: RowMapping,
+ *,
+ action: Action,
+ fingerprint: str,
+ review_revision: int,
+ actor_user_id: int,
+) -> IdentityEventRequest:
+ from pullbox.core.metadata_identity_events import (
+ IdentityEvidenceLocator,
+ IdentityEvidenceRecordKind,
+ )
+
+ identity = ExternalIdentityRef(row.identity_namespace, kind, row.external_id)
+ try:
+ parent = _parent(row)
+ except IdentityReviewRequiredError:
+ if action is not Action.REJECT:
+ raise
+ # An invalid proof must remain rejectable without trusting its contents.
+ parent = None
+ return IdentityEventRequest(
+ uuid5(
+ _REVIEW_NAMESPACE, f"{kind}:{local_id}:{row.id}:{actor_user_id}:{action}:{fingerprint}"
+ ),
+ local_id,
+ action,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(identity, IdentityEvidenceKind.USER_SELECTION),
+ fingerprint,
+ locator=IdentityEvidenceLocator(
+ IdentityEvidenceRecordKind(f"{kind.value}_identity_event"), row.id
+ ),
+ parent_identity=parent,
+ ),
+ actor=IdentityEventActor.USER,
+ actor_user_id=actor_user_id,
+ review_revision=review_revision,
+ )
+
+
+async def _preview(
+ session: AsyncSession, kind: MetadataEntityKind, local_id: int, row: RowMapping
+) -> dict[str, object]:
+ active, history, key = _tables(kind)
+ target = (await _locked_targets(session, kind, [local_id]))[local_id]
+ owners = (
+ (
+ await session.execute(
+ select(active)
+ .where(
+ _scope(active, kind),
+ _namespace(active, kind) == row.identity_namespace,
+ or_(active.c[key] == local_id, active.c.external_id == row.external_id),
+ )
+ .order_by(active.c.id)
+ )
+ )
+ .mappings()
+ .all()
+ )
+ latest = await session.scalar(
+ select(func.max(history.c.id)).where(
+ history.c[key] == local_id,
+ history.c.identity_namespace == row.identity_namespace,
+ )
+ )
+ target_model = Base.metadata.tables[
+ {
+ MetadataEntityKind.SERIES: "series",
+ MetadataEntityKind.ISSUE: "issues",
+ MetadataEntityKind.STORY_ARC: "story_arcs",
+ }[kind]
+ ]
+ legacy_owner = None
+ if row.identity_namespace is IdentityNamespace.COMICVINE and int(row.external_id) < 2**63:
+ legacy_owner = await session.scalar(
+ select(target_model.c.id).where(target_model.c.comicvine_id == int(row.external_id))
+ )
+ proof: dict[str, object] = {
+ "schema_version": 1,
+ "candidate": dict(row),
+ "target": dict(target),
+ "owners": [dict(owner) for owner in owners],
+ "latest_event": latest,
+ "legacy_owner": legacy_owner,
+ }
+ if kind is MetadataEntityKind.ISSUE:
+ series = (
+ (
+ await session.execute(
+ select(Series.id, Series.comicvine_id).where(Series.id == target.series_id)
+ )
+ )
+ .mappings()
+ .one()
+ )
+ parent_owner = await _owner(
+ session, MetadataEntityKind.SERIES, target.series_id, row.identity_namespace
+ )
+ proof["parent"] = dict(series)
+ proof["parent_owner"] = dict(parent_owner) if parent_owner else None
+ fingerprint = hashlib.sha256(
+ json.dumps(proof, sort_keys=True, default=str, separators=(",", ":")).encode()
+ ).hexdigest()
+ current = next((item for item in owners if item[key] == local_id), None)
+ external_owner = next(
+ (item[key] for item in owners if item.external_id == row.external_id), None
+ )
+ state = await session.scalar(
+ select(history.c.verification_state)
+ .where(
+ history.c[key] == local_id,
+ history.c.identity_namespace == row.identity_namespace,
+ history.c.external_id == row.external_id,
+ )
+ .order_by(history.c.id.desc())
+ .limit(1)
+ )
+ if (
+ state is not State.REJECTED
+ and current is not None
+ and current.external_id == row.external_id
+ ):
+ state = current.verification_state
+ return {
+ "event_id": row.id,
+ "entity_kind": kind,
+ "local_id": local_id,
+ "identity_namespace": row.identity_namespace,
+ "external_id": row.external_id,
+ "verification_state": state,
+ "current_external_id": current.external_id if current else None,
+ "owner_local_id": external_owner or legacy_owner,
+ "review_revision": (latest or 0) + 1,
+ "fingerprint": fingerprint,
+ }
+
+
+async def preview_identity_review(
+ session: AsyncSession, kind: MetadataEntityKind, local_id: int, event_id: int
+) -> dict[str, object]:
+ """Return a sanitized optimistic snapshot; it is not an authorization token."""
+ row = await _candidate(session, kind, local_id, event_id)
+ return await _preview(session, kind, local_id, row)
+
+
+async def list_identity_claims(
+ session: AsyncSession, kind: MetadataEntityKind, local_id: int, *, limit: int, offset: int
+) -> tuple[list[dict[str, object]], int]:
+ """Page the latest state per claim, without returning raw evidence or paths."""
+ if not 1 <= limit <= 200 or offset < 0:
+ raise ValueError("Invalid identity claim page")
+ await _locked_targets(session, kind, [local_id])
+ active, history, key = _tables(kind)
+ latest = (
+ select(func.max(history.c.id).label("id"))
+ .where(history.c[key] == local_id)
+ .group_by(history.c.identity_namespace, history.c.external_id)
+ .subquery()
+ )
+ total = await session.scalar(select(func.count()).select_from(latest))
+ result = await session.execute(
+ select(
+ history.c.id.label("event_id"),
+ history.c.identity_namespace,
+ history.c.external_id,
+ case(
+ (history.c.verification_state == State.REJECTED, history.c.verification_state),
+ else_=func.coalesce(active.c.verification_state, history.c.verification_state),
+ ).label("verification_state"),
+ )
+ .outerjoin(
+ active,
+ (active.c[key] == history.c[key])
+ & _scope(active, kind)
+ & (_namespace(active, kind) == history.c.identity_namespace)
+ & (active.c.external_id == history.c.external_id),
+ )
+ .where(history.c.id.in_(select(latest.c.id)))
+ .order_by(history.c.id.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+ return [dict(row) for row in result.mappings()], total or 0
+
+
+async def apply_identity_review(
+ session: AsyncSession,
+ kind: MetadataEntityKind,
+ local_id: int,
+ event_id: int,
+ *,
+ action: Action,
+ fingerprint: str,
+ review_revision: int,
+ actor_user_id: int,
+) -> IdentityAttachmentReceipt:
+ """Apply an authenticated decision in the caller's transaction, without I/O.
+
+ The transport must require interactive operator auth and CSRF. Actor identity
+ comes from that dependency, never the request body. Replays return history,
+ not a claim that the old ownership is still current.
+ """
+ if action not in {Action.CONFIRM, Action.REJECT}:
+ raise ValueError("Review requires an explicit confirm or reject decision")
+ await _begin_write(session)
+ try:
+ async with session.begin_nested():
+ if not await session.scalar(
+ select(User.id).where(User.id == actor_user_id, User.is_active.is_(True))
+ ):
+ raise IdentityReviewRequiredError("An active operator is required")
+ row = await _candidate(session, kind, local_id, event_id)
+ request = _request(
+ kind,
+ local_id,
+ row,
+ action=action,
+ fingerprint=fingerprint,
+ review_revision=review_revision,
+ actor_user_id=actor_user_id,
+ )
+ await _lock_parent_graph(session, [request])
+ replay = await _replay(session, request)
+ if replay:
+ return replay
+ current = await _preview(session, kind, local_id, row)
+ if (
+ current["fingerprint"] != fingerprint
+ or current["review_revision"] != review_revision
+ ):
+ raise IdentityReviewRequiredError(
+ "Identity evidence changed; reload the review before deciding"
+ )
+ if action is Action.CONFIRM:
+ await _confirm(session, request)
+ state = State.VERIFIED
+ else:
+ await _reject(session, request)
+ state = State.REJECTED
+ return await _append(session, request, state)
+ except IntegrityError as exc:
+ raise IdentityAttachmentConflictError(
+ "Identity ownership changed; reload before retrying"
+ ) from exc
+
+
+async def _confirm(session: AsyncSession, request: IdentityEventRequest) -> None:
+ identity = request.evidence.claim.identity
+ kind, namespace = identity.entity_kind, identity.namespace
+ if namespace is IdentityNamespace.COMICVINE and int(identity.external_id) >= 2**63:
+ raise IdentityAttachmentConflictError(
+ "ComicVine identity exceeds the compatibility ID range"
+ )
+ targets = await _locked_targets(session, kind, [request.local_id])
+ await _validate_legacy_owners(session, kind, [request], targets)
+ if kind is MetadataEntityKind.ISSUE:
+ if request.evidence.parent_identity is None:
+ raise IdentityAttachmentConflictError(
+ "Issue confirmation requires exact series evidence; recheck its source"
+ )
+ await _validate_issue_parents(session, [request], targets)
+ active, _, key = _tables(kind)
+ owner = await _owner(session, kind, request.local_id, namespace)
+ other = await session.scalar(
+ select(active.c[key]).where(
+ _scope(active, kind),
+ _namespace(active, kind) == namespace,
+ active.c.external_id == identity.external_id,
+ active.c[key] != request.local_id,
+ )
+ )
+ if other is not None or (owner is not None and owner.external_id != identity.external_id):
+ raise IdentityAttachmentConflictError(
+ "Identity already has an owner; review that assignment before replacing it"
+ )
+ now = datetime.now(UTC)
+ values: dict[str, object] = {
+ "verification_state": State.VERIFIED,
+ "evidence_kind": IdentityEvidenceKind.USER_SELECTION,
+ "evidence_locator": prepare_identity_event(request).request_json,
+ "verified_at": now,
+ "last_seen_at": now,
+ "revision": owner.revision + 1 if owner else 1,
+ }
+ if owner:
+ await session.execute(update(active).where(active.c.id == owner.id).values(**values))
+ else:
+ values.update({key: request.local_id, "external_id": identity.external_id})
+ values.update(
+ {"source": namespace.value, "namespace": "story_arc"}
+ if kind is MetadataEntityKind.STORY_ARC
+ else {"identity_namespace": namespace}
+ )
+ await session.execute(insert(active).values(**values))
+ if namespace is IdentityNamespace.COMICVINE:
+ target = Base.metadata.tables[
+ {
+ MetadataEntityKind.SERIES: "series",
+ MetadataEntityKind.ISSUE: "issues",
+ MetadataEntityKind.STORY_ARC: "story_arcs",
+ }[kind]
+ ]
+ await session.execute(
+ update(target)
+ .where(target.c.id == request.local_id)
+ .values(comicvine_id=int(identity.external_id))
+ )
+
+
+async def _reject(session: AsyncSession, request: IdentityEventRequest) -> None:
+ identity = request.evidence.claim.identity
+ kind, namespace = identity.entity_kind, identity.namespace
+ active, _, _ = _tables(kind)
+ owner = await _owner(session, kind, request.local_id, namespace)
+ target = Base.metadata.tables[
+ {
+ MetadataEntityKind.SERIES: "series",
+ MetadataEntityKind.ISSUE: "issues",
+ MetadataEntityKind.STORY_ARC: "story_arcs",
+ }[kind]
+ ]
+ legacy = await session.scalar(
+ select(target.c.comicvine_id).where(target.c.id == request.local_id)
+ )
+ same_owner = owner is not None and owner.external_id == identity.external_id
+ same_legacy = namespace is IdentityNamespace.COMICVINE and str(legacy) == identity.external_id
+ if kind is MetadataEntityKind.SERIES and (same_owner or same_legacy):
+ child = Base.metadata.tables["issue_external_identities"]
+ dependent = await session.scalar(
+ select(child.c.id)
+ .join(Issue, Issue.id == child.c.issue_id)
+ .where(
+ Issue.series_id == request.local_id,
+ child.c.identity_namespace == namespace,
+ )
+ .limit(1)
+ )
+ legacy_child = namespace is IdentityNamespace.COMICVINE and await session.scalar(
+ select(Issue.id)
+ .where(
+ Issue.series_id == request.local_id,
+ Issue.comicvine_id.is_not(None),
+ )
+ .limit(1)
+ )
+ if dependent or legacy_child:
+ raise IdentityAttachmentConflictError(
+ "Review dependent issue identities before detaching their series"
+ )
+ if same_owner and owner is not None:
+ await session.execute(delete(active).where(active.c.id == owner.id))
+ if same_legacy:
+ await session.execute(
+ update(target).where(target.c.id == request.local_id).values(comicvine_id=None)
+ )
diff --git a/src/pullbox/services/metadata_issue_catalog.py b/src/pullbox/services/metadata_issue_catalog.py
new file mode 100644
index 00000000..bdd8618f
--- /dev/null
+++ b/src/pullbox/services/metadata_issue_catalog.py
@@ -0,0 +1,164 @@
+"""Shared issue-only writes for complete catalogs and bounded source windows."""
+
+from dataclasses import replace
+from datetime import datetime
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind
+from pullbox.models import Issue, Series
+from pullbox.models.issue import IssueStatus
+from pullbox.services.metadata_assembly import assemble_metadata
+from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines
+from pullbox.services.metadata_credits import write_issue_credits
+from pullbox.services.metadata_entity_values import apply_issue_metadata_values
+from pullbox.services.metadata_identity_attachment import attach_verified_identities
+from pullbox.services.metadata_identity_review import record_identity_observation
+from pullbox.services.metadata_series_adoption import (
+ SourceIssueBatch as SourceIssueBatch,
+)
+from pullbox.services.metadata_series_adoption import (
+ _identities,
+ _metadata_label,
+ _request,
+ _require_unowned_issues,
+ _validate_issue_batch,
+)
+from pullbox.services.metadata_series_refresh_state import ISSUE_FIELDS, SeriesRefreshState
+from pullbox.services.metadata_service import classify_issue_metadata
+
+
+class IssueCatalogConflictError(ValueError):
+ """A source issue cannot safely update its canonical library target."""
+
+
+async def apply_issue_batch(
+ session: AsyncSession,
+ state: SeriesRefreshState,
+ batch: SourceIssueBatch,
+ now: datetime,
+ *,
+ complete: bool = False,
+ replace_managed: bool = False,
+) -> tuple[int, ...]:
+ """Apply to a locked, revalidated read set; return exactly the created IDs.
+
+ The caller owns transaction rollback, series metadata, completeness and
+ checkpoint updates. This writer never starts provider or filesystem work.
+ """
+ source = batch.source
+ parent = ExternalIdentityRef(
+ source.identity_namespace, MetadataEntityKind.SERIES, batch.series_external_id
+ )
+ policy = next((item for item in state.policies if item.source is source), None)
+ if (
+ parent.external_id != batch.series_external_id
+ or parent not in state.series.identities
+ or policy is None
+ or not policy.enabled
+ or policy.revision != batch.source_revision
+ ):
+ raise IssueCatalogConflictError("Catalog source or verified series identity changed.")
+ try:
+ numbers = _validate_issue_batch(batch)
+ except ValueError as exc:
+ raise IssueCatalogConflictError(str(exc)) from exc
+ by_identity = {identity: item for item in state.issues for identity in item.identities}
+ offered = {
+ ExternalIdentityRef(source.identity_namespace, MetadataEntityKind.ISSUE, item.external_id)
+ for item in batch.issues
+ }
+ missing = {
+ identity for identity in by_identity if identity.namespace is source.identity_namespace
+ } - offered
+ if complete and missing:
+ raise IssueCatalogConflictError(
+ "The provider removed existing issue identities. "
+ "Review the catalog; existing issues and files were kept."
+ )
+ by_number = {item.values.issue_number_text: item for item in state.issues}
+ prepared = []
+ for metadata, (number, text) in zip(batch.issues, numbers, strict=True):
+ identity = ExternalIdentityRef(
+ source.identity_namespace, MetadataEntityKind.ISSUE, metadata.external_id
+ )
+ existing = by_identity.get(identity)
+ if existing is not None and existing.values.issue_number_text != text:
+ raise IssueCatalogConflictError(
+ "A provider issue was renumbered. Review the issue match; existing files were kept."
+ )
+ if existing is None and text in by_number:
+ raise IssueCatalogConflictError(
+ "An issue designation already belongs to a different identity. "
+ "Review its match; no issues were reassigned."
+ )
+ snapshot = assemble_metadata(
+ MetadataEntityKind.ISSUE,
+ existing.identities if existing else (identity,),
+ [metadata.model_copy(update={"issue_number_text": text})],
+ state.policies,
+ now=now,
+ current=existing.values if existing else None,
+ previous=existing.baseline if existing else None,
+ parent_identities=state.series.identities,
+ replace_managed=replace_managed,
+ fields=ISSUE_FIELDS,
+ )
+ prepared.append((existing, metadata, number, text, snapshot))
+ new_metadata = tuple(metadata for old, metadata, *_rest in prepared if old is None)
+ if new_metadata:
+ await _require_unowned_issues(session, replace(batch, issues=new_metadata))
+ series = await session.get(Series, state.series.local_id)
+ if series is None:
+ raise IssueCatalogConflictError("The series no longer exists.")
+ existing_issues = {
+ item.id: item
+ for item in await session.scalars(select(Issue).where(Issue.series_id == series.id))
+ }
+ created = []
+ for offset in range(0, len(prepared), 200):
+ pending = []
+ for existing, metadata, number, text, snapshot in prepared[offset : offset + 200]:
+ issue = existing_issues.get(existing.local_id) if existing else None
+ if issue is None:
+ issue = Issue(
+ series_id=series.id,
+ issue_number=number,
+ issue_number_text=text,
+ status=IssueStatus.WANTED if series.monitored else IssueStatus.SKIPPED,
+ issue_type=classify_issue_metadata(series.series_type, metadata.title)[1],
+ metadata_source=_metadata_label(source),
+ )
+ session.add(issue)
+ apply_issue_metadata_values(issue, snapshot.values)
+ pending.append((issue, existing, metadata, snapshot))
+ await session.flush()
+ await attach_verified_identities(
+ session,
+ [
+ _request(
+ batch, metadata, _identities(metadata, MetadataEntityKind.ISSUE)[0], issue.id
+ )
+ for issue, old, metadata, _ in pending
+ if old is None
+ ],
+ )
+ for issue, old, metadata, _ in pending:
+ if old is None:
+ created.append(issue.id)
+ for crosswalk in _identities(metadata, MetadataEntityKind.ISSUE)[1:]:
+ await record_identity_observation(
+ session, _request(batch, metadata, crosswalk, issue.id, observation=True)
+ )
+ await write_issue_credits(
+ session, {issue.id: item.values.credits for issue, _, _, item in pending}
+ )
+ await save_metadata_baselines(
+ session,
+ [
+ MetadataBaselineWrite(issue.id, item, old.baseline_revision if old else 0)
+ for issue, old, _, item in pending
+ ],
+ )
+ return tuple(created)
diff --git a/src/pullbox/services/metadata_issue_refresh.py b/src/pullbox/services/metadata_issue_refresh.py
new file mode 100644
index 00000000..8513a788
--- /dev/null
+++ b/src/pullbox/services/metadata_issue_refresh.py
@@ -0,0 +1,131 @@
+"""Refresh one verified issue without changing its designation, ownership or files."""
+
+import asyncio
+from datetime import UTC, datetime
+
+import structlog
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import NotFoundError
+from pullbox.core.issue_numbers import normalize_issue_number_text
+from pullbox.core.metadata_identity import MetadataEntityKind
+from pullbox.models import Issue, Series
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.schemas.issue_metadata_links import IssueRefreshRead
+from pullbox.schemas.metadata_sources import ProviderIssueRead, SourceStatus
+from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines
+from pullbox.services.metadata_credits import write_issue_credits
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_entity_values import apply_issue_metadata_values
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_refresh_snapshot import fetch_metadata_snapshot
+from pullbox.services.metadata_series_refresh_state import ISSUE_FIELDS, read_series_refresh_state
+from pullbox.services.metadata_sources import load_source_runtime
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+logger = structlog.get_logger(__name__)
+
+
+async def refresh_issue_from_sources(
+ session: AsyncSession,
+ issue_id: int,
+ *,
+ gcd_api_enabled: bool,
+ registry: MetadataSourceRegistry | None = None,
+) -> IssueRefreshRead:
+ """Read/fetch/revalidate/apply in a caller-owned transaction, never an archive writer."""
+ if session.new or session.dirty or session.deleted:
+ raise ValueError("Finish pending library changes before refreshing metadata.")
+ series_id = await session.scalar(select(Issue.series_id).where(Issue.id == issue_id))
+ if series_id is None:
+ raise NotFoundError("Issue", issue_id)
+ before = await read_series_refresh_state(session, series_id, issue_ids=(issue_id,))
+ member = before.issues[0]
+ if not member.identities:
+ raise ValueError("Link a verified metadata provider to this issue before refreshing.")
+ if registry is None:
+ registry = MetadataSourceRegistry(
+ await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled),
+ gcd_api_enabled=gcd_api_enabled,
+ read_cache=source_read_cache(session),
+ total_timeout=60,
+ )
+ await session.rollback()
+ try:
+ async with asyncio.timeout(65):
+ fetched = await fetch_metadata_snapshot(
+ registry,
+ MetadataEntityKind.ISSUE,
+ member.identities,
+ now=datetime.now(UTC),
+ requested_fields=ISSUE_FIELDS,
+ current=member.values,
+ previous=member.baseline,
+ overrides=member.overrides,
+ replace_managed=True,
+ parent_identities=before.series.identities,
+ )
+ except TimeoutError as exc:
+ raise ValueError(
+ "Metadata refresh timed out. No issue metadata was changed; retry later."
+ ) from exc
+ failures = [
+ item
+ for item in fetched.outcomes
+ if item.status
+ not in {
+ SourceStatus.OK,
+ SourceStatus.NOT_QUERIED,
+ SourceStatus.DISABLED,
+ SourceStatus.FEATURE_DISABLED,
+ }
+ ]
+ if not fetched.candidates and (
+ failures
+ or not any(
+ item.status in {SourceStatus.OK, SourceStatus.NOT_QUERIED} for item in fetched.outcomes
+ )
+ ):
+ raise ValueError(
+ "No linked provider could refresh this issue. Check Metadata settings "
+ "or try again later; no metadata was changed."
+ )
+ if any(
+ not isinstance(item, ProviderIssueRead)
+ or normalize_issue_number_text(item.issue_number_text) != member.values.issue_number_text
+ for item in fetched.candidates
+ ):
+ raise ValueError(
+ "The provider changed this issue's exact number. Review the match before retrying; "
+ "no metadata was changed."
+ )
+ async with metadata_write_scope(session):
+ await session.execute(
+ select(MetadataSourceConfig)
+ .order_by(MetadataSourceConfig.source)
+ .with_for_update(read=True)
+ )
+ await session.execute(select(Series.id).where(Series.id == series_id).with_for_update())
+ await session.execute(select(Issue.id).where(Issue.id == issue_id).with_for_update())
+ current = await read_series_refresh_state(session, series_id, issue_ids=(issue_id,))
+ if before != current:
+ raise ValueError(
+ "Issue metadata, identities or source settings changed. Retry the refresh; "
+ "no metadata was changed."
+ )
+ issue = await session.get(Issue, issue_id)
+ assert issue is not None
+ apply_issue_metadata_values(issue, fetched.snapshot.values)
+ await write_issue_credits(session, {issue_id: fetched.snapshot.values.credits})
+ await save_metadata_baselines(
+ session, [MetadataBaselineWrite(issue_id, fetched.snapshot, member.baseline_revision)]
+ )
+ await session.flush()
+ logger.info(
+ "metadata_issue_source_refreshed",
+ issue_id=issue_id,
+ series_id=series_id,
+ partial=bool(failures),
+ )
+ return IssueRefreshRead(issue_id=issue_id, outcomes=failures)
diff --git a/src/pullbox/services/metadata_read_cache.py b/src/pullbox/services/metadata_read_cache.py
new file mode 100644
index 00000000..6f989e3a
--- /dev/null
+++ b/src/pullbox/services/metadata_read_cache.py
@@ -0,0 +1,252 @@
+"""Persistent normalized source reads with source-bound conditional validators."""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import weakref
+from dataclasses import dataclass, field
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING
+
+import structlog
+from sqlalchemy import delete, select
+from sqlalchemy.dialects.postgresql import insert as pg_insert
+from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
+
+from pullbox.models.provider_cache import MetadataProviderCacheEntry as Entry
+from pullbox.schemas.metadata_sources import MetadataFetch, SourceCapability, SourceStatus
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable
+
+ from pullbox.core.metadata_identity import MetadataSource
+
+logger = structlog.get_logger(__name__)
+_KIND = "source-read-v1"
+_MAX_BYTES = 256 * 1024
+_MAX_ENTRIES = 128
+
+
+@dataclass
+class _Flight:
+ task: asyncio.Task[bytes]
+ waiters: int = 0
+
+
+@dataclass
+class _Coordinator:
+ pending: dict[tuple[str, bool], _Flight] = field(default_factory=dict)
+
+
+# Coordinators do not retain engines; active fetches own their sessions and are
+# drained when their final waiter leaves. Separate application DBs never share.
+_coordinators: weakref.WeakKeyDictionary[AsyncEngine, _Coordinator] = weakref.WeakKeyDictionary()
+
+
+def source_read_cache(session: AsyncSession) -> MetadataReadCache | None:
+ """Use the caller's database without borrowing its transaction."""
+ if not isinstance(session.bind, AsyncEngine):
+ return None
+ return MetadataReadCache(
+ async_sessionmaker(session.bind, class_=type(session), expire_on_commit=False)
+ )
+
+
+class MetadataReadCache:
+ def __init__(
+ self,
+ factory: async_sessionmaker[AsyncSession],
+ *,
+ now: Callable[[], datetime] | None = None,
+ ) -> None:
+ self.factory = factory
+ self.now = now or (lambda: datetime.now(UTC))
+ engine = factory.kw.get("bind")
+ if not isinstance(engine, AsyncEngine):
+ raise ValueError("Source cache requires its own engine-bound sessions")
+ self.coordinator = _coordinators.setdefault(engine, _Coordinator())
+
+ async def _load(
+ self, source: MetadataSource, key: str
+ ) -> tuple[datetime, dict[str, object]] | None:
+ try:
+ async with asyncio.timeout(2), self.factory() as session:
+ row = await session.scalar(
+ select(Entry).where(
+ Entry.provider_name == source.value,
+ Entry.cache_kind == _KIND,
+ Entry.cache_key == key,
+ Entry.expires_at > self.now(),
+ )
+ )
+ return (row.fetched_at, row.payload) if row is not None else None
+ except (SQLAlchemyError, TimeoutError):
+ logger.warning("metadata_read_cache_unavailable", source=source.value)
+ return None
+
+ async def _store(
+ self,
+ source: MetadataSource,
+ key: str,
+ request: dict[str, object],
+ payload: bytes,
+ fetched_at: datetime,
+ ) -> None:
+ if len(payload) > _MAX_BYTES:
+ return
+ try:
+ async with asyncio.timeout(2), self.factory.begin() as session:
+ insert = (
+ pg_insert if session.get_bind().dialect.name == "postgresql" else sqlite_insert
+ )
+ statement = insert(Entry).values(
+ provider_name=source.value,
+ cache_kind=_KIND,
+ cache_key=key,
+ request=request,
+ payload=json.loads(payload),
+ fetched_at=fetched_at,
+ expires_at=fetched_at + timedelta(days=1),
+ )
+ await session.execute(
+ statement.on_conflict_do_update(
+ index_elements=[Entry.provider_name, Entry.cache_kind, Entry.cache_key],
+ set_={
+ "payload": statement.excluded.payload,
+ "fetched_at": statement.excluded.fetched_at,
+ "expires_at": statement.excluded.expires_at,
+ },
+ where=Entry.fetched_at <= statement.excluded.fetched_at,
+ )
+ )
+ oldest = (
+ select(Entry.id)
+ .where(
+ Entry.cache_kind == _KIND,
+ Entry.provider_name.in_(["comicvine_api", "metron_api", "gcd_api_v2"]),
+ )
+ .order_by(Entry.fetched_at.desc(), Entry.id.desc())
+ .offset(_MAX_ENTRIES)
+ )
+ await session.execute(delete(Entry).where(Entry.id.in_(oldest)))
+ except (SQLAlchemyError, TimeoutError):
+ logger.warning("metadata_read_cache_store_failed", source=source.value)
+
+ async def _fetch[T](
+ self,
+ source: MetadataSource,
+ key: str,
+ request: dict[str, object],
+ model: type[MetadataFetch[T]],
+ loader: Callable[[str | None], Awaitable[MetadataFetch[T]]],
+ validate: Callable[[T], None],
+ *,
+ conditional: bool,
+ revalidate: bool,
+ ) -> bytes:
+ stored = await self._load(source, key)
+ cached = None
+ if stored is not None:
+ try:
+ cached = model.model_validate(stored[1])
+ if cached.status is SourceStatus.OK and cached.data is not None:
+ validate(cached.data)
+ elif cached.status is not SourceStatus.NOT_FOUND or cached.data is not None:
+ raise ValueError("Invalid cached response")
+ validator = cached.validator
+ if validator is not None and (
+ not 1 <= len(validator) <= 128
+ or any(ord(char) < 32 or ord(char) > 126 for char in validator)
+ ):
+ raise ValueError("Invalid cached validator")
+ except (ValueError, TypeError, AttributeError):
+ cached = None
+ if cached is not None and stored is not None and not revalidate:
+ ttl = timedelta(seconds=300 if cached.status is SourceStatus.OK else 15)
+ if stored[0] <= self.now() < stored[0] + ttl:
+ return cached.model_dump_json().encode()
+ validator = (
+ cached.validator
+ if cached is not None and cached.data is not None and conditional
+ else None
+ )
+ started_at = self.now()
+ result = await loader(validator)
+ if result.status is SourceStatus.NOT_MODIFIED:
+ if cached is None or cached.data is None or validator is None:
+ return model(status=SourceStatus.INCOMPATIBLE_RESPONSE).model_dump_json().encode()
+ result = cached
+ if result.status is SourceStatus.OK:
+ if result.data is None:
+ return model(status=SourceStatus.INCOMPATIBLE_RESPONSE).model_dump_json().encode()
+ validate(result.data)
+ task = asyncio.current_task()
+ if task is not None and task.cancelling():
+ raise asyncio.CancelledError
+ payload = result.model_dump_json().encode()
+ if result.status in {SourceStatus.OK, SourceStatus.NOT_FOUND}:
+ await self._store(source, key, request, payload, started_at)
+ return payload
+
+ async def get[T](
+ self,
+ source: MetadataSource,
+ revision: int,
+ capability: SourceCapability,
+ identifier: str,
+ page: int,
+ model: type[MetadataFetch[T]],
+ loader: Callable[[str | None], Awaitable[MetadataFetch[T]]],
+ validate: Callable[[T], None],
+ *,
+ conditional: bool,
+ revalidate: bool = False,
+ ) -> MetadataFetch[T]:
+ request: dict[str, object] = {
+ "source": source.value,
+ "revision": revision,
+ "operation": capability.value,
+ "external_id": identifier,
+ "page": page,
+ }
+ # A public cache identity, not an authentication or password digest.
+ key = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest()
+ flight_key = (key, revalidate)
+ flight = self.coordinator.pending.get(flight_key)
+ if flight is not None and flight.task.cancelling():
+ return model(status=SourceStatus.UNAVAILABLE, retry_after_seconds=1)
+ if flight is None:
+ if len(self.coordinator.pending) >= 8:
+ return model(status=SourceStatus.UNAVAILABLE, retry_after_seconds=1)
+ flight = _Flight(
+ asyncio.create_task(
+ self._fetch(
+ source,
+ key,
+ request,
+ model,
+ loader,
+ validate,
+ conditional=conditional,
+ revalidate=revalidate,
+ )
+ )
+ )
+ self.coordinator.pending[flight_key] = flight
+ flight.waiters += 1
+ try:
+ return model.model_validate_json(await asyncio.shield(flight.task))
+ finally:
+ flight.waiters -= 1
+ if not flight.waiters:
+ if not flight.task.done():
+ flight.task.cancel()
+ try:
+ await asyncio.gather(flight.task, return_exceptions=True)
+ finally:
+ if self.coordinator.pending.get(flight_key) is flight:
+ self.coordinator.pending.pop(flight_key)
diff --git a/src/pullbox/services/metadata_refresh_snapshot.py b/src/pullbox/services/metadata_refresh_snapshot.py
new file mode 100644
index 00000000..5794cd19
--- /dev/null
+++ b/src/pullbox/services/metadata_refresh_snapshot.py
@@ -0,0 +1,192 @@
+"""Read-only refresh cascade; callers revalidate ownership before applying a snapshot."""
+
+import asyncio
+from collections.abc import Sequence
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
+
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues, field_domain
+from pullbox.schemas.metadata_sources import (
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ SourceCapability,
+ SourceOutcome,
+ SourceStatus,
+)
+from pullbox.services.metadata_assembly import (
+ MetadataAssemblyError,
+ MetadataCandidate,
+ assemble_metadata,
+ missing_metadata_value,
+)
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+
+
+class MetadataRefreshSnapshot(BaseModel):
+ model_config = ConfigDict(frozen=True)
+ snapshot: MetadataSnapshot
+ outcomes: tuple[SourceOutcome, ...]
+ revisions: dict[MetadataSource, int]
+ candidates: tuple[MetadataCandidate, ...] = ()
+
+
+async def fetch_metadata_snapshot(
+ registry: MetadataSourceRegistry,
+ kind: MetadataEntityKind,
+ identities: Sequence[ExternalIdentityRef],
+ *,
+ now: datetime,
+ requested_fields: frozenset[str],
+ current: MetadataValues | None = None,
+ previous: MetadataSnapshot | None = None,
+ overrides: frozenset[str] = frozenset(),
+ replace_managed: bool = False,
+ parent_identities: Sequence[ExternalIdentityRef] = (),
+ initial_candidates: Sequence[MetadataCandidate] = (),
+) -> MetadataRefreshSnapshot:
+ """Fetch only attached identities and stop each domain at its first usable value.
+
+ No writes, searches, identity attachment, file operations or catalog membership
+ changes occur here. A success is evidence for a later revision-checked write,
+ not permission to apply metadata after ownership/configuration has changed.
+ """
+ fields = {
+ MetadataEntityKind.SERIES: ProviderSeriesRead.model_fields.keys(),
+ MetadataEntityKind.ISSUE: ProviderIssueRead.model_fields.keys(),
+ MetadataEntityKind.STORY_ARC: ProviderStoryArcRead.model_fields.keys(),
+ }[kind]
+ if not requested_fields or requested_fields - (fields & MetadataValues.model_fields.keys()):
+ raise ValueError("Choose supported metadata fields for this entity kind.")
+ # Refresh must not mistake a fresh preview cache entry for an upstream check.
+ reader = MetadataSourceRegistry(
+ list(registry.runtime.values()),
+ factories=registry.factories,
+ gcd_api_enabled=registry.gcd_api_enabled,
+ per_source_timeout=registry.per_source_timeout,
+ total_timeout=registry.total_timeout,
+ concurrency=registry.concurrency,
+ read_cache=registry.read_cache,
+ revalidate_reads=True,
+ )
+ policies = [runtime.policy for runtime in reader.runtime.values()]
+ candidates: list[MetadataCandidate] = []
+
+ def assemble(*, local: bool) -> MetadataSnapshot:
+ return assemble_metadata(
+ kind,
+ identities,
+ candidates,
+ policies,
+ now=now,
+ current=current if local else None,
+ previous=previous if local else None,
+ overrides=overrides if local else frozenset(),
+ replace_managed=replace_managed,
+ parent_identities=parent_identities,
+ fields=requested_fields,
+ )
+
+ snapshot = assemble(local=True)
+ initial = {origin.field: origin for origin in snapshot.origins}
+ needed = set()
+ for field in requested_fields:
+ value = getattr(snapshot.values, field)
+ missing = missing_metadata_value(value)
+ origin = initial.get(field)
+ if origin is not None and origin.user_override:
+ continue
+ if missing or (
+ replace_managed
+ and origin is not None
+ and (origin.source is not None or origin.derivation is not None)
+ ):
+ needed.add(field)
+ capability = {
+ MetadataEntityKind.SERIES: SourceCapability.SERIES_DETAILS,
+ MetadataEntityKind.ISSUE: SourceCapability.ISSUE_DETAILS,
+ MetadataEntityKind.STORY_ARC: SourceCapability.STORY_ARC_DETAILS,
+ }[kind]
+ known = {identity.namespace: identity.external_id for identity in identities}
+ outcomes = {}
+ remaining = set()
+ for source in reader.runtime:
+ if source.identity_namespace not in known:
+ continue
+ status = reader._unavailable(source, capability=capability)
+ outcomes[source] = SourceOutcome(source=source, status=status or SourceStatus.NOT_QUERIED)
+ if status is None:
+ if kind is MetadataEntityKind.ISSUE and not any(
+ parent.namespace is source.identity_namespace for parent in parent_identities
+ ):
+ raise MetadataAssemblyError("Issue refresh requires verified parent identities.")
+ remaining.add(source)
+
+ # A freshly fetched catalog may already include this exact descriptive row.
+ # Validate it through the same assembler, but still consult higher priorities.
+ candidates.extend(initial_candidates)
+ snapshot = assemble(local=True)
+ for candidate in initial_candidates:
+ remaining.discard(candidate.source)
+ outcomes[candidate.source] = SourceOutcome(source=candidate.source, status=SourceStatus.OK)
+
+ def rank(source: MetadataSource, field: str) -> tuple[int, str]:
+ policy = reader.runtime[source].policy
+ domain = field_domain(kind, field)
+ return policy.domain_priorities.get(domain, policy.priority), source.value
+
+ revisions = {source: runtime.policy.revision for source, runtime in reader.runtime.items()}
+ deadline = asyncio.get_running_loop().time() + reader.total_timeout
+ while remaining and needed:
+ offered = assemble(local=False)
+ winners = {origin.field: origin.source for origin in offered.origins}
+ choices = []
+ for field in needed:
+ winner = winners.get(field)
+ eligible = [
+ source
+ for source in remaining
+ if (
+ field != "image_url"
+ or source not in {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2}
+ )
+ and (winner is None or rank(source, field) < rank(winner, field))
+ ]
+ if eligible:
+ source = min(eligible, key=lambda source: rank(source, field))
+ choices.append((rank(source, field), source))
+ if not choices:
+ break
+ _, source = min(choices)
+ remaining.remove(source)
+ identifier = known[source.identity_namespace]
+ try:
+ async with asyncio.timeout_at(deadline):
+ result = await (
+ reader.series(source, identifier)
+ if kind is MetadataEntityKind.SERIES
+ else reader.issue(source, identifier)
+ if kind is MetadataEntityKind.ISSUE
+ else reader.story_arc(source, identifier)
+ )
+ except TimeoutError:
+ outcomes[source] = SourceOutcome(source=source, status=SourceStatus.TIMEOUT)
+ break
+ outcomes[source] = SourceOutcome(
+ source=source, status=result.status, retry_after_seconds=result.retry_after_seconds
+ )
+ if result.status is SourceStatus.INCOMPATIBLE_RESPONSE:
+ # The read guard also uses this status for wrong native IDs/parents.
+ # Falling back would let provider priority conceal exact disagreement.
+ raise MetadataAssemblyError("Source metadata requires review before refresh.")
+ if result.status is SourceStatus.OK and result.data is not None:
+ candidates.append(result.data)
+ snapshot = assemble(local=True)
+ return MetadataRefreshSnapshot(
+ snapshot=snapshot,
+ outcomes=tuple(outcomes.values()),
+ revisions=revisions,
+ candidates=tuple(candidates),
+ )
diff --git a/src/pullbox/services/metadata_scheduled_refresh.py b/src/pullbox/services/metadata_scheduled_refresh.py
new file mode 100644
index 00000000..9d0cfac4
--- /dev/null
+++ b/src/pullbox/services/metadata_scheduled_refresh.py
@@ -0,0 +1,83 @@
+"""Source eligibility and caller-owned background refresh of existing series."""
+
+from dataclasses import dataclass
+from pathlib import Path
+
+from sqlalchemy import exists, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.sql.elements import ColumnElement
+
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.models import Issue, Series
+from pullbox.models.issue import IssueStatus
+from pullbox.models.metadata_identity import SeriesExternalIdentity
+from pullbox.schemas.metadata_sources import SourceCapability, SourceOutcome
+from pullbox.services.cover_resolver import resolve_covers_dir
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_series_refresh import refresh_series_catalog_from_sources
+from pullbox.services.metadata_sources import load_source_runtime
+
+
+async def scheduled_series_eligibility(
+ session: AsyncSession, *, gcd_api_enabled: bool
+) -> ColumnElement[bool]:
+ """Use executable configured sources, not cached health or compatibility IDs."""
+ registry = MetadataSourceRegistry(
+ await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled),
+ gcd_api_enabled=gcd_api_enabled,
+ )
+ namespaces = {
+ source.identity_namespace
+ for source, runtime in registry.runtime.items()
+ if registry.source_availability(source, SourceCapability.SERIES_DETAILS) is None
+ and registry.source_availability(source, SourceCapability.ISSUE_LIST) is None
+ and (
+ source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL}
+ or runtime.credential is not None
+ )
+ }
+ return exists().where(
+ SeriesExternalIdentity.series_id == Series.id,
+ SeriesExternalIdentity.identity_namespace.in_(namespaces),
+ SeriesExternalIdentity.verification_state == IdentityVerificationState.VERIFIED,
+ )
+
+
+@dataclass(frozen=True)
+class ScheduledSeriesRefresh:
+ added: int
+ search_wanted: bool
+ cover_url: str | None
+ covers: Path
+ outcomes: tuple[SourceOutcome, ...] = ()
+
+
+async def refresh_scheduled_series(
+ session: AsyncSession, series_id: int, *, registry: MetadataSourceRegistry | None = None
+) -> ScheduledSeriesRefresh:
+ """Fill gaps and synchronize a complete catalog; the task commits progress too."""
+ covers = await resolve_covers_dir(session)
+ result = await refresh_series_catalog_from_sources(
+ session, series_id, registry=registry, replace_managed=False
+ )
+ wanted = (
+ await session.scalar(
+ select(Issue.id)
+ .where(
+ Issue.series_id == series_id,
+ Issue.id.in_(result.created_issue_ids),
+ Issue.status == IssueStatus.WANTED,
+ )
+ .limit(1)
+ )
+ if result.created_issue_ids
+ else None
+ )
+ return ScheduledSeriesRefresh(
+ len(result.created_issue_ids),
+ result.series.monitored and wanted is not None,
+ result.series.cover_url,
+ covers,
+ result.outcomes,
+ )
diff --git a/src/pullbox/services/metadata_search_cache.py b/src/pullbox/services/metadata_search_cache.py
new file mode 100644
index 00000000..bed8b728
--- /dev/null
+++ b/src/pullbox/services/metadata_search_cache.py
@@ -0,0 +1,179 @@
+"""Bounded, application-local provider candidate snapshots."""
+
+import asyncio
+import hashlib
+import json
+import time
+from collections import OrderedDict
+from collections.abc import Awaitable, Callable, Sequence
+from dataclasses import dataclass
+
+from pullbox.schemas.metadata_sources import (
+ SeriesDiscoveryQuery,
+ SeriesDiscoveryRead,
+ SourceStatus,
+ StoryArcDiscoveryQuery,
+ StoryArcDiscoveryRead,
+)
+from pullbox.services.metadata_sources import SourceRuntime
+
+
+class MetadataSearchBusyError(RuntimeError):
+ """Too many different interactive metadata searches are already running."""
+
+
+def discovery_cache_key(
+ query: SeriesDiscoveryQuery | StoryArcDiscoveryQuery,
+ runtime: Sequence[SourceRuntime],
+ *,
+ catalog_generation: str | None,
+ gcd_api_enabled: bool,
+ gcd_generation: str | None = None,
+) -> str:
+ request = query.model_dump(mode="json")
+ request["query"] = " ".join(query.query.split()).casefold()
+ if query.sources is not None:
+ request["sources"] = sorted(query.sources)
+ payload = {
+ "contract": "series-search-v1"
+ if isinstance(query, SeriesDiscoveryQuery)
+ else "arc-search-v1",
+ "query": request,
+ "sources": [
+ {
+ **item.policy.model_dump(
+ mode="json",
+ include={
+ "source",
+ "revision",
+ "enabled",
+ "priority",
+ "domain_priorities",
+ "credential_configured",
+ "configuration_status",
+ },
+ ),
+ "unavailable": item.unavailable,
+ }
+ for item in sorted(runtime, key=lambda item: item.policy.source)
+ ],
+ "catalog_generation": catalog_generation,
+ "gcd_generation": gcd_generation,
+ "gcd_api_enabled": gcd_api_enabled,
+ }
+ # This digest identifies public search/configuration snapshots, not credentials.
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
+
+
+@dataclass
+class _Flight:
+ task: asyncio.Task[bytes]
+ waiters: int = 0
+
+
+class MetadataSearchCache:
+ def __init__(
+ self,
+ *,
+ max_entries: int = 32,
+ max_bytes: int = 8 * 1024 * 1024,
+ max_pending: int = 4,
+ clock: Callable[[], float] | None = None,
+ ) -> None:
+ if not 1 <= max_entries <= 128 or not 1 <= max_bytes <= 32 * 1024 * 1024:
+ raise ValueError("Search snapshot storage must be bounded")
+ if not 1 <= max_pending <= 8:
+ raise ValueError("Search admission must be bounded")
+ self.max_entries, self.max_bytes, self.max_pending = max_entries, max_bytes, max_pending
+ self.clock = clock or time.monotonic
+ self._entries: OrderedDict[str, tuple[float, bytes]] = OrderedDict()
+ self._bytes = 0
+ self._pending: dict[str, _Flight] = {}
+
+ def _discard(self, key: str) -> None:
+ _, payload = self._entries.pop(key)
+ self._bytes -= len(payload)
+
+ async def _load(
+ self,
+ key: str,
+ loader: Callable[[], Awaitable[SeriesDiscoveryRead | StoryArcDiscoveryRead]],
+ *,
+ cache_result: bool,
+ ) -> bytes:
+ result = await loader()
+ task = asyncio.current_task()
+ if task is not None and task.cancelling():
+ raise asyncio.CancelledError
+ payload = result.model_dump_json().encode()
+ if cache_result and len(payload) <= self.max_bytes:
+ while self._entries and (
+ len(self._entries) >= self.max_entries
+ or self._bytes + len(payload) > self.max_bytes
+ ):
+ self._discard(next(iter(self._entries)))
+ complete = bool(result.results) and all(
+ source.status in {SourceStatus.OK, SourceStatus.EMPTY} and not source.truncated
+ for source in result.sources
+ )
+ self._entries[key] = (self.clock() + (300 if complete else 30), payload)
+ self._bytes += len(payload)
+ return payload
+
+ async def get(
+ self,
+ key: str,
+ loader: Callable[[], Awaitable[SeriesDiscoveryRead]],
+ *,
+ cache_result: bool = True,
+ ) -> SeriesDiscoveryRead:
+ return SeriesDiscoveryRead.model_validate_json(
+ await self._get_payload(key, loader, cache_result=cache_result)
+ )
+
+ async def get_arcs(
+ self, key: str, loader: Callable[[], Awaitable[StoryArcDiscoveryRead]]
+ ) -> StoryArcDiscoveryRead:
+ return StoryArcDiscoveryRead.model_validate_json(
+ await self._get_payload(key, loader, cache_result=True)
+ )
+
+ async def _get_payload(
+ self,
+ key: str,
+ loader: Callable[[], Awaitable[SeriesDiscoveryRead | StoryArcDiscoveryRead]],
+ *,
+ cache_result: bool,
+ ) -> bytes:
+ for expired in [key for key, (until, _) in self._entries.items() if until <= self.clock()]:
+ self._discard(expired)
+ cached = self._entries.get(key) if cache_result else None
+ if cached is not None:
+ self._entries.move_to_end(key)
+ return cached[1]
+ flight = self._pending.get(key)
+ if flight is not None and flight.task.cancelling():
+ raise MetadataSearchBusyError("The previous search is stopping. Try again shortly.")
+ if flight is None:
+ if len(self._pending) >= self.max_pending:
+ raise MetadataSearchBusyError(
+ "Other metadata searches are running. Try again shortly."
+ )
+ flight = _Flight(
+ asyncio.create_task(self._load(key, loader, cache_result=cache_result))
+ )
+ self._pending[key] = flight
+ flight.waiters += 1
+ try:
+ payload = await asyncio.shield(flight.task)
+ return payload
+ finally:
+ flight.waiters -= 1
+ if not flight.waiters:
+ if not flight.task.done():
+ flight.task.cancel()
+ try:
+ await asyncio.gather(flight.task, return_exceptions=True)
+ finally:
+ if self._pending.get(key) is flight:
+ self._pending.pop(key)
diff --git a/src/pullbox/services/metadata_series_add.py b/src/pullbox/services/metadata_series_add.py
new file mode 100644
index 00000000..f301984b
--- /dev/null
+++ b/src/pullbox/services/metadata_series_add.py
@@ -0,0 +1,94 @@
+"""Transaction-owned Add Series command for a server-fetched source catalog."""
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from dataclasses import replace
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.events import EventBus, SeriesAdded
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind
+from pullbox.services.cover_cache_service import purge_series_cover_cache
+from pullbox.services.metadata_series_adoption import (
+ SeriesAdoptionResult,
+ SourceSeriesBundle,
+ adopt_source_series_bundle,
+ persist_adoption_series_baseline,
+)
+from pullbox.services.metadata_service import (
+ MetadataService,
+ classify_issue_metadata,
+ infer_series_type_from_issue_evidence,
+)
+from pullbox.services.series_service import (
+ SeriesFolderCreationResult,
+ SeriesService,
+ rollback_series_folder_creation,
+)
+
+
+@asynccontextmanager
+async def source_series_add_transaction(
+ session: AsyncSession,
+ bundle: SourceSeriesBundle,
+ *,
+ library_root_id: int | None,
+ search_on_add: bool,
+ event_bus: EventBus,
+) -> AsyncIterator[SeriesAdoptionResult]:
+ """Own this add transaction, including response construction before commit.
+
+ The caller releases its read snapshot before fetching the bundle. This is an
+ explicit lifecycle boundary, not a flush-only service or a nested transaction.
+ Only newly created directories are eligible for rollback cleanup.
+ """
+ if session.in_transaction():
+ raise ValueError("Source Add requires a session without an active transaction")
+ folder: SeriesFolderCreationResult | None = None
+ try:
+ async with session.begin():
+ result = await adopt_source_series_bundle(session, bundle, monitored=search_on_add)
+ if result.created:
+ series = result.series
+ infer_series_type_from_issue_evidence(
+ series,
+ [
+ classify_issue_metadata(series.series_type, issue.title)[0]
+ for issue in bundle.issues
+ ],
+ )
+ await MetadataService.classify_and_link_series(
+ session,
+ series,
+ preserve_provider_type=bundle.series.series_type is not None,
+ )
+ if result.snapshot is not None:
+ result = replace(
+ result,
+ snapshot=await persist_adoption_series_baseline(
+ session, series, result.snapshot
+ ),
+ )
+ if library_root_id is not None:
+ folder = await SeriesService._create_series_folder(
+ session,
+ series,
+ library_root_id,
+ series.comicvine_id,
+ source_identity=ExternalIdentityRef(
+ bundle.series.identity_namespace,
+ MetadataEntityKind.SERIES,
+ bundle.series.external_id,
+ ),
+ )
+ await purge_series_cover_cache(session, series.id)
+ await session.flush()
+ yield result
+ except BaseException:
+ if folder is not None:
+ rollback_series_folder_creation(folder)
+ raise
+ if result.created:
+ await event_bus.emit(
+ SeriesAdded(series_id=result.series.id, comicvine_id=result.series.comicvine_id)
+ )
diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py
new file mode 100644
index 00000000..f3a01d4c
--- /dev/null
+++ b/src/pullbox/services/metadata_series_adoption.py
@@ -0,0 +1,716 @@
+"""Server-fetched source metadata adoption; never accepts browser metadata."""
+
+import asyncio
+import hashlib
+import json
+from copy import copy
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from uuid import UUID
+
+import structlog
+from sqlalchemy import select, tuple_
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.issue_numbers import parse_issue_number_text
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.core.metadata_identity_events import IdentityEventEvidence, IdentityEventRequest
+from pullbox.core.metadata_identity_state import IdentityVerificationAction
+from pullbox.core.naming import classify_series_type
+from pullbox.models import Issue, Series
+from pullbox.models.issue import IssueStatus
+from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.models.series import IssueCatalogState, SeriesStatus, SeriesType
+from pullbox.schemas.metadata_snapshot import (
+ FieldOrigin,
+ MetadataSnapshot,
+ MetadataValues,
+ field_domain,
+)
+from pullbox.schemas.metadata_sources import (
+ CatalogExcludedIssue,
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ SourceStatus,
+)
+from pullbox.services.metadata_assembly import assemble_metadata
+from pullbox.services.metadata_baselines import (
+ MetadataBaselineWrite,
+ load_metadata_baseline,
+ save_metadata_baselines,
+)
+from pullbox.services.metadata_catalog_checkpoints import (
+ CatalogCheckpointConflictError,
+ save_full_catalog_checkpoint,
+)
+from pullbox.services.metadata_credits import write_issue_credits
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_identity_attachment import attach_verified_identities
+from pullbox.services.metadata_identity_review import record_identity_observation
+from pullbox.services.metadata_series_artwork import with_representative_cover
+from pullbox.services.metadata_service import MetadataService, classify_issue_metadata
+from pullbox.services.metadata_source_reads import source_id
+from pullbox.services.metadata_sources import read_source_policies
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+logger = structlog.get_logger(__name__)
+
+
+def _metadata_label(source: MetadataSource) -> str:
+ # Existing refresh precedence depends on these legacy ComicVine labels.
+ return {
+ MetadataSource.COMICVINE_API: "comicvine",
+ MetadataSource.COMICVINE_LOCAL: "pullbox_catalog",
+ }.get(source, source.value)
+
+
+class SeriesAdoptionError(ValueError):
+ """A complete, consistent source catalog could not be adopted safely."""
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ status: SourceStatus | None = None,
+ retry_after_seconds: int | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.status = status
+ self.retry_after_seconds = retry_after_seconds
+
+
+class SeriesCatalogCountError(SeriesAdoptionError):
+ """Inconsistent counts make this source unusable, not an identity conflict."""
+
+
+@dataclass(frozen=True)
+class SourceIssueBatch:
+ source: MetadataSource
+ series_external_id: str
+ issues: tuple[ProviderIssueRead, ...]
+ source_revision: int
+
+
+@dataclass(frozen=True)
+class SourceSeriesBundle:
+ series: ProviderSeriesRead
+ issues: tuple[ProviderIssueRead, ...]
+ source_revision: int
+ catalog_total: int
+ catalog_started_at: datetime | None = None
+ excluded_issues: tuple[ProviderIssueRead, ...] = ()
+
+
+@dataclass(frozen=True)
+class SeriesAdoptionResult:
+ series: Series
+ created: bool
+ snapshot: MetadataSnapshot | None = None
+ issue_snapshots: tuple[MetadataSnapshot, ...] = ()
+
+
+async def fetch_source_series_bundle(
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ source_revision: int,
+ max_issues: int = 10000,
+ timeout: float = 120,
+ profile: ProviderSeriesRead | None = None,
+) -> SourceSeriesBundle:
+ identifier = source_id(source, MetadataEntityKind.SERIES, external_id)
+ runtime = registry.runtime.get(source)
+ if runtime is None or runtime.policy.revision != source_revision:
+ raise SeriesAdoptionError("Metadata source settings changed. Preview the series again.")
+ if type(max_issues) is not int or not 1 <= max_issues <= 10000 or not 0 < timeout <= 120:
+ raise ValueError("Invalid adoption resource limits")
+ if registry.revalidate_reads and registry.read_cache is not None:
+ # A coalesced response may have started before this fetch. Preserve the
+ # caller's cache and concurrency budget but read this full catalog live.
+ registry = copy(registry)
+ registry.read_cache = None
+ # Cached pages without revalidation cannot establish a new modification floor.
+ started_at = (
+ datetime.now(UTC) if registry.read_cache is None or registry.revalidate_reads else None
+ )
+ try:
+ async with asyncio.timeout(timeout):
+ if profile is None:
+ result_profile = await registry.series(source, identifier)
+ if result_profile.status is not SourceStatus.OK or result_profile.data is None:
+ raise SeriesAdoptionError(
+ f"Could not read the series: {result_profile.status.value}.",
+ status=result_profile.status,
+ retry_after_seconds=result_profile.retry_after_seconds,
+ )
+ profile = result_profile.data
+ if (
+ profile.source is not source
+ or profile.identity_namespace is not source.identity_namespace
+ or profile.external_id != identifier
+ ):
+ raise SeriesAdoptionError(
+ "The series profile belongs to a different source identity."
+ )
+ if profile.issue_count is not None and profile.issue_count > max_issues:
+ raise SeriesAdoptionError("The issue catalog exceeds the interactive add limit.")
+ issues: list[ProviderIssueRead] = []
+ seen: set[str] = set()
+ page = 1
+ total: int | None = None
+ while True:
+ result = await registry.issues(source, identifier, page=page)
+ if result.status is not SourceStatus.OK or result.data is None:
+ raise SeriesAdoptionError(
+ f"Could not finish the issue catalog: {result.status.value}. "
+ "Retry the add.",
+ status=result.status,
+ retry_after_seconds=result.retry_after_seconds,
+ )
+ current = result.data
+ if total is None:
+ total = current.total
+ if current.total != total or (
+ profile.issue_count is not None and profile.issue_count != total
+ ):
+ raise SeriesCatalogCountError(
+ "The provider catalog count changed. Retry the add."
+ )
+ if total > max_issues:
+ raise SeriesAdoptionError(
+ "The issue catalog exceeds the interactive add limit."
+ )
+ if page == 1:
+ profile = with_representative_cover(profile, current.results)
+ for issue in current.results:
+ if source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL} and (
+ profile.source_updated_at is None
+ or issue.source_updated_at != profile.source_updated_at
+ ):
+ raise SeriesAdoptionError("The local catalog changed. Retry the add.")
+ if issue.external_id in seen:
+ raise SeriesAdoptionError(
+ "The issue catalog repeats an identity. Retry the add."
+ )
+ seen.add(issue.external_id)
+ issues.append(issue)
+ if current.next_page is None:
+ if len(issues) != total:
+ raise SeriesAdoptionError("The issue catalog is incomplete. Retry the add.")
+ return SourceSeriesBundle(
+ profile, tuple(issues), source_revision, total, started_at
+ )
+ page = current.next_page
+ except TimeoutError as exc:
+ raise SeriesAdoptionError(
+ "The issue catalog request timed out. Retry the add.", status=SourceStatus.TIMEOUT
+ ) from exc
+
+
+async def adopt_source_series_bundle(
+ session: AsyncSession, bundle: SourceSeriesBundle, *, monitored: bool = False
+) -> SeriesAdoptionResult:
+ """Persist a server-owned bundle, leaving transactions and side effects to the caller.
+
+ Add is not refresh: an existing exact owner keeps its metadata, issue catalog,
+ monitoring and files. Crosswalk issue IDs remain observations until their
+ foreign parent has been verified independently.
+ """
+ numbers = _validate_bundle(bundle)
+ try:
+ async with metadata_write_scope(session):
+ config = await session.scalar(
+ select(MetadataSourceConfig)
+ .where(MetadataSourceConfig.source == bundle.series.source.value)
+ .with_for_update(read=True)
+ .execution_options(populate_existing=True)
+ )
+ if config is None or not config.enabled or config.revision != bundle.source_revision:
+ raise SeriesAdoptionError(
+ "Metadata source settings changed. Preview the series again."
+ )
+ return await _adopt(session, bundle, numbers, monitored=monitored)
+ except CatalogCheckpointConflictError as exc:
+ raise SeriesAdoptionError(
+ "Catalog progress could not be saved safely. Preview the series again."
+ ) from exc
+ except (ValidationError, IntegrityError) as exc:
+ raise SeriesAdoptionError(
+ "Metadata identity conflicts with an existing match. Review the match before retrying."
+ ) from exc
+
+
+def _identities(
+ metadata: ProviderSeriesRead | ProviderIssueRead, kind: MetadataEntityKind
+) -> tuple[ExternalIdentityRef, ...]:
+ native = ExternalIdentityRef(metadata.source.identity_namespace, kind, metadata.external_id)
+ if (
+ native.namespace != metadata.identity_namespace
+ or native.external_id != metadata.external_id
+ ):
+ raise SeriesAdoptionError("Metadata does not match the selected source identity.")
+ identities = {native.namespace: native}
+ for crosswalk in metadata.cross_identities:
+ if crosswalk.entity_kind is not kind or (
+ crosswalk.namespace in identities and identities[crosswalk.namespace] != crosswalk
+ ):
+ raise SeriesAdoptionError(
+ "Provider identities disagree. Review the match before retrying."
+ )
+ identities[crosswalk.namespace] = crosswalk
+ for identity in identities.values():
+ if identity.namespace is IdentityNamespace.COMICVINE and int(identity.external_id) >= 2**63:
+ raise SeriesAdoptionError("ComicVine identity exceeds the supported range.")
+ return tuple(identities.values())
+
+
+def _validate_bundle(bundle: SourceSeriesBundle) -> list[tuple[float, str]]:
+ profile = bundle.series
+ _identities(profile, MetadataEntityKind.SERIES)
+ catalog = (*bundle.issues, *bundle.excluded_issues)
+ if (
+ not profile.title.strip()
+ or len(profile.title) > 500
+ or len(catalog) > 10000
+ or type(bundle.catalog_total) is not int
+ or bundle.catalog_total != len(catalog)
+ or (profile.issue_count is not None and profile.issue_count != len(catalog))
+ ):
+ raise SeriesAdoptionError("The series profile or issue catalog is incomplete.")
+ if bundle.excluded_issues:
+ if profile.source is not MetadataSource.GCD_LOCAL or not bundle.issues:
+ raise SeriesAdoptionError("This catalog cannot be added with exclusions.")
+ for issue in bundle.excluded_issues:
+ try:
+ parse_issue_number_text(issue.issue_number_text)
+ except ValueError:
+ continue
+ raise SeriesAdoptionError("Supported issues cannot be omitted by catalog review.")
+ _validate_issue_batch(
+ SourceIssueBatch(profile.source, profile.external_id, catalog, bundle.source_revision),
+ allow_unsupported=True,
+ )
+ return _validate_issue_batch(
+ SourceIssueBatch(profile.source, profile.external_id, bundle.issues, bundle.source_revision)
+ )
+
+
+def _validate_issue_batch(
+ batch: SourceIssueBatch, *, allow_unsupported: bool = False
+) -> list[tuple[float, str]]:
+ if (
+ type(batch.source_revision) is not int
+ or batch.source_revision <= 0
+ or len(batch.issues) > 10000
+ ):
+ raise SeriesAdoptionError("Invalid catalog revision or issue batch size.")
+ numbers = []
+ seen_numbers: set[str] = set()
+ seen_ids: set[ExternalIdentityRef] = set()
+ for issue in batch.issues:
+ if issue.source is not batch.source or issue.series_external_id != batch.series_external_id:
+ raise SeriesAdoptionError("An issue belongs to a different source or series.")
+ for identity in _identities(issue, MetadataEntityKind.ISSUE):
+ if identity in seen_ids:
+ raise SeriesAdoptionError("The issue catalog repeats an identity.")
+ seen_ids.add(identity)
+ try:
+ number = parse_issue_number_text(issue.issue_number_text)
+ except ValueError as exc:
+ if allow_unsupported and batch.source is MetadataSource.GCD_LOCAL:
+ continue
+ raise SeriesAdoptionError(
+ "This catalog contains an unsupported issue designation; no issues were added."
+ ) from exc
+ if number[1] in seen_numbers:
+ raise SeriesAdoptionError(
+ "Multiple provider issues use the same designation. Review the catalog."
+ )
+ seen_numbers.add(number[1])
+ numbers.append(number)
+ return numbers
+
+
+def _request(
+ bundle: SourceSeriesBundle | SourceIssueBatch,
+ metadata: ProviderSeriesRead | ProviderIssueRead,
+ identity: ExternalIdentityRef,
+ local_id: int,
+ *,
+ observation: bool = False,
+) -> IdentityEventRequest:
+ native = ExternalIdentityRef(
+ metadata.identity_namespace, identity.entity_kind, metadata.external_id
+ )
+ # Content digest for evidence/retry identity, never an authentication token.
+ revision = hashlib.sha256(
+ json.dumps(
+ {
+ "source_revision": bundle.source_revision,
+ "metadata": metadata.model_dump(mode="json"),
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode()
+ ).hexdigest()
+ parent = (
+ ExternalIdentityRef(
+ metadata.identity_namespace, MetadataEntityKind.SERIES, metadata.series_external_id
+ )
+ if isinstance(metadata, ProviderIssueRead) and identity == native
+ else None
+ )
+ return IdentityEventRequest(
+ UUID(hex=revision[:32]),
+ local_id,
+ IdentityVerificationAction.OBSERVE if observation else IdentityVerificationAction.VERIFY,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(
+ identity,
+ IdentityEvidenceKind.PROVIDER_RESULT
+ if identity == native
+ else IdentityEvidenceKind.PROVIDER_CROSSWALK,
+ metadata.source,
+ ),
+ revision,
+ source_identity=native,
+ parent_identity=parent,
+ ),
+ )
+
+
+async def _series_owner(
+ session: AsyncSession, identities: tuple[ExternalIdentityRef, ...]
+) -> Series | None:
+ owners = set(
+ await session.scalars(
+ select(SeriesExternalIdentity.series_id).where(
+ tuple_(
+ SeriesExternalIdentity.identity_namespace, SeriesExternalIdentity.external_id
+ ).in_([(identity.namespace, identity.external_id) for identity in identities])
+ )
+ )
+ )
+ cv = next((item for item in identities if item.namespace is IdentityNamespace.COMICVINE), None)
+ if cv is not None:
+ owners.update(
+ await session.scalars(
+ select(Series.id).where(Series.comicvine_id == int(cv.external_id))
+ )
+ )
+ if len(owners) > 1:
+ raise SeriesAdoptionError(
+ "Provider identities belong to different library series. Review the match."
+ )
+ if not owners:
+ return None
+ result = await session.execute(
+ select(Series).where(Series.id == next(iter(owners))).with_for_update()
+ )
+ return result.scalar_one_or_none()
+
+
+async def _require_unowned_issues(
+ session: AsyncSession, bundle: SourceSeriesBundle | SourceIssueBatch
+) -> None:
+ identities = [
+ identity
+ for issue in bundle.issues
+ for identity in _identities(issue, MetadataEntityKind.ISSUE)
+ ]
+ for offset in range(0, len(identities), 200):
+ batch = identities[offset : offset + 200]
+ owner = await session.scalar(
+ select(IssueExternalIdentity.issue_id)
+ .where(
+ tuple_(
+ IssueExternalIdentity.identity_namespace, IssueExternalIdentity.external_id
+ ).in_([(identity.namespace, identity.external_id) for identity in batch])
+ )
+ .limit(1)
+ )
+ cv_ids = [
+ int(item.external_id) for item in batch if item.namespace is IdentityNamespace.COMICVINE
+ ]
+ legacy = (
+ await session.scalar(select(Issue.id).where(Issue.comicvine_id.in_(cv_ids)).limit(1))
+ if cv_ids
+ else None
+ )
+ if owner is not None or legacy is not None:
+ raise SeriesAdoptionError(
+ "An issue already belongs to another library series. Review the match."
+ )
+
+
+async def _adopt(
+ session: AsyncSession,
+ bundle: SourceSeriesBundle,
+ numbers: list[tuple[float, str]],
+ *,
+ monitored: bool,
+) -> SeriesAdoptionResult:
+ profile = bundle.series
+ identities = _identities(profile, MetadataEntityKind.SERIES)
+ series = await _series_owner(session, identities)
+ created = series is None
+ snapshot = None
+ issue_snapshots: tuple[MetadataSnapshot, ...] = ()
+ if series is None:
+ await _require_unowned_issues(session, bundle)
+ policies = await read_source_policies(session)
+ now = datetime.now(UTC)
+ native_parent = ExternalIdentityRef(
+ profile.identity_namespace, MetadataEntityKind.SERIES, profile.external_id
+ )
+ try:
+ snapshot = assemble_metadata(
+ MetadataEntityKind.SERIES, identities, [profile], policies, now=now
+ )
+ issue_snapshots = tuple(
+ assemble_metadata(
+ MetadataEntityKind.ISSUE,
+ [
+ ExternalIdentityRef(
+ metadata.identity_namespace,
+ MetadataEntityKind.ISSUE,
+ metadata.external_id,
+ )
+ ],
+ [metadata.model_copy(update={"issue_number_text": text})],
+ policies,
+ now=now,
+ parent_identities=[native_parent],
+ )
+ for metadata, (_, text) in zip(bundle.issues, numbers, strict=True)
+ )
+ except ValueError as exc:
+ raise SeriesAdoptionError("The source metadata cannot be assembled safely.") from exc
+ values = snapshot.values
+ publisher_id = (
+ await MetadataService._ensure_publisher(session, values.publisher)
+ if values.publisher
+ else None
+ )
+ series = Series(
+ title=values.title,
+ sort_title=values.sort_title or values.title,
+ year_start=values.year_start,
+ year_end=values.year_end,
+ description=values.description,
+ cover_url=values.image_url,
+ status=SeriesStatus(profile.status)
+ if profile.status is not None and profile.status in SeriesStatus
+ else SeriesStatus.UNKNOWN,
+ series_type=SeriesType(profile.series_type)
+ if profile.series_type is not None and profile.series_type in SeriesType
+ else SeriesType(
+ classify_series_type(
+ profile.title,
+ description=profile.description,
+ issue_count=bundle.catalog_total,
+ year_start=profile.year_start,
+ )
+ ),
+ issue_count=len(bundle.issues),
+ catalog_exclusions=[
+ CatalogExcludedIssue(
+ source=MetadataSource.GCD_LOCAL,
+ series_external_id=issue.series_external_id,
+ external_id=issue.external_id,
+ issue_number_text=issue.issue_number_text,
+ ).model_dump(mode="json")
+ for issue in bundle.excluded_issues
+ ],
+ monitored=monitored,
+ publisher_id=publisher_id,
+ metadata_source=_metadata_label(profile.source),
+ metadata_last_refreshed=profile.source_updated_at or now,
+ issue_catalog_state=IssueCatalogState.COMPLETE,
+ issue_catalog_last_synced_at=now,
+ issue_catalog_last_checked_at=now,
+ comicvine_url=profile.resource_url
+ if profile.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
+ )
+ session.add(series)
+ await session.flush()
+ await attach_verified_identities(
+ session,
+ [_request(bundle, profile, identity, series.id) for identity in identities],
+ require_current_ownership=True,
+ )
+ await session.refresh(series, attribute_names=["comicvine_id"])
+ if not created:
+ logger.info(
+ "metadata_series_add_existing", series_id=series.id, source=profile.source.value
+ )
+ return SeriesAdoptionResult(series, False)
+ for offset in range(0, len(bundle.issues), 200):
+ members = []
+ for metadata, (number, text), issue_snapshot in zip(
+ bundle.issues[offset : offset + 200],
+ numbers[offset : offset + 200],
+ issue_snapshots[offset : offset + 200],
+ strict=True,
+ ):
+ issue_values = issue_snapshot.values
+ issue = Issue(
+ series_id=series.id,
+ issue_number=number,
+ issue_number_text=text,
+ title=issue_values.title,
+ description=issue_values.description,
+ release_date=issue_values.cover_date,
+ store_date=issue_values.store_date,
+ cover_url=issue_values.image_url,
+ page_count=issue_values.page_count,
+ status=IssueStatus.WANTED if monitored else IssueStatus.SKIPPED,
+ metadata_source=_metadata_label(metadata.source),
+ issue_type=classify_issue_metadata(series.series_type, metadata.title)[1],
+ comicvine_url=metadata.resource_url
+ if metadata.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
+ )
+ session.add(issue)
+ members.append((issue, metadata))
+ await session.flush()
+ await attach_verified_identities(
+ session,
+ [
+ _request(
+ bundle, metadata, _identities(metadata, MetadataEntityKind.ISSUE)[0], issue.id
+ )
+ for issue, metadata in members
+ ],
+ )
+ for issue, metadata in members:
+ for crosswalk in _identities(metadata, MetadataEntityKind.ISSUE)[1:]:
+ await record_identity_observation(
+ session, _request(bundle, metadata, crosswalk, issue.id, observation=True)
+ )
+ await write_issue_credits(
+ session,
+ {
+ issue.id: item.values.credits
+ for (issue, _), item in zip(
+ members, issue_snapshots[offset : offset + 200], strict=True
+ )
+ },
+ )
+ await save_metadata_baselines(
+ session,
+ [
+ MetadataBaselineWrite(issue.id, issue_snapshot)
+ for (issue, _), issue_snapshot in zip(
+ members, issue_snapshots[offset : offset + 200], strict=True
+ )
+ ],
+ )
+ await session.flush()
+ if profile.status is None:
+ await MetadataService.infer_series_status(session, series)
+ if snapshot is not None:
+ snapshot = await persist_adoption_series_baseline(session, series, snapshot)
+ if bundle.catalog_started_at is not None:
+ claim = await session.scalar(
+ select(SeriesExternalIdentity).where(
+ SeriesExternalIdentity.series_id == series.id,
+ SeriesExternalIdentity.identity_namespace == profile.identity_namespace,
+ )
+ )
+ assert claim is not None
+ await save_full_catalog_checkpoint(
+ session,
+ series.id,
+ source=profile.source,
+ source_revision=bundle.source_revision,
+ identity_revision=claim.revision,
+ external_id=profile.external_id,
+ started_at=bundle.catalog_started_at,
+ source_updated_at=profile.source_updated_at,
+ )
+ logger.info(
+ "metadata_series_added",
+ series_id=series.id,
+ source=profile.source.value,
+ issue_count=len(bundle.issues),
+ )
+ return SeriesAdoptionResult(series, True, snapshot, issue_snapshots)
+
+
+async def persist_adoption_series_baseline(
+ session: AsyncSession, series: Series, snapshot: MetadataSnapshot
+) -> MetadataSnapshot:
+ """Record Add's inferred defaults, not invented provider data or user edits."""
+ values = snapshot.values.model_dump()
+ origins = {origin.field: origin for origin in snapshot.origins}
+ now = datetime.now(UTC)
+ derived = {
+ "sort_title": FieldOrigin(
+ field="sort_title",
+ domain=field_domain(MetadataEntityKind.SERIES, "sort_title"),
+ observed_at=now,
+ derivation="normalization",
+ ),
+ "series_type": FieldOrigin(
+ field="series_type",
+ domain=field_domain(MetadataEntityKind.SERIES, "series_type"),
+ observed_at=now,
+ derivation="classification",
+ ),
+ "status": FieldOrigin(
+ field="status",
+ domain=field_domain(MetadataEntityKind.SERIES, "status"),
+ observed_at=now,
+ derivation="lifecycle",
+ ),
+ "year_end": FieldOrigin(
+ field="year_end",
+ domain=field_domain(MetadataEntityKind.SERIES, "year_end"),
+ observed_at=now,
+ derivation="lifecycle",
+ ),
+ "issue_count": FieldOrigin(
+ field="issue_count",
+ domain=field_domain(MetadataEntityKind.SERIES, "issue_count"),
+ observed_at=now,
+ derivation="catalog",
+ ),
+ }
+ actual = {
+ "sort_title": series.sort_title,
+ "series_type": series.series_type.value,
+ "status": series.status.value,
+ "year_end": series.year_end,
+ "issue_count": series.issue_count,
+ }
+ for field, value in actual.items():
+ if values[field] != value:
+ values[field] = value
+ origins[field] = derived[field]
+ snapshot = MetadataSnapshot.model_validate(
+ {
+ **snapshot.model_dump(),
+ "values": MetadataValues.model_validate(values),
+ "origins": tuple(origins.values()),
+ }
+ )
+ saved = await load_metadata_baseline(session, MetadataEntityKind.SERIES, series.id)
+ if saved is None or saved.snapshot != snapshot:
+ await save_metadata_baselines(
+ session, [MetadataBaselineWrite(series.id, snapshot, saved.revision if saved else 0)]
+ )
+ return snapshot
diff --git a/src/pullbox/services/metadata_series_artwork.py b/src/pullbox/services/metadata_series_artwork.py
new file mode 100644
index 00000000..a7a1268a
--- /dev/null
+++ b/src/pullbox/services/metadata_series_artwork.py
@@ -0,0 +1,38 @@
+"""Representative Metron series artwork from an already-read first issue page."""
+
+from collections.abc import Sequence
+
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.schemas.metadata_sources import ProviderIssueRead, ProviderSeriesRead
+from pullbox.services.provider_artwork import allowed_artwork_url
+
+
+def representative_series_cover(
+ source: MetadataSource, external_id: str, issues: Sequence[ProviderIssueRead]
+) -> str | None:
+ if source is not MetadataSource.METRON_API or len(issues) > 100:
+ return None
+ if any(
+ issue.source is not source
+ or issue.identity_namespace is not source.identity_namespace
+ or issue.series_external_id != external_id
+ for issue in issues
+ ):
+ return None
+ return next(
+ (
+ issue.image_url
+ for issue in issues
+ if issue.image_url and allowed_artwork_url(issue.image_url)
+ ),
+ None,
+ )
+
+
+def with_representative_cover(
+ profile: ProviderSeriesRead, issues: Sequence[ProviderIssueRead]
+) -> ProviderSeriesRead:
+ if profile.image_url:
+ return profile
+ cover = representative_series_cover(profile.source, profile.external_id, issues)
+ return profile.model_copy(update={"image_url": cover}) if cover else profile
diff --git a/src/pullbox/services/metadata_series_preview.py b/src/pullbox/services/metadata_series_preview.py
new file mode 100644
index 00000000..1a84556a
--- /dev/null
+++ b/src/pullbox/services/metadata_series_preview.py
@@ -0,0 +1,95 @@
+"""Source-specific series previews; returned metadata is not an adoption proof."""
+
+import asyncio
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.library_naming import build_series_relative_path
+from pullbox.core.library_policy import load_effective_library_ingest_policy
+from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind, MetadataSource
+from pullbox.core.naming import classify_series_type
+from pullbox.models.library import LibraryRoot
+from pullbox.models.publisher import Publisher
+from pullbox.models.series import Series, SeriesType
+from pullbox.schemas.metadata_sources import (
+ MetadataFetch,
+ MetadataPage,
+ ProviderSeriesRead,
+ SeriesPreviewRead,
+ SourceStatus,
+)
+from pullbox.services.metadata_catalog_review import catalog_review
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_series_adoption import fetch_source_series_bundle
+from pullbox.services.metadata_series_artwork import with_representative_cover
+from pullbox.services.metadata_source_reads import source_id
+
+
+async def preview_series_folder(
+ session: AsyncSession, profile: ProviderSeriesRead, root: LibraryRoot
+) -> str:
+ """Render a naming suggestion without adopting metadata or touching the filesystem."""
+ policy = await load_effective_library_ingest_policy(session, root)
+ series = Series(
+ title=profile.title,
+ year_start=profile.year_start,
+ publisher=Publisher(name=profile.publisher) if profile.publisher else None,
+ comicvine_id=int(profile.external_id)
+ if profile.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
+ series_type=SeriesType(profile.series_type)
+ if profile.series_type is not None and profile.series_type in SeriesType
+ else SeriesType(
+ classify_series_type(
+ profile.title,
+ description=profile.description,
+ issue_count=profile.issue_count or 0,
+ year_start=profile.year_start,
+ )
+ ),
+ )
+ return build_series_relative_path(series, policy).as_posix()
+
+
+async def preview_source_series(
+ registry: MetadataSourceRegistry, source: MetadataSource, external_id: str
+) -> SeriesPreviewRead:
+ identifier = source_id(source, MetadataEntityKind.SERIES, external_id)
+ runtime = registry.runtime.get(source)
+ preview = SeriesPreviewRead(
+ source=source,
+ external_id=identifier,
+ source_revision=runtime.policy.revision if runtime else 0,
+ series=MetadataFetch(status=SourceStatus.NOT_QUERIED),
+ issues=MetadataFetch(status=SourceStatus.NOT_QUERIED),
+ )
+ try:
+ if source is MetadataSource.GCD_LOCAL:
+ bundle = await fetch_source_series_bundle(
+ registry, source, identifier, source_revision=preview.source_revision
+ )
+ preview.series = MetadataFetch(status=SourceStatus.OK, data=bundle.series)
+ preview.issues = MetadataFetch(
+ status=SourceStatus.OK,
+ data=MetadataPage(
+ results=list(bundle.issues[:100]),
+ total=bundle.catalog_total,
+ next_page=2 if len(bundle.issues) > 100 else None,
+ ),
+ )
+ preview.catalog_review = catalog_review(bundle)
+ return preview
+ async with asyncio.timeout(registry.total_timeout):
+ preview.series = await registry.series(source, identifier)
+ if preview.series.status is SourceStatus.OK:
+ preview.issues = await registry.issues(source, identifier)
+ if preview.series.data is not None and preview.issues.data is not None:
+ preview.series.data = with_representative_cover(
+ preview.series.data, preview.issues.data.results
+ )
+ except TimeoutError:
+ if preview.series.status is SourceStatus.NOT_QUERIED:
+ preview.series = MetadataFetch(status=SourceStatus.TIMEOUT)
+ else:
+ preview.issues = MetadataFetch(status=SourceStatus.TIMEOUT)
+ return preview
diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py
new file mode 100644
index 00000000..0e0a7859
--- /dev/null
+++ b/src/pullbox/services/metadata_series_refresh.py
@@ -0,0 +1,451 @@
+"""Source-aware series refresh: short read, bounded fetch, revision-checked write."""
+
+import asyncio
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from dataclasses import dataclass, replace
+from datetime import UTC, datetime
+from pathlib import Path
+
+import structlog
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError, SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.config import get_settings
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.models import Issue, Series
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.models.series import IssueCatalogState
+from pullbox.schemas.metadata_snapshot import FieldOrigin, MetadataSnapshot, field_domain
+from pullbox.schemas.metadata_sources import (
+ MetadataDomain,
+ ProviderSeriesRead,
+ SourceCapability,
+ SourceOutcome,
+ SourceStatus,
+)
+from pullbox.services.cover_resolver import resolve_covers_dir
+from pullbox.services.metadata_assembly import assemble_metadata
+from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines
+from pullbox.services.metadata_catalog_checkpoints import save_full_catalog_checkpoint
+from pullbox.services.metadata_catalog_review import apply_catalog_exclusions
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_entity_values import apply_series_metadata_values
+from pullbox.services.metadata_issue_catalog import (
+ IssueCatalogConflictError,
+ SourceIssueBatch,
+ apply_issue_batch,
+)
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_refresh_snapshot import fetch_metadata_snapshot
+from pullbox.services.metadata_series_adoption import (
+ SeriesAdoptionError,
+ SeriesCatalogCountError,
+ SourceSeriesBundle,
+ _validate_bundle,
+ fetch_source_series_bundle,
+)
+from pullbox.services.metadata_series_artwork import with_representative_cover
+from pullbox.services.metadata_series_refresh_state import (
+ SERIES_FIELDS,
+ SeriesRefreshState,
+ read_series_refresh_state,
+)
+from pullbox.services.metadata_service import MetadataService
+from pullbox.services.metadata_sources import load_source_runtime
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+from pullbox.services.provider_artwork import ProviderArtworkClient, pending_provider_cover
+
+logger = structlog.get_logger(__name__)
+
+
+class SeriesRefreshError(ValueError):
+ """The refresh requires a new read or an explicit identity decision."""
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ outcomes: tuple[SourceOutcome, ...] = (),
+ retry_after_seconds: int | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.outcomes = outcomes
+ self.retry_after_seconds = retry_after_seconds
+
+
+@dataclass(frozen=True)
+class SeriesCatalogRefresh:
+ series: Series
+ created_issue_ids: tuple[int, ...]
+ outcomes: tuple[SourceOutcome, ...] = ()
+
+
+@asynccontextmanager
+async def source_series_refresh_transaction(
+ session: AsyncSession, series_id: int
+) -> AsyncIterator[Series]:
+ """Serialize the response before commit, then refresh artwork outside the write."""
+ if session.new or session.dirty or session.deleted:
+ raise SeriesRefreshError("Finish pending library changes before refreshing metadata.")
+ covers = await resolve_covers_dir(session)
+ try:
+ series = await refresh_series_from_sources(session, series_id)
+ cover_url = series.cover_url
+ yield series
+ await session.commit()
+ except BaseException:
+ await session.rollback()
+ raise
+ if cover_url:
+ await refresh_series_artwork(session, series_id, cover_url, covers)
+
+
+async def refresh_series_artwork(
+ session: AsyncSession, series_id: int, url: str, covers: Path
+) -> None:
+ """Best-effort optional cache work after the caller commits metadata."""
+ try:
+ await _refresh_artwork(session, series_id, url, covers)
+ except (OSError, SQLAlchemyError) as exc:
+ logger.warning(
+ "metadata_series_artwork_refresh_failed",
+ series_id=series_id,
+ error_type=type(exc).__name__,
+ )
+
+
+async def _refresh_artwork(session: AsyncSession, series_id: int, url: str, covers: Path) -> None:
+ destination = covers / str(series_id) / "series.jpg"
+ async with (
+ ProviderArtworkClient() as client,
+ pending_provider_cover(client, url, destination) as pending,
+ ):
+ if pending is None:
+ return
+ async with session.begin():
+ # A different refresh/delete may have won while the image downloaded.
+ current = await session.scalar(
+ select(Series)
+ .where(Series.id == series_id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if current is not None and current.cover_url == url:
+ pending.replace(destination)
+ current.cover_path = f"/api/v1/series/{series_id}/cover"
+
+
+async def refresh_series_from_sources(
+ session: AsyncSession,
+ series_id: int,
+ *,
+ registry: MetadataSourceRegistry | None = None,
+ replace_managed: bool = True,
+) -> Series:
+ """Refresh metadata through the canonical writer, preserving the public result."""
+ result = await refresh_series_catalog_from_sources(
+ session, series_id, registry=registry, replace_managed=replace_managed
+ )
+ return result.series
+
+
+async def refresh_series_catalog_from_sources(
+ session: AsyncSession,
+ series_id: int,
+ *,
+ registry: MetadataSourceRegistry | None = None,
+ replace_managed: bool = True,
+) -> SeriesCatalogRefresh:
+ """Release only a clean read transaction; leave the atomic write to the caller.
+
+ No library/archive files are modified. The caller owns response construction,
+ commit, and post-commit cover work. Never call from a pending edit transaction.
+ """
+ if session.new or session.dirty or session.deleted:
+ raise SeriesRefreshError("Finish pending library changes before refreshing metadata.")
+ try:
+ before = await read_series_refresh_state(session, series_id)
+ if not before.series.identities:
+ raise SeriesRefreshError(
+ "This series needs a verified metadata identity before refresh. Review its match."
+ )
+ if registry is None:
+ enabled = get_settings().metadata_gcd_api_v2_enabled
+ runtime = await load_source_runtime(session, gcd_api_enabled=enabled)
+ registry = MetadataSourceRegistry(
+ runtime,
+ gcd_api_enabled=enabled,
+ read_cache=source_read_cache(session),
+ revalidate_reads=True,
+ total_timeout=60,
+ )
+ else:
+ registry = MetadataSourceRegistry(
+ list(registry.runtime.values()),
+ factories=registry.factories,
+ gcd_api_enabled=registry.gcd_api_enabled,
+ read_cache=registry.read_cache,
+ revalidate_reads=True,
+ per_source_timeout=registry.per_source_timeout,
+ total_timeout=registry.total_timeout,
+ concurrency=registry.concurrency,
+ )
+ await session.rollback()
+ now = datetime.now(UTC)
+ async with asyncio.timeout(120):
+ fetched = await fetch_metadata_snapshot(
+ registry,
+ MetadataEntityKind.SERIES,
+ before.series.identities,
+ now=now,
+ requested_fields=SERIES_FIELDS,
+ current=before.series.values,
+ previous=before.series.baseline,
+ overrides=before.series.overrides,
+ replace_managed=replace_managed,
+ )
+ profiles = {
+ item.source: item
+ for item in fetched.candidates
+ if isinstance(item, ProviderSeriesRead)
+ }
+ failed = {
+ item.source: item
+ for item in fetched.outcomes
+ if item.status not in {SourceStatus.OK, SourceStatus.NOT_QUERIED}
+ }
+ bundle = await _catalog(registry, before, profiles, failed)
+ profile = profiles.get(MetadataSource.METRON_API)
+ if (
+ profile is not None
+ and not profile.image_url
+ and bundle.series.source is not profile.source
+ ):
+ page = await registry.issues(profile.source, profile.external_id)
+ if page.status is SourceStatus.OK and page.data is not None:
+ profiles[profile.source] = with_representative_cover(profile, page.data.results)
+ profiles[bundle.series.source] = bundle.series
+ snapshot = assemble_metadata(
+ MetadataEntityKind.SERIES,
+ before.series.identities,
+ list(profiles.values()),
+ before.policies,
+ now=now,
+ current=before.series.values,
+ previous=before.series.baseline,
+ overrides=before.series.overrides,
+ replace_managed=replace_managed,
+ fields=SERIES_FIELDS,
+ )
+ async with metadata_write_scope(session):
+ # Source policy writers never take entity locks. Lock policies first,
+ # followed by the same parent-first graph order as identity writers.
+ await session.execute(
+ select(MetadataSourceConfig)
+ .order_by(MetadataSourceConfig.source)
+ .with_for_update(read=True)
+ )
+ await session.execute(select(Series.id).where(Series.id == series_id).with_for_update())
+ await session.execute(
+ select(Issue.id)
+ .where(Issue.series_id == series_id)
+ .order_by(Issue.id)
+ .with_for_update()
+ )
+ current = await read_series_refresh_state(session, series_id)
+ if current != before:
+ raise SeriesRefreshError(
+ "Library metadata, identities or source settings changed. Retry the refresh."
+ )
+ result = await _apply(
+ session, current, bundle, snapshot, now, replace_managed=replace_managed
+ )
+ if bundle.catalog_started_at is not None:
+ checkpoint = next(
+ (item for item in current.checkpoints if item.source is bundle.series.source),
+ None,
+ )
+ identity_revision = next(
+ revision
+ for ref, _, revision in current.series.claims
+ if ref.namespace is bundle.series.identity_namespace
+ and ref.external_id == bundle.series.external_id
+ )
+ await save_full_catalog_checkpoint(
+ session,
+ result.series.id,
+ source=bundle.series.source,
+ source_revision=bundle.source_revision,
+ identity_revision=identity_revision,
+ external_id=bundle.series.external_id,
+ started_at=bundle.catalog_started_at,
+ source_updated_at=bundle.series.source_updated_at,
+ expected_revision=checkpoint.revision if checkpoint else 0,
+ )
+ await session.flush()
+ logger.info(
+ "metadata_series_source_refreshed",
+ series_id=series_id,
+ source=bundle.series.source.value,
+ catalog_count=bundle.catalog_total,
+ )
+ return replace(result, outcomes=tuple(failed.values()))
+ except SeriesRefreshError:
+ raise
+ except IssueCatalogConflictError as exc:
+ raise SeriesRefreshError(str(exc)) from exc
+ except (ValueError, IntegrityError, ValidationError) as exc:
+ raise SeriesRefreshError(
+ "Metadata could not be refreshed safely. Review the series match "
+ "or retry after source settings are corrected."
+ ) from exc
+ except TimeoutError as exc:
+ raise SeriesRefreshError(
+ "Metadata refresh timed out. No library metadata was changed; retry later.",
+ retry_after_seconds=300,
+ ) from exc
+
+
+async def _catalog(
+ registry: MetadataSourceRegistry,
+ state: SeriesRefreshState,
+ profiles: dict[MetadataSource, ProviderSeriesRead],
+ failed: dict[MetadataSource, SourceOutcome],
+) -> SourceSeriesBundle:
+ known = {item.namespace: item.external_id for item in state.series.identities}
+ inconsistent_counts = False
+ catalog_namespaces = {
+ identity.namespace for issue in state.issues for identity in issue.identities
+ }
+ sources = sorted(
+ registry.runtime,
+ key=lambda source: (
+ registry.runtime[source].policy.domain_priorities.get(
+ MetadataDomain.ISSUES, registry.runtime[source].policy.priority
+ ),
+ source.value,
+ ),
+ )
+ for source in sources:
+ if (
+ source in failed
+ or source.identity_namespace not in known
+ # A series link authorizes descriptive enrichment, not issue rematching.
+ or (catalog_namespaces and source.identity_namespace not in catalog_namespaces)
+ or registry._unavailable(source, capability=SourceCapability.ISSUE_LIST)
+ ):
+ continue
+ try:
+ bundle = await fetch_source_series_bundle(
+ registry,
+ source,
+ known[source.identity_namespace],
+ source_revision=registry.runtime[source].policy.revision,
+ profile=profiles.get(source),
+ )
+ bundle = apply_catalog_exclusions(bundle, state.catalog_exclusions)
+ _validate_bundle(bundle)
+ return bundle
+ except SeriesCatalogCountError:
+ # Do not accept an incomplete catalog or mistake a stale summary for
+ # an identity dispute. Another eligible source must pass every check.
+ inconsistent_counts = True
+ failed[source] = SourceOutcome(source=source, status=SourceStatus.INCOMPATIBLE_RESPONSE)
+ logger.info(
+ "metadata_series_catalog_count_mismatch",
+ series_id=state.series.local_id,
+ source=source.value,
+ )
+ except SeriesAdoptionError as exc:
+ if exc.status is None or exc.status is SourceStatus.INCOMPATIBLE_RESPONSE:
+ raise SeriesRefreshError(
+ "The provider issue catalog changed or disagrees with this series. "
+ "Review the issue match before retrying."
+ ) from exc
+ logger.info(
+ "metadata_series_catalog_unavailable", source=source.value, status=exc.status.value
+ )
+ failed[source] = SourceOutcome(
+ source=source, status=exc.status, retry_after_seconds=exc.retry_after_seconds
+ )
+ if inconsistent_counts:
+ raise SeriesRefreshError(
+ "A metadata source reported inconsistent issue counts, and no configured source "
+ "could supply a complete catalog. Check source availability in Metadata settings "
+ "or retry later. Existing issue matches and files were kept.",
+ outcomes=tuple(failed.values()),
+ )
+ raise SeriesRefreshError(
+ "No configured source could supply a complete issue catalog. "
+ "Check source status and retry.",
+ outcomes=tuple(failed.values()),
+ )
+
+
+def _with_derived(
+ snapshot: MetadataSnapshot, field: str, value: object, now: datetime
+) -> MetadataSnapshot:
+ if getattr(snapshot.values, field) == value:
+ return snapshot
+ origins = {item.field: item for item in snapshot.origins}
+ origins[field] = FieldOrigin(
+ field=field,
+ domain=field_domain(snapshot.entity_kind, field),
+ observed_at=now,
+ derivation="catalog" if field == "issue_count" else "lifecycle",
+ )
+ return MetadataSnapshot.model_validate(
+ {
+ **snapshot.model_dump(),
+ "values": {**snapshot.values.model_dump(), field: value},
+ "origins": tuple(origins.values()),
+ }
+ )
+
+
+async def _apply(
+ session: AsyncSession,
+ state: SeriesRefreshState,
+ bundle: SourceSeriesBundle,
+ snapshot: MetadataSnapshot,
+ now: datetime,
+ *,
+ replace_managed: bool,
+) -> SeriesCatalogRefresh:
+ series = await session.get(Series, state.series.local_id)
+ assert series is not None
+ await apply_series_metadata_values(session, series, snapshot.values)
+ created = await apply_issue_batch(
+ session,
+ state,
+ SourceIssueBatch(
+ bundle.series.source, bundle.series.external_id, bundle.issues, bundle.source_revision
+ ),
+ now,
+ complete=True,
+ replace_managed=replace_managed,
+ )
+ series.issue_count = len(state.issues) + len(created)
+ snapshot = _with_derived(snapshot, "issue_count", series.issue_count, now)
+ origins = {item.field: item for item in snapshot.origins}
+ status_origin = origins.get("status")
+ if (
+ status_origin is not None
+ and status_origin.derivation == "lifecycle"
+ and not series.status_override
+ ):
+ await MetadataService.infer_series_status(session, series)
+ snapshot = _with_derived(snapshot, "status", series.status.value, now)
+ snapshot = _with_derived(snapshot, "year_end", series.year_end, now)
+ series.metadata_last_refreshed = now
+ series.issue_catalog_state = IssueCatalogState.COMPLETE
+ series.issue_catalog_last_synced_at = now
+ series.issue_catalog_last_checked_at = now
+ series.issue_catalog_error = None
+ await save_metadata_baselines(
+ session, [MetadataBaselineWrite(series.id, snapshot, state.series.baseline_revision)]
+ )
+ return SeriesCatalogRefresh(series, created)
diff --git a/src/pullbox/services/metadata_series_refresh_state.py b/src/pullbox/services/metadata_series_refresh_state.py
new file mode 100644
index 00000000..222a727f
--- /dev/null
+++ b/src/pullbox/services/metadata_series_refresh_state.py
@@ -0,0 +1,286 @@
+"""Bounded, immutable library read set for source-aware refresh."""
+
+from dataclasses import dataclass
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import NotFoundError
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.models import Issue, Series
+from pullbox.models.metadata_baseline import IssueMetadataBaseline, SeriesMetadataBaseline
+from pullbox.models.metadata_identity import (
+ IssueExternalIdentity,
+ IssueIdentityEvent,
+ SeriesExternalIdentity,
+ SeriesIdentityEvent,
+)
+from pullbox.models.publisher import Publisher
+from pullbox.models.series import IssueCatalogState
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues
+from pullbox.schemas.metadata_sources import CatalogExcludedIssue, SourcePolicyRead
+from pullbox.services.metadata_baselines import MetadataBaselineConflictError
+from pullbox.services.metadata_catalog_checkpoints import (
+ CatalogCheckpoint,
+ read_catalog_checkpoints,
+)
+from pullbox.services.metadata_credits import read_issue_credits
+from pullbox.services.metadata_sources import read_source_policies
+
+SERIES_FIELDS = frozenset(
+ {
+ "title",
+ "sort_title",
+ "publisher",
+ "description",
+ "year_start",
+ "year_end",
+ "series_type",
+ "status",
+ "issue_count",
+ "image_url",
+ "volume",
+ "language",
+ }
+)
+ISSUE_FIELDS = frozenset(
+ {
+ "title",
+ "description",
+ "issue_number_text",
+ "cover_date",
+ "store_date",
+ "page_count",
+ "image_url",
+ "credits",
+ }
+)
+
+
+@dataclass(frozen=True)
+class RefreshEntityState:
+ local_id: int
+ values: MetadataValues
+ baseline: MetadataSnapshot | None
+ baseline_revision: int
+ claims: tuple[tuple[ExternalIdentityRef, IdentityVerificationState | None, int], ...]
+ event_id: int
+ comicvine_id: int | None
+ overrides: frozenset[str] = frozenset()
+
+ @property
+ def identities(self) -> tuple[ExternalIdentityRef, ...]:
+ return tuple(
+ ref for ref, state, _ in self.claims if state is IdentityVerificationState.VERIFIED
+ )
+
+
+@dataclass(frozen=True)
+class SeriesRefreshState:
+ series: RefreshEntityState
+ issues: tuple[RefreshEntityState, ...]
+ policies: tuple[SourcePolicyRead, ...]
+ checkpoints: tuple[CatalogCheckpoint, ...] = ()
+ catalog_exclusions: tuple[CatalogExcludedIssue, ...] = ()
+
+
+def _baseline(payload: str | None, kind: MetadataEntityKind) -> MetadataSnapshot | None:
+ if payload is None:
+ return None
+ try:
+ snapshot = MetadataSnapshot.model_validate_json(payload)
+ if snapshot.entity_kind is not kind:
+ raise ValueError("Wrong baseline kind")
+ return snapshot
+ except ValueError as exc:
+ raise MetadataBaselineConflictError(
+ "Stored metadata baseline is invalid. Review before refreshing."
+ ) from exc
+
+
+async def read_series_refresh_state(
+ session: AsyncSession,
+ series_id: int,
+ *,
+ issue_ids: tuple[int, ...] | None = None,
+ allow_partial_catalog: bool = False,
+ include_issues: bool = True,
+) -> SeriesRefreshState:
+ """Capture a catalog or a bounded exact subset using the same value mapping."""
+ if allow_partial_catalog and issue_ids is None:
+ raise ValueError("Partial catalog reads require an exact bounded issue selection.")
+ if issue_ids is not None and (
+ not issue_ids
+ or len(issue_ids) > 200
+ or len(set(issue_ids)) != len(issue_ids)
+ or any(
+ isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in issue_ids
+ )
+ ):
+ raise ValueError("Choose between one and 200 distinct positive issue IDs.")
+ issue_filter = (
+ Issue.series_id == series_id
+ if issue_ids is None
+ else (Issue.series_id == series_id) & Issue.id.in_(issue_ids)
+ )
+ row = (
+ await session.execute(
+ select(
+ Series,
+ Publisher.name,
+ SeriesMetadataBaseline.revision,
+ SeriesMetadataBaseline.snapshot_json,
+ )
+ .outerjoin(Publisher, Publisher.id == Series.publisher_id)
+ .outerjoin(SeriesMetadataBaseline, SeriesMetadataBaseline.series_id == Series.id)
+ .where(Series.id == series_id)
+ .execution_options(populate_existing=True)
+ )
+ ).one_or_none()
+ if row is None:
+ raise NotFoundError("Series", series_id)
+ series, publisher, revision, payload = row
+ if series.issue_catalog_state is IssueCatalogState.HYDRATING and not allow_partial_catalog:
+ raise ValueError("Initial metadata sync is already in progress.")
+ baseline = _baseline(payload, MetadataEntityKind.SERIES)
+ values = baseline.values.model_dump() if baseline else {}
+ values.update(
+ title=series.title,
+ sort_title=series.sort_title,
+ publisher=publisher,
+ description=series.description,
+ year_start=series.year_start,
+ year_end=series.year_end,
+ series_type=series.series_type.value,
+ status=series.status.value,
+ issue_count=series.issue_count,
+ image_url=series.cover_url,
+ )
+ claims = tuple(
+ (
+ ExternalIdentityRef(
+ item.identity_namespace, MetadataEntityKind.SERIES, item.external_id
+ ),
+ item.verification_state,
+ item.revision,
+ )
+ for item in await session.scalars(
+ select(SeriesExternalIdentity)
+ .where(SeriesExternalIdentity.series_id == series_id)
+ .order_by(SeriesExternalIdentity.identity_namespace)
+ .execution_options(populate_existing=True)
+ )
+ )
+ event_id = await session.scalar(
+ select(func.max(SeriesIdentityEvent.id)).where(SeriesIdentityEvent.series_id == series_id)
+ )
+ parent = RefreshEntityState(
+ series_id,
+ MetadataValues.model_validate(values),
+ baseline,
+ revision or 0,
+ claims,
+ event_id or 0,
+ series.comicvine_id,
+ frozenset({"status", "year_end"}) if series.status_override else frozenset(),
+ )
+ if not include_issues:
+ if issue_ids is not None:
+ raise ValueError("Series-only reads cannot select issues.")
+ return SeriesRefreshState(
+ parent,
+ (),
+ tuple(
+ item.model_copy(
+ update={"last_tested_at": None, "last_success_at": None, "last_status": None}
+ )
+ for item in await read_source_policies(session)
+ ),
+ )
+ members = (
+ await session.execute(
+ select(Issue, IssueMetadataBaseline.revision, IssueMetadataBaseline.snapshot_json)
+ .outerjoin(IssueMetadataBaseline, IssueMetadataBaseline.issue_id == Issue.id)
+ .where(issue_filter)
+ .order_by(Issue.id)
+ .limit(10001)
+ .execution_options(populate_existing=True)
+ )
+ ).all()
+ if len(members) > 10000:
+ raise ValueError("This series exceeds the bounded refresh limit of 10,000 issues.")
+ if issue_ids is not None and {issue.id for issue, _, _ in members} != set(issue_ids):
+ raise ValueError("Requested issues no longer belong to this series.")
+ issue_claims: dict[int, list[tuple[ExternalIdentityRef, IdentityVerificationState, int]]] = {}
+ for claim in await session.scalars(
+ select(IssueExternalIdentity)
+ .join(Issue, Issue.id == IssueExternalIdentity.issue_id)
+ .where(issue_filter)
+ .order_by(IssueExternalIdentity.identity_namespace)
+ .execution_options(populate_existing=True)
+ ):
+ issue_claims.setdefault(claim.issue_id, []).append(
+ (
+ ExternalIdentityRef(
+ claim.identity_namespace, MetadataEntityKind.ISSUE, claim.external_id
+ ),
+ claim.verification_state,
+ claim.revision,
+ )
+ )
+ events = dict(
+ (
+ await session.execute(
+ select(IssueIdentityEvent.issue_id, func.max(IssueIdentityEvent.id))
+ .join(Issue, Issue.id == IssueIdentityEvent.issue_id)
+ .where(issue_filter)
+ .group_by(IssueIdentityEvent.issue_id)
+ )
+ )
+ .tuples()
+ .all()
+ )
+ credits = await read_issue_credits(session, [issue.id for issue, _, _ in members])
+ issues = []
+ for issue, issue_revision, issue_payload in members:
+ previous = _baseline(issue_payload, MetadataEntityKind.ISSUE)
+ issue_values = previous.values.model_dump() if previous else {}
+ issue_values.update(
+ title=issue.title,
+ description=issue.description,
+ issue_number_text=issue.effective_issue_number_text,
+ cover_date=issue.release_date,
+ store_date=issue.store_date,
+ page_count=issue.page_count,
+ image_url=issue.cover_url,
+ credits=credits.get(
+ issue.id, () if previous and previous.values.credits is not None else None
+ ),
+ )
+ issues.append(
+ RefreshEntityState(
+ issue.id,
+ MetadataValues.model_validate(issue_values),
+ previous,
+ issue_revision or 0,
+ tuple(issue_claims.get(issue.id, ())),
+ events.get(issue.id, 0),
+ issue.comicvine_id,
+ )
+ )
+ # Connection-test timestamps do not affect authority. Everything that can
+ # affect provider selection remains part of the read set, including revision.
+ policies = tuple(
+ item.model_copy(
+ update={"last_tested_at": None, "last_success_at": None, "last_status": None}
+ )
+ for item in await read_source_policies(session)
+ )
+ return SeriesRefreshState(
+ parent,
+ tuple(issues),
+ policies,
+ await read_catalog_checkpoints(session, series_id),
+ tuple(CatalogExcludedIssue.model_validate(item) for item in series.catalog_exclusions),
+ )
diff --git a/src/pullbox/services/metadata_series_retry.py b/src/pullbox/services/metadata_series_retry.py
new file mode 100644
index 00000000..e002acc6
--- /dev/null
+++ b/src/pullbox/services/metadata_series_retry.py
@@ -0,0 +1,253 @@
+"""Bounded, caller-committed admission and retries for scheduled source reads."""
+
+import hashlib
+import math
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+
+from sqlalchemy import delete, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.sql.elements import ColumnElement
+
+from pullbox.models import MetadataSeriesRetry, Series
+from pullbox.models.metadata_source_account import MetadataSourceAccount
+from pullbox.models.series import IssueCatalogState
+from pullbox.schemas.metadata_sources import SourceOutcome, SourceStatus
+from pullbox.services.metadata_account_admission import account_key
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_sources import SourceRuntime, load_source_runtime
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+TRANSIENT = {SourceStatus.RATE_LIMITED, SourceStatus.TIMEOUT, SourceStatus.UNAVAILABLE}
+
+
+def config_key(runtime: SourceRuntime) -> str:
+ # Only a one-way scope key is retained; credentials never enter retry records.
+ token = runtime.credential.get_secret_value() if runtime.credential else ""
+ return hashlib.sha256(f"{runtime.policy.revision}:{token}".encode()).hexdigest()
+
+
+@dataclass(frozen=True)
+class RetryToken:
+ id: int
+ source: str
+ revision: int
+
+
+@dataclass(frozen=True)
+class RetryAdmission:
+ registry: MetadataSourceRegistry
+ config_keys: dict[str, str]
+ tokens: tuple[RetryToken, ...]
+ held: frozenset[str]
+
+
+async def retry_runtime(session: AsyncSession, *, gcd_api_enabled: bool) -> list[SourceRuntime]:
+ return await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled)
+
+
+def _changed(runtime: list[SourceRuntime]) -> ColumnElement[bool]:
+ return or_(
+ False,
+ *(
+ (MetadataSeriesRetry.source == item.policy.source.value)
+ & (MetadataSeriesRetry.config_key != config_key(item))
+ for item in runtime
+ if item.policy.enabled and item.unavailable is None
+ ),
+ )
+
+
+async def retry_candidates(
+ session: AsyncSession, task_id: str, runtime: list[SourceRuntime], *, limit: int
+) -> list[int]:
+ return list(
+ await session.scalars(
+ select(MetadataSeriesRetry.series_id)
+ .join(Series, Series.id == MetadataSeriesRetry.series_id)
+ .where(
+ MetadataSeriesRetry.task_id == task_id,
+ Series.issue_catalog_state != IssueCatalogState.HYDRATING,
+ or_(MetadataSeriesRetry.retry_at <= datetime.now(UTC), _changed(runtime)),
+ )
+ .group_by(MetadataSeriesRetry.series_id)
+ .order_by(func.min(MetadataSeriesRetry.retry_at), MetadataSeriesRetry.series_id)
+ .limit(limit)
+ )
+ )
+
+
+async def retry_deadline(
+ session: AsyncSession, task_id: str, runtime: list[SourceRuntime]
+) -> datetime | None:
+ if await session.scalar(
+ select(MetadataSeriesRetry.id)
+ .where(MetadataSeriesRetry.task_id == task_id, _changed(runtime))
+ .limit(1)
+ ):
+ return datetime.now(UTC)
+ return await session.scalar(
+ select(func.min(MetadataSeriesRetry.retry_at)).where(MetadataSeriesRetry.task_id == task_id)
+ )
+
+
+async def prune_retries(session: AsyncSession, task_id: str, eligible: ColumnElement[bool]) -> None:
+ await session.execute(
+ delete(MetadataSeriesRetry).where(
+ MetadataSeriesRetry.task_id == task_id,
+ MetadataSeriesRetry.series_id.not_in(select(Series.id).where(eligible)),
+ )
+ )
+
+
+async def admit_retry(
+ session: AsyncSession,
+ task_id: str,
+ series_id: int,
+ *,
+ gcd_api_enabled: bool,
+ retry_only: bool,
+) -> RetryAdmission:
+ runtime = await retry_runtime(session, gcd_api_enabled=gcd_api_enabled)
+ keys = {item.policy.source.value: config_key(item) for item in runtime}
+ rows = list(
+ await session.scalars(
+ select(MetadataSeriesRetry).where(
+ MetadataSeriesRetry.task_id == task_id, MetadataSeriesRetry.series_id == series_id
+ )
+ )
+ )
+ now = datetime.now(UTC)
+ held = frozenset(
+ row.source
+ for row in rows
+ if row.source != "*"
+ and row.config_key == keys.get(row.source)
+ and (row.retry_at is None or row.retry_at > now)
+ )
+ due = {row.source for row in rows if row.source not in held}
+ selected = [
+ item
+ for item in runtime
+ if item.policy.source.value not in held
+ and (not retry_only or "*" in due or item.policy.source.value in due)
+ ]
+ return RetryAdmission(
+ MetadataSourceRegistry(
+ selected, gcd_api_enabled=gcd_api_enabled, revalidate_reads=True, total_timeout=60
+ ),
+ keys,
+ tuple(RetryToken(row.id, row.source, row.revision) for row in rows),
+ held,
+ )
+
+
+async def settle_retry(
+ session: AsyncSession,
+ task_id: str,
+ series_id: int,
+ admission: RetryAdmission,
+ *,
+ outcomes: tuple[SourceOutcome, ...] = (),
+ retry_seconds: float | None = None,
+) -> None:
+ """Settle only the read generation; newer deferred work must survive this caller."""
+ failures = {
+ item.source.value: item
+ for item in outcomes
+ if item.status in TRANSIENT | {SourceStatus.AUTHENTICATION_FAILED}
+ }
+ now = datetime.now(UTC)
+ pending: dict[str, tuple[str, datetime | None, str]] = {}
+ for source, item in failures.items():
+ if source in admission.held:
+ continue
+ delay = item.retry_after_seconds or (
+ 3600 if item.status is SourceStatus.RATE_LIMITED else 300
+ )
+ pending[source] = (
+ item.status.value,
+ None
+ if item.status is SourceStatus.AUTHENTICATION_FAILED
+ else now + timedelta(seconds=max(60, min(delay, 7 * 86400))),
+ admission.config_keys[source],
+ )
+ if retry_seconds is not None and not failures:
+ if not math.isfinite(retry_seconds) or retry_seconds < 0:
+ raise ValueError("Invalid metadata retry deadline")
+ pending["*"] = (
+ SourceStatus.TIMEOUT.value,
+ now + timedelta(seconds=max(60, min(retry_seconds, 7 * 86400))),
+ "",
+ )
+ async with metadata_write_scope(session):
+ # A failure may settle after an explicit account probe has already recovered.
+ auth_sources = {
+ source
+ for source, (status, _, _) in pending.items()
+ if status == SourceStatus.AUTHENTICATION_FAILED.value
+ }
+ if auth_sources:
+ current = {
+ item.policy.source.value: item
+ for item in await retry_runtime(
+ session, gcd_api_enabled=admission.registry.gcd_api_enabled
+ )
+ }
+ for account in await session.scalars(
+ select(MetadataSourceAccount)
+ .where(MetadataSourceAccount.source.in_(auth_sources))
+ .order_by(MetadataSourceAccount.source)
+ .with_for_update()
+ ):
+ runtime = current[account.source]
+ if (
+ runtime.unavailable is None
+ and account.account_key == account_key(runtime)
+ and pending[account.source][2] == config_key(runtime)
+ and account.status != SourceStatus.AUTHENTICATION_FAILED.value
+ ):
+ pending[account.source] = (
+ account.status or SourceStatus.UNAVAILABLE.value,
+ account.retry_at or now,
+ pending[account.source][2],
+ )
+ if (
+ await session.scalar(select(Series.id).where(Series.id == series_id).with_for_update())
+ is None
+ ):
+ return
+ tokens = {item.source: item for item in admission.tokens}
+ rows = {
+ row.source: row
+ for row in await session.scalars(
+ select(MetadataSeriesRetry)
+ .where(
+ MetadataSeriesRetry.task_id == task_id,
+ MetadataSeriesRetry.series_id == series_id,
+ )
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ }
+ for source in (set(tokens) | set(pending)) - admission.held:
+ row, token = rows.get(source), tokens.get(source)
+ if token is not None and row is None:
+ continue
+ if row is not None and (
+ token is None or (row.id, row.revision) != (token.id, token.revision)
+ ):
+ continue
+ if source not in pending:
+ if row is not None:
+ await session.delete(row)
+ continue
+ status, due, key = pending[source]
+ if row is None:
+ row = MetadataSeriesRetry(
+ task_id=task_id, series_id=series_id, source=source, revision=0
+ )
+ session.add(row)
+ row.status, row.retry_at, row.config_key = status, due, key
+ row.revision += 1
+ await session.flush()
diff --git a/src/pullbox/services/metadata_service.py b/src/pullbox/services/metadata_service.py
index 3ce9db72..edd6e405 100644
--- a/src/pullbox/services/metadata_service.py
+++ b/src/pullbox/services/metadata_service.py
@@ -15,8 +15,9 @@
import structlog
from sqlalchemy import delete, select
-from pullbox.core.exceptions import NotFoundError, ProviderError
+from pullbox.core.exceptions import NotFoundError, ProviderError, ValidationError
from pullbox.core.issue_numbers import format_issue_number, normalize_issue_number_text
+from pullbox.core.metadata_identity import MetadataEntityKind
from pullbox.core.name_matcher import NameMatcher
from pullbox.core.naming import (
classify_series_type,
@@ -38,6 +39,13 @@
from pullbox.providers.metadata.comicvine import ComicVineError
from pullbox.services.catalog.reader import CatalogIssueSummary, CatalogSeriesMetadata
from pullbox.services.cover_cache_service import purge_series_cover_cache
+from pullbox.services.metadata_writer_identity import (
+ attach_issue_metadata_identity,
+ attach_issue_summary_identities,
+ attach_series_metadata_identity,
+ comicvine_identity,
+ metadata_write_scope,
+)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -45,6 +53,7 @@
from pullbox.providers.base import IssueMetadata, IssueSummary, SeriesSearchResult
from pullbox.providers.metadata.comicvine import ComicVineProvider
from pullbox.services.catalog.reader import CatalogReader
+ from pullbox.services.metadata_writer_identity import ImportIdentityOrigin
logger = structlog.get_logger(__name__)
@@ -111,6 +120,24 @@ def _exact_issue_number_text(
_UNTRUSTED_LEGACY_SERIES_TYPES = frozenset({SeriesType.ONE_SHOT, SeriesType.SPECIAL})
+def classify_issue_metadata(
+ series_type: SeriesType, title: str | None, provider_type: str | None = None
+) -> tuple[IssueType, IssueType]:
+ """Return explicit evidence and effective type, keeping inheritance separate."""
+ explicit = IssueType.ISSUE
+ with contextlib.suppress(ValueError):
+ explicit = IssueType(provider_type or "")
+ if explicit == IssueType.ISSUE:
+ with contextlib.suppress(ValueError):
+ explicit = IssueType(detect_issue_type_from_metadata_title(title))
+ effective = (
+ _SERIES_TO_ISSUE_TYPE.get(series_type, IssueType.ISSUE)
+ if explicit == IssueType.ISSUE
+ else explicit
+ )
+ return explicit, effective
+
+
def _series_type_from_complete_issue_evidence(
issue_types: list[IssueType],
) -> SeriesType | None:
@@ -125,6 +152,23 @@ def _series_type_from_complete_issue_evidence(
return None
+def infer_series_type_from_issue_evidence(series: Series, issue_types: list[IssueType]) -> None:
+ """Apply complete explicit issue evidence, never inherited or partial evidence."""
+ if (
+ classify_series_type(
+ series.title,
+ description=series.description,
+ issue_count=series.issue_count,
+ year_start=series.year_start,
+ )
+ != SeriesType.STANDARD.value
+ ):
+ return
+ inferred = _series_type_from_complete_issue_evidence(issue_types)
+ if inferred is not None:
+ series.series_type = inferred
+
+
class MetadataService:
"""Orchestrates metadata fetching and caching.
@@ -181,12 +225,27 @@ async def upsert_series_metadata(
session: AsyncSession,
comicvine_id: int,
meta: SeriesMetadata,
+ *,
+ identity_origin: ImportIdentityOrigin | None = None,
) -> Series:
"""Create or update a local Series row from prefetched provider metadata.
This split lets long-running workflows fetch provider data before they
enter a write-heavy section, keeping SQLite write locks short.
"""
+ identity = comicvine_identity(MetadataEntityKind.SERIES, meta.provider_id)
+ if identity.external_id != str(comicvine_id):
+ raise ValidationError("Series metadata does not match the requested ComicVine ID.")
+ async with metadata_write_scope(session):
+ return await self._upsert_series_metadata(session, comicvine_id, meta, identity_origin)
+
+ async def _upsert_series_metadata(
+ self,
+ session: AsyncSession,
+ comicvine_id: int,
+ meta: SeriesMetadata,
+ identity_origin: ImportIdentityOrigin | None,
+ ) -> Series:
log = logger.bind(comicvine_id=comicvine_id)
source = "pullbox_catalog" if isinstance(meta, CatalogSeriesMetadata) else "comicvine"
@@ -199,7 +258,9 @@ async def upsert_series_metadata(
publisher_id = await self._ensure_publisher(session, meta.publisher)
existing = (
- await session.execute(select(Series).where(Series.comicvine_id == comicvine_id))
+ await session.execute(
+ select(Series).where(Series.comicvine_id == comicvine_id).with_for_update()
+ )
).scalar_one_or_none()
if (
@@ -208,6 +269,7 @@ async def upsert_series_metadata(
and existing.metadata_source == "comicvine"
):
# Basic catalog hydration must never replace a completed full refresh.
+ await attach_series_metadata_identity(session, existing, meta, identity_origin)
return existing
if existing:
existing.title = meta.title
@@ -250,6 +312,7 @@ async def upsert_series_metadata(
)
session.add(series)
await session.flush()
+ await attach_series_metadata_identity(session, series, meta, identity_origin)
await purge_series_cover_cache(
session,
series.id,
@@ -258,6 +321,8 @@ async def upsert_series_metadata(
series.cover_path = None
log.debug("metadata_series_created", series_id=series.id)
+ if existing:
+ await attach_series_metadata_identity(session, series, meta, identity_origin)
await self.classify_and_link_series(session, series)
return series
@@ -409,10 +474,12 @@ async def get_recent_issue_summaries_for_series(
except ComicVineError as exc:
raise _provider_error_from_comicvine(exc) from exc
+ @staticmethod
async def classify_and_link_series(
- self,
session: AsyncSession,
series: Series,
+ *,
+ preserve_provider_type: bool = False,
) -> None:
"""Detect series type from CV title and link to parent series.
@@ -429,6 +496,8 @@ async def classify_and_link_series(
year_start=series.year_start,
)
)
+ if preserve_provider_type and previous_type != SeriesType.STANDARD:
+ detected = previous_type
if detected == SeriesType.STANDARD and previous_type != SeriesType.STANDARD:
issue_types = [
IssueType(detect_issue_type_from_metadata_title(title))
@@ -446,7 +515,7 @@ async def classify_and_link_series(
# summary fields that are not represented in local prose.
detected = previous_type
series.series_type = detected
- await self._repair_issue_types_after_series_change(
+ await MetadataService._repair_issue_types_after_series_change(
session,
series,
previous_type=previous_type,
@@ -463,7 +532,7 @@ async def classify_and_link_series(
return # Couldn't extract a different base title
# Search for a standard parent series with the base title + similar year
- parent = await self._find_parent_series(
+ parent = await MetadataService._find_parent_series(
session,
base_title,
series.year_start,
@@ -594,11 +663,35 @@ async def fetch_issue(
except ComicVineError as exc:
raise _provider_error_from_comicvine(exc) from exc
- existing = (
- await session.execute(select(Issue).where(Issue.comicvine_id == comicvine_id))
- ).scalar_one_or_none()
+ identity = comicvine_identity(MetadataEntityKind.ISSUE, meta.provider_id)
+ if int(identity.external_id) != comicvine_id:
+ raise ValidationError("The returned metadata belongs to a different ComicVine issue.")
+ target = (
+ await session.execute(
+ select(Issue.id, Issue.series_id).where(Issue.comicvine_id == comicvine_id)
+ )
+ ).one_or_none()
+ if target is None:
+ raise NotFoundError("Issue", comicvine_id)
- if existing:
+ async with metadata_write_scope(session):
+ # Match attachment lock order: parent before child, and recheck the
+ # relationship after locking in case recovery moved it meanwhile.
+ series = await session.scalar(
+ select(Series)
+ .where(Series.id == target.series_id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ existing = await session.scalar(
+ select(Issue)
+ .where(Issue.id == target.id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if series is None or existing is None or existing.series_id != series.id:
+ raise ValidationError("The issue changed during refresh. Reload before retrying.")
+ await attach_issue_metadata_identity(session, series, existing, meta)
existing.issue_number = meta.issue_number
existing.issue_number_text = _exact_issue_number_text(
meta.issue_number,
@@ -617,9 +710,6 @@ async def fetch_issue(
existing.metadata_source = "comicvine"
issue = existing
log.debug("metadata_issue_updated", issue_id=issue.id)
- else:
- raise NotFoundError("Issue", comicvine_id)
-
return issue
async def prefetch_issue_metadata_batch(
@@ -682,9 +772,16 @@ async def _get_or_create_creator(
creator = (
await session.execute(select(Creator).where(Creator.comicvine_id == comicvine_id))
).scalar_one_or_none()
- if creator is None:
+ else:
+ # A name is descriptive evidence, not proof of another creator's
+ # ComicVine identity. Reuse only an unclaimed descriptive row.
creator = (
- await session.execute(select(Creator).where(Creator.name == name))
+ await session.execute(
+ select(Creator)
+ .where(Creator.name == name, Creator.comicvine_id.is_(None))
+ .order_by(Creator.id)
+ .limit(1)
+ )
).scalar_one_or_none()
if creator is None:
creator = Creator(name=name, comicvine_id=comicvine_id, comicvine_url=comicvine_url)
@@ -755,21 +852,50 @@ async def upsert_issue_summaries(
summaries: list[IssueSummary],
*,
infer_series_type_from_summaries: bool = False,
+ identity_origin: ImportIdentityOrigin | None = None,
) -> list[Issue]:
"""Create or update local Issue rows from prefetched provider summaries."""
+ for summary in summaries:
+ comicvine_identity(MetadataEntityKind.ISSUE, summary.provider_id)
+ async with metadata_write_scope(session):
+ return await self._upsert_issue_summaries(
+ session,
+ series,
+ summaries,
+ infer_series_type_from_summaries=infer_series_type_from_summaries,
+ identity_origin=identity_origin,
+ )
+
+ async def _upsert_issue_summaries(
+ self,
+ session: AsyncSession,
+ series: Series,
+ summaries: list[IssueSummary],
+ *,
+ infer_series_type_from_summaries: bool,
+ identity_origin: ImportIdentityOrigin | None,
+ ) -> list[Issue]:
series_id = series.id
if series_id is None:
raise NotFoundError("Series", "unpersisted")
+ # Match the identity writer's parent-before-child lock order before
+ # ORM autoflush can update any existing issue rows.
+ await session.execute(select(Series.id).where(Series.id == series_id).with_for_update())
log = logger.bind(series_id=series_id)
created = []
+ identity_members: list[tuple[Issue, IssueSummary]] = []
provider_issue_ids = [int(summary.provider_id) for summary in summaries]
existing_result = await session.execute(select(Issue).where(Issue.series_id == series_id))
existing_issues = list(existing_result.scalars().all())
- provider_result = await session.execute(
- select(Issue).where(Issue.comicvine_id.in_(provider_issue_ids))
- )
- existing_provider_issues = list(provider_result.scalars().all())
+ existing_provider_issues: list[Issue] = []
+ for offset in range(0, len(provider_issue_ids), 200):
+ provider_result = await session.execute(
+ select(Issue).where(
+ Issue.comicvine_id.in_(provider_issue_ids[offset : offset + 200])
+ )
+ )
+ existing_provider_issues.extend(provider_result.scalars().all())
existing_by_text = {issue.effective_issue_number_text: issue for issue in existing_issues}
legacy_by_number = {
issue.issue_number: issue
@@ -813,6 +939,7 @@ async def upsert_issue_summaries(
# A basic snapshot cannot establish that live fields are stale.
# Keep its identity and fields, and do not infer parent type here.
summary_evidence_types.append(IssueType.ISSUE)
+ identity_members.append((existing_by_provider, summary))
continue
if existing_by_provider is not None and existing_by_provider is not existing:
if existing is None:
@@ -839,19 +966,10 @@ async def upsert_issue_summaries(
# Compact provider type and provider title are explicit evidence.
# Series inheritance is a fallback and cannot establish consensus.
- explicit_type = IssueType.ISSUE
- with contextlib.suppress(ValueError):
- explicit_type = IssueType(summary.issue_type)
- if explicit_type == IssueType.ISSUE:
- with contextlib.suppress(ValueError):
- explicit_type = IssueType(detect_issue_type_from_metadata_title(summary.title))
+ explicit_type, detected_type = classify_issue_metadata(
+ series.series_type, summary.title, summary.issue_type
+ )
summary_evidence_types.append(explicit_type)
- detected_type = explicit_type
- if detected_type == IssueType.ISSUE:
- detected_type = _SERIES_TO_ISSUE_TYPE.get(
- series.series_type,
- IssueType.ISSUE,
- )
if existing:
if sync_issue_identity:
@@ -865,7 +983,7 @@ async def upsert_issue_summaries(
existing_by_text.pop(old_issue_number_text, None)
existing_by_text[exact_issue_number_text] = existing
if assign_provider_issue_id:
- existing.comicvine_id = provider_issue_id
+ identity_members.append((existing, summary))
if summary.title:
existing.title = summary.title
if summary.release_date:
@@ -884,7 +1002,7 @@ async def upsert_issue_summaries(
else:
issue = Issue(
series_id=series_id,
- comicvine_id=provider_issue_id if assign_provider_issue_id else None,
+ comicvine_id=None,
issue_number=summary.issue_number,
issue_number_text=exact_issue_number_text,
title=summary.title,
@@ -898,34 +1016,16 @@ async def upsert_issue_summaries(
existing_by_text[exact_issue_number_text] = issue
if assign_provider_issue_id:
existing_by_provider_id[provider_issue_id] = issue
+ identity_members.append((issue, summary))
await session.flush()
+ await attach_issue_summary_identities(session, series, identity_members, identity_origin)
# Only a complete catalog where every summary has explicit non-standard
# evidence may infer the series type. Recent/targeted subsets and a mix
# of ordinary plus special issues must never reclassify the parent.
- if (
- infer_series_type_from_summaries
- and summaries
- and classify_series_type(
- series.title,
- description=series.description,
- issue_count=series.issue_count,
- year_start=series.year_start,
- )
- == SeriesType.STANDARD.value
- ):
- inferred_series_type = _series_type_from_complete_issue_evidence(summary_evidence_types)
- if inferred_series_type is not None:
- series.series_type = inferred_series_type
- log.debug(
- "series_type_inferred_from_issues",
- series_id=series_id,
- series_type=inferred_series_type.value,
- source_issue_types=sorted(
- {issue_type.value for issue_type in summary_evidence_types}
- ),
- )
+ if infer_series_type_from_summaries and summaries:
+ infer_series_type_from_issue_evidence(series, summary_evidence_types)
log.debug(
"metadata_issues_synced",
diff --git a/src/pullbox/services/metadata_source_reads.py b/src/pullbox/services/metadata_source_reads.py
new file mode 100644
index 00000000..7afbc9d0
--- /dev/null
+++ b/src/pullbox/services/metadata_source_reads.py
@@ -0,0 +1,561 @@
+"""Bounded source-bound reads for selection, preview, and catalog hydration."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING, Protocol, runtime_checkable
+
+import structlog
+
+from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource
+from pullbox.schemas.metadata_sources import (
+ MetadataFetch,
+ MetadataPage,
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ RecentIssueWindow,
+ SourceCapability,
+ SourceOutcome,
+ SourceStatus,
+)
+from pullbox.services.metadata_account_admission import AccountAttempt, account_request
+from pullbox.services.metadata_discovery import MetadataSourceError
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable
+
+ from pullbox.services.metadata_discovery import MetadataSourceAdapter, MetadataSourceRegistry
+
+logger = structlog.get_logger(__name__)
+PAGE_SIZE = 100
+MAX_PAGE = 10000
+
+
+@runtime_checkable
+class SeriesDetailAdapter(Protocol):
+ async def series(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderSeriesRead]: ...
+
+
+@runtime_checkable
+class IssueDetailAdapter(Protocol):
+ async def issue(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderIssueRead]: ...
+
+
+@runtime_checkable
+class IssueListAdapter(Protocol):
+ async def issues(
+ self, external_id: str, *, page: int = 1, validator: str | None = None
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]: ...
+
+
+@runtime_checkable
+class RecentIssueAdapter(Protocol):
+ async def recent_issues(
+ self, external_id: str, *, since: datetime
+ ) -> MetadataFetch[RecentIssueWindow]: ...
+
+
+@runtime_checkable
+class StoryArcDetailAdapter(Protocol):
+ async def story_arc(
+ self, external_id: str, *, validator: str | None = None
+ ) -> MetadataFetch[ProviderStoryArcRead]: ...
+
+
+@runtime_checkable
+class StoryArcIssueAdapter(Protocol):
+ async def story_arc_issues(
+ self, external_id: str, *, page: int = 1, validator: str | None = None
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]: ...
+
+
+@runtime_checkable
+class StoryArcSearchAdapter(Protocol):
+ async def story_arcs(
+ self, query: str, *, page: int = 1
+ ) -> MetadataPage[ProviderStoryArcRead]: ...
+
+
+def source_id(source: MetadataSource, kind: MetadataEntityKind, value: str) -> str:
+ if not isinstance(value, str):
+ raise ValueError("Expected a source identity string")
+ return ExternalIdentityRef(source.identity_namespace, kind, value).external_id
+
+
+def page_number(page: int) -> None:
+ if type(page) is not int or not 1 <= page <= MAX_PAGE:
+ raise ValueError("Metadata page must be between one and ten thousand")
+
+
+def issue_checkpoint(since: datetime) -> datetime:
+ if not isinstance(since, datetime) or since.utcoffset() is None:
+ raise ValueError("Issue checkpoint must include a timezone")
+ return since.astimezone(UTC)
+
+
+def _identity(
+ row: ProviderSeriesRead | ProviderIssueRead | ProviderStoryArcRead,
+ source: MetadataSource,
+ kind: MetadataEntityKind,
+) -> None:
+ if (
+ row.source is not source
+ or row.identity_namespace is not source.identity_namespace
+ or source_id(source, kind, row.external_id) != row.external_id
+ ):
+ raise ValueError("Source returned a different identity")
+
+
+def _issue(row: ProviderIssueRead, source: MetadataSource) -> None:
+ _identity(row, source, MetadataEntityKind.ISSUE)
+ if (
+ source_id(source, MetadataEntityKind.SERIES, row.series_external_id)
+ != row.series_external_id
+ or not row.issue_number_text.strip()
+ or len(row.issue_number_text) > 320
+ ):
+ raise ValueError("Source returned an invalid issue")
+
+
+async def _read[T](
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ capability: SourceCapability,
+ operation: Callable[[MetadataSourceAdapter, str | None], Awaitable[MetadataFetch[T]]],
+ validate: Callable[[T], None],
+ validator: str | None,
+ *,
+ deadline: float | None = None,
+ cache_identity: tuple[str, int] | None = None,
+ model: type[MetadataFetch[T]] | None = None,
+) -> MetadataFetch[T]:
+ if validator is not None and (
+ not isinstance(validator, str)
+ or not 1 <= len(validator) <= 128
+ or any(ord(char) < 32 or ord(char) > 126 for char in validator)
+ ):
+ raise ValueError("Invalid source validator")
+ unavailable = registry._unavailable(source, capability=capability)
+ if unavailable is not None:
+ return MetadataFetch(status=unavailable)
+ deadline = (
+ deadline
+ if deadline is not None
+ else asyncio.get_running_loop().time() + registry.total_timeout
+ )
+ runtime = registry.runtime[source]
+ if (
+ registry.read_cache is not None
+ and cache_identity is not None
+ and model is not None
+ and validator is None
+ and runtime.policy.revision > 0
+ and runtime.credential is not None
+ and source
+ in {MetadataSource.COMICVINE_API, MetadataSource.METRON_API, MetadataSource.GCD_API_V2}
+ ):
+
+ async def load(current: str | None) -> MetadataFetch[T]:
+ return await _read(
+ registry, source, capability, operation, validate, current, deadline=deadline
+ )
+
+ try:
+ async with asyncio.timeout_at(deadline):
+ result = await registry.read_cache.get(
+ source,
+ runtime.policy.revision,
+ capability,
+ *cache_identity,
+ model,
+ load,
+ validate,
+ conditional=SourceCapability.CONDITIONAL_REFRESH
+ in registry.factories[source].capabilities,
+ revalidate=registry.revalidate_reads,
+ )
+ except TimeoutError:
+ return MetadataFetch(status=SourceStatus.TIMEOUT)
+ if result.data is not None:
+ validate(result.data)
+ return result
+ try:
+ async with account_request(
+ runtime, slots=registry.read_slots, deadline=deadline
+ ) as attempt:
+ if attempt.blocked is not None:
+ return MetadataFetch(
+ status=attempt.blocked.status,
+ retry_after_seconds=attempt.blocked.retry_after_seconds,
+ )
+ result = await _read_admitted(
+ registry, source, capability, operation, validate, validator, deadline, attempt
+ )
+ attempt.outcome = SourceOutcome(
+ source=source, status=result.status, retry_after_seconds=result.retry_after_seconds
+ )
+ return result
+ except TimeoutError:
+ return MetadataFetch(status=SourceStatus.TIMEOUT)
+
+
+async def _read_admitted[T](
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ capability: SourceCapability,
+ operation: Callable[[MetadataSourceAdapter, str | None], Awaitable[MetadataFetch[T]]],
+ validate: Callable[[T], None],
+ validator: str | None,
+ deadline: float,
+ attempt: AccountAttempt,
+) -> MetadataFetch[T]:
+ adapter = None
+ close_failed = False
+ try:
+ if asyncio.get_running_loop().time() >= deadline:
+ return MetadataFetch(status=SourceStatus.TIMEOUT)
+ async with asyncio.timeout_at(deadline):
+ async with asyncio.timeout(registry.per_source_timeout):
+ adapter = registry.factories[source].factory(registry.runtime[source])
+ attempt.started = True
+ result = await operation(adapter, validator)
+ if result.status is SourceStatus.OK:
+ if result.data is None:
+ raise ValueError("Missing source metadata")
+ validate(result.data)
+ elif (
+ result.data is not None
+ or result.status not in {SourceStatus.NOT_FOUND, SourceStatus.NOT_MODIFIED}
+ or (result.status is SourceStatus.NOT_MODIFIED and validator is None)
+ ):
+ raise ValueError("Invalid source read outcome")
+ except TimeoutError:
+ result = MetadataFetch(status=SourceStatus.TIMEOUT)
+ except MetadataSourceError as exc:
+ result = MetadataFetch(status=exc.status, retry_after_seconds=exc.retry_after_seconds)
+ except (ValueError, TypeError, AttributeError):
+ result = MetadataFetch(status=SourceStatus.INCOMPATIBLE_RESPONSE)
+ except Exception:
+ logger.warning("metadata_source_read_failed", source=source.value, capability=capability)
+ result = MetadataFetch(status=SourceStatus.UNAVAILABLE)
+ finally:
+ if adapter is not None:
+ try:
+ async with asyncio.timeout(2):
+ await adapter.close()
+ except Exception:
+ logger.warning("metadata_source_close_failed", source=source.value)
+ close_failed = True
+ if close_failed and result.status in {
+ SourceStatus.OK,
+ SourceStatus.NOT_FOUND,
+ SourceStatus.NOT_MODIFIED,
+ }:
+ return MetadataFetch(status=SourceStatus.UNAVAILABLE)
+ return result
+
+
+async def read_series(
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ validator: str | None,
+) -> MetadataFetch[ProviderSeriesRead]:
+ identifier = source_id(source, MetadataEntityKind.SERIES, external_id)
+
+ async def operation(
+ adapter: MetadataSourceAdapter, current: str | None
+ ) -> MetadataFetch[ProviderSeriesRead]:
+ if not isinstance(adapter, SeriesDetailAdapter):
+ raise MetadataSourceError(SourceStatus.UNSUPPORTED)
+ return await adapter.series(identifier, validator=current)
+
+ def validate(row: ProviderSeriesRead) -> None:
+ _identity(row, source, MetadataEntityKind.SERIES)
+ if row.external_id != identifier or not row.title.strip() or len(row.title) > 500:
+ raise ValueError("Source returned a different series")
+
+ return await _read(
+ registry,
+ source,
+ SourceCapability.SERIES_DETAILS,
+ operation,
+ validate,
+ validator,
+ cache_identity=(identifier, 1),
+ model=MetadataFetch[ProviderSeriesRead],
+ )
+
+
+async def read_issue(
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ validator: str | None,
+) -> MetadataFetch[ProviderIssueRead]:
+ identifier = source_id(source, MetadataEntityKind.ISSUE, external_id)
+
+ async def operation(
+ adapter: MetadataSourceAdapter, current: str | None
+ ) -> MetadataFetch[ProviderIssueRead]:
+ if not isinstance(adapter, IssueDetailAdapter):
+ raise MetadataSourceError(SourceStatus.UNSUPPORTED)
+ return await adapter.issue(identifier, validator=current)
+
+ def validate(row: ProviderIssueRead) -> None:
+ _issue(row, source)
+ if row.external_id != identifier:
+ raise ValueError("Source returned a different issue")
+
+ return await _read(
+ registry,
+ source,
+ SourceCapability.ISSUE_DETAILS,
+ operation,
+ validate,
+ validator,
+ cache_identity=(identifier, 1),
+ model=MetadataFetch[ProviderIssueRead],
+ )
+
+
+def validate_recent_issue_window(
+ result: RecentIssueWindow, source: MetadataSource, identifier: str, since: datetime
+) -> None:
+ # Revalidate even constructed/copied DTOs at the adapter trust boundary.
+ checked = RecentIssueWindow.model_validate(result.model_dump())
+ if (
+ len(checked.results) != min(100, checked.matched_total)
+ or checked.truncated != (checked.matched_total > 100)
+ or len({row.external_id for row in checked.results}) != len(checked.results)
+ ):
+ raise ValueError("Incomplete recent issue window")
+ if checked.scope == "modified_since":
+ if checked.since is None or issue_checkpoint(checked.since) != since:
+ raise ValueError("Different issue checkpoint")
+ elif checked.since is not None:
+ raise ValueError("Publication slice cannot claim a modification checkpoint")
+ local = source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.GCD_LOCAL}
+ if local:
+ if checked.source_updated_at is None:
+ raise ValueError("Missing catalog generation")
+ issue_checkpoint(checked.source_updated_at)
+ for row in checked.results:
+ _issue(row, source)
+ if row.series_external_id != identifier:
+ raise ValueError("Issue belongs to a different series")
+ if checked.scope == "modified_since" and (
+ row.source_updated_at is None or issue_checkpoint(row.source_updated_at) <= since
+ ):
+ raise ValueError("Issue is outside the requested modification window")
+ if local and row.source_updated_at != checked.source_updated_at:
+ raise ValueError("Catalog generation changed during the read")
+
+
+async def read_recent_issues(
+ registry: MetadataSourceRegistry, source: MetadataSource, external_id: str, *, since: datetime
+) -> MetadataFetch[RecentIssueWindow]:
+ identifier = source_id(source, MetadataEntityKind.SERIES, external_id)
+ checkpoint = issue_checkpoint(since)
+
+ async def operation(
+ adapter: MetadataSourceAdapter, _validator: str | None
+ ) -> MetadataFetch[RecentIssueWindow]:
+ if not isinstance(adapter, RecentIssueAdapter):
+ raise MetadataSourceError(SourceStatus.UNSUPPORTED)
+ return await adapter.recent_issues(identifier, since=checkpoint)
+
+ def validate(result: RecentIssueWindow) -> None:
+ validate_recent_issue_window(result, source, identifier, checkpoint)
+
+ # A checkpoint-specific slice must not reuse a full-catalog page cache entry.
+ return await _read(registry, source, SourceCapability.RECENT_ISSUES, operation, validate, None)
+
+
+async def read_issues(
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ page: int,
+ validator: str | None,
+) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ identifier = source_id(source, MetadataEntityKind.SERIES, external_id)
+ page_number(page)
+
+ async def operation(
+ adapter: MetadataSourceAdapter,
+ current: str | None,
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ if not isinstance(adapter, IssueListAdapter):
+ raise MetadataSourceError(SourceStatus.UNSUPPORTED)
+ return await adapter.issues(identifier, page=page, validator=current)
+
+ def validate(result: MetadataPage[ProviderIssueRead]) -> None:
+ validate_metadata_page(result, page)
+ for row in result.results:
+ _issue(row, source)
+ if row.series_external_id != identifier:
+ raise ValueError("Source returned an issue from a different series")
+
+ return await _read(
+ registry,
+ source,
+ SourceCapability.ISSUE_LIST,
+ operation,
+ validate,
+ validator,
+ cache_identity=(identifier, page),
+ model=MetadataFetch[MetadataPage[ProviderIssueRead]],
+ )
+
+
+def validate_metadata_page[T: (ProviderIssueRead, ProviderStoryArcRead)](
+ result: MetadataPage[T], page: int, *, max_page: int = MAX_PAGE
+) -> None:
+ remaining = max(0, result.total - (page - 1) * PAGE_SIZE)
+ more = remaining > PAGE_SIZE
+ if (
+ not 0 <= result.total <= 1_000_000_000
+ or len(result.results) != min(remaining, PAGE_SIZE)
+ or result.next_page != (page + 1 if more and page < max_page else None)
+ or result.truncated != (more and page == max_page)
+ or result.order_is_reading_order
+ or len({row.external_id for row in result.results}) != len(result.results)
+ ):
+ raise ValueError("Source returned an incomplete metadata page")
+
+
+def _arc(row: ProviderStoryArcRead, source: MetadataSource) -> None:
+ _identity(row, source, MetadataEntityKind.STORY_ARC)
+ if not row.title.strip() or len(row.title) > 500:
+ raise ValueError("Source returned an invalid arc title")
+ ids = row.issue_external_ids
+ if row.declared_issue_count is not None and row.declared_issue_count < 0:
+ raise ValueError("Invalid declared arc membership count")
+ if ids is not None and (
+ len(ids) > 5000
+ or len(ids) != len(set(ids))
+ or any(source_id(source, MetadataEntityKind.ISSUE, value) != value for value in ids)
+ ):
+ raise ValueError("Invalid explicit arc membership")
+ if row.membership_complete and (
+ ids is None
+ or (row.declared_issue_count is not None and len(ids) != row.declared_issue_count)
+ ):
+ raise ValueError("Incomplete explicit arc membership")
+
+
+async def read_story_arc(
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ validator: str | None,
+) -> MetadataFetch[ProviderStoryArcRead]:
+ identifier = source_id(source, MetadataEntityKind.STORY_ARC, external_id)
+
+ async def operation(
+ adapter: MetadataSourceAdapter, current: str | None
+ ) -> MetadataFetch[ProviderStoryArcRead]:
+ if not isinstance(adapter, StoryArcDetailAdapter):
+ raise MetadataSourceError(SourceStatus.UNSUPPORTED)
+ return await adapter.story_arc(identifier, validator=current)
+
+ def validate(row: ProviderStoryArcRead) -> None:
+ _arc(row, source)
+ if row.external_id != identifier:
+ raise ValueError("Source returned a different story arc")
+
+ return await _read(
+ registry,
+ source,
+ SourceCapability.STORY_ARC_DETAILS,
+ operation,
+ validate,
+ validator,
+ cache_identity=(identifier, 1),
+ model=MetadataFetch[ProviderStoryArcRead],
+ )
+
+
+async def read_story_arc_issues(
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ external_id: str,
+ *,
+ page: int,
+ validator: str | None,
+) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ identifier = source_id(source, MetadataEntityKind.STORY_ARC, external_id)
+ page_number(page)
+ if page > 50:
+ raise ValueError("Story arc membership is bounded to 5000 issues")
+
+ async def operation(
+ adapter: MetadataSourceAdapter,
+ current: str | None,
+ ) -> MetadataFetch[MetadataPage[ProviderIssueRead]]:
+ if not isinstance(adapter, StoryArcIssueAdapter):
+ raise MetadataSourceError(SourceStatus.UNSUPPORTED)
+ return await adapter.story_arc_issues(identifier, page=page, validator=current)
+
+ def validate(result: MetadataPage[ProviderIssueRead]) -> None:
+ validate_metadata_page(result, page)
+ if result.total > 5000:
+ raise ValueError("Story arc membership exceeds the review limit")
+ for row in result.results:
+ _issue(row, source)
+
+ return await _read(
+ registry,
+ source,
+ SourceCapability.STORY_ARC_ISSUES,
+ operation,
+ validate,
+ validator,
+ cache_identity=(identifier, page),
+ model=MetadataFetch[MetadataPage[ProviderIssueRead]],
+ )
+
+
+async def read_story_arcs(
+ registry: MetadataSourceRegistry,
+ source: MetadataSource,
+ query: str,
+ *,
+ page: int,
+ deadline: float,
+) -> MetadataFetch[MetadataPage[ProviderStoryArcRead]]:
+ async def operation(
+ adapter: MetadataSourceAdapter,
+ current: str | None,
+ ) -> MetadataFetch[MetadataPage[ProviderStoryArcRead]]:
+ if not isinstance(adapter, StoryArcSearchAdapter):
+ raise MetadataSourceError(SourceStatus.UNSUPPORTED)
+ return MetadataFetch(
+ status=SourceStatus.OK, data=await adapter.story_arcs(query, page=page)
+ )
+
+ def validate(result: MetadataPage[ProviderStoryArcRead]) -> None:
+ validate_metadata_page(result, page, max_page=100)
+ for row in result.results:
+ _arc(row, source)
+
+ return await _read(
+ registry,
+ source,
+ SourceCapability.STORY_ARC_SEARCH,
+ operation,
+ validate,
+ None,
+ deadline=deadline,
+ )
diff --git a/src/pullbox/services/metadata_source_status.py b/src/pullbox/services/metadata_source_status.py
new file mode 100644
index 00000000..978c12e1
--- /dev/null
+++ b/src/pullbox/services/metadata_source_status.py
@@ -0,0 +1,131 @@
+"""Read-only, bounded operator views of account holds and deferred series work."""
+
+from datetime import UTC, datetime
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.models import MetadataSeriesRetry, Series
+from pullbox.models.metadata_source_account import MetadataSourceAccount
+from pullbox.schemas.metadata_sources import (
+ DeferredMetadataRead,
+ SourceAccountRead,
+ SourceDescriptor,
+ SourceStatus,
+)
+from pullbox.schemas.pagination import PaginatedResponse
+from pullbox.services.metadata_account_admission import account_key
+from pullbox.services.metadata_discovery import describe_source_policies
+from pullbox.services.metadata_series_retry import config_key
+from pullbox.services.metadata_sources import SourceRuntime, load_source_runtime
+
+
+async def _accounts(
+ session: AsyncSession, runtime: list[SourceRuntime]
+) -> dict[str, SourceAccountRead]:
+ current = {item.policy.source.value: item for item in runtime}
+ result = {}
+ for row in await session.scalars(select(MetadataSourceAccount)):
+ source = current.get(row.source)
+ if (
+ source is not None
+ and source.unavailable is None
+ and row.account_key == account_key(source)
+ ):
+ result[row.source] = SourceAccountRead(
+ status=row.status, retry_at=row.retry_at, probe_until=row.lease_until
+ )
+ return result
+
+
+async def source_status(session: AsyncSession, *, gcd_api_enabled: bool) -> list[SourceDescriptor]:
+ runtime = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled)
+ accounts = await _accounts(session, runtime)
+ counts = {
+ row.source: (row.series_count, row.work_count)
+ for row in (
+ await session.execute(
+ select(
+ MetadataSeriesRetry.source,
+ func.count(func.distinct(MetadataSeriesRetry.series_id)).label("series_count"),
+ func.count().label("work_count"),
+ ).group_by(MetadataSeriesRetry.source)
+ )
+ )
+ }
+ result = describe_source_policies(
+ [item.policy for item in runtime], gcd_api_enabled=gcd_api_enabled
+ )
+ for item in result:
+ item.account = accounts.get(item.source.value)
+ item.deferred_series, item.deferred_work = counts.get(item.source.value, (0, 0))
+ return result
+
+
+async def deferred_work(
+ session: AsyncSession,
+ *,
+ source: MetadataSource | None,
+ limit: int,
+ offset: int,
+ gcd_api_enabled: bool,
+) -> PaginatedResponse[DeferredMetadataRead]:
+ runtime = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled)
+ accounts = await _accounts(session, runtime)
+ current = {item.policy.source.value: item for item in runtime}
+ criteria = (MetadataSeriesRetry.source == source.value,) if source is not None else ()
+ total = (
+ await session.scalar(select(func.count()).select_from(MetadataSeriesRetry).where(*criteria))
+ or 0
+ )
+ rows = await session.execute(
+ select(MetadataSeriesRetry, Series.title)
+ .join(Series, Series.id == MetadataSeriesRetry.series_id)
+ .where(*criteria)
+ .order_by(MetadataSeriesRetry.series_id, MetadataSeriesRetry.id)
+ .limit(limit)
+ .offset(offset)
+ )
+ now = datetime.now(UTC)
+ items = []
+ for row, title in rows:
+ active = current.get(row.source)
+ account = accounts.get(row.source)
+ state: str = "ready"
+ due = row.retry_at
+ if active and active.unavailable is not None:
+ state, due = "source_disabled", None
+ elif account and account.status is SourceStatus.AUTHENTICATION_FAILED:
+ state, due = "authentication_required", None
+ elif (
+ account
+ and account.status is not None
+ and any(value and value > now for value in (account.retry_at, account.probe_until))
+ ):
+ state, due = (
+ "waiting",
+ max(value for value in (account.retry_at, account.probe_until) if value),
+ )
+ elif active is not None and config_key(active) != row.config_key:
+ due = now
+ elif row.retry_at is None:
+ state = "authentication_required"
+ elif row.retry_at > now:
+ state = "waiting"
+ items.append(
+ DeferredMetadataRead.model_validate(
+ dict(
+ id=row.id,
+ series_id=row.series_id,
+ series_title=title,
+ source=row.source,
+ task_id=row.task_id,
+ state=state,
+ retry_at=due,
+ )
+ )
+ )
+ return PaginatedResponse(
+ items=items, total=total, limit=limit, offset=offset, has_more=offset + len(items) < total
+ )
diff --git a/src/pullbox/services/metadata_sources.py b/src/pullbox/services/metadata_sources.py
new file mode 100644
index 00000000..f6b966cc
--- /dev/null
+++ b/src/pullbox/services/metadata_sources.py
@@ -0,0 +1,330 @@
+"""Persist validated source policy without executing providers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from pathlib import PurePosixPath
+from typing import TYPE_CHECKING
+
+from pydantic import SecretStr, ValidationError
+from sqlalchemy import false, or_, select, update
+from sqlalchemy.exc import IntegrityError
+
+from pullbox.config import get_settings
+from pullbox.core.comicvine_key import get_comicvine_api_key, save_comicvine_api_key
+from pullbox.core.encryption import decrypt_secret, encrypt_secret, is_encrypted
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.models.config import SystemConfig
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.schemas.metadata_sources import (
+ MetadataDomain,
+ SourceOutcome,
+ SourcePolicyRead,
+ SourcePolicyWrite,
+ SourcePriorityWrite,
+ SourceSettings,
+ SourceStatus,
+)
+
+if TYPE_CHECKING:
+ from datetime import datetime
+
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ from pullbox.providers.metadata.gcd_local_database import GcdSnapshot
+ from pullbox.services.metadata_account_admission import MetadataAccountAdmission
+
+
+class SourceConfigurationConflictError(ValueError):
+ """Source policy was edited after the displayed revision."""
+
+
+@dataclass(frozen=True)
+class SourceRuntime:
+ policy: SourcePolicyRead
+ credential: SecretStr | None = None
+ unavailable: SourceStatus | None = None
+ account_admission: MetadataAccountAdmission | None = field(
+ default=None, repr=False, compare=False
+ )
+ gcd_snapshot: GcdSnapshot | None = field(default=None, repr=False)
+
+
+def default_policy(source: MetadataSource) -> SourcePolicyRead:
+ return SourcePolicyRead(
+ source=source,
+ identity_namespace=source.identity_namespace,
+ enabled=source in {MetadataSource.COMICVINE_LOCAL, MetadataSource.COMICVINE_API},
+ priority=(list(MetadataSource).index(source) + 1) * 10,
+ domain_priorities={},
+ settings=SourceSettings(),
+ credential_configured=False,
+ revision=0,
+ )
+
+
+async def read_source_policies(session: AsyncSession) -> list[SourcePolicyRead]:
+ rows = {
+ row.source: row
+ for row in await session.scalars(
+ select(MetadataSourceConfig).execution_options(populate_existing=True)
+ )
+ }
+ cv_row = await session.get(SystemConfig, "comicvine_api_key", populate_existing=True)
+ cv_configured = bool((cv_row and cv_row.value) or get_settings().comicvine_api_key)
+ result = []
+ for source in MetadataSource:
+ policy = default_policy(source)
+ row = rows.get(source.value)
+ if row is not None:
+ try:
+ body = SourcePolicyWrite(
+ revision=row.revision,
+ enabled=row.enabled,
+ priority=row.priority,
+ domain_priorities=row.domain_priorities,
+ settings=row.settings,
+ )
+ _validate(source, body, gcd_api_enabled=True)
+ policy = SourcePolicyRead(
+ **{
+ **policy.model_dump(),
+ **body.model_dump(exclude={"clear_credential"}),
+ "last_tested_at": row.last_tested_at,
+ "last_success_at": row.last_success_at,
+ "last_status": row.last_status,
+ }
+ )
+ except (ValueError, ValidationError):
+ policy = policy.model_copy(
+ update={
+ "enabled": False,
+ "revision": row.revision,
+ "configuration_status": SourceStatus.INVALID_CONFIG,
+ }
+ )
+ policy.credential_configured = (
+ cv_configured
+ if source is MetadataSource.COMICVINE_API
+ else bool(row and row.credential_secret)
+ )
+ result.append(policy)
+ return sorted(result, key=lambda item: (item.priority, item.source.value))
+
+
+def _validate(source: MetadataSource, body: SourcePolicyWrite, *, gcd_api_enabled: bool) -> None:
+ if source is MetadataSource.GCD_API_V2 and body.enabled and not gcd_api_enabled:
+ raise ValueError("GCD API v2 is disabled by the release feature flag")
+ if any(
+ type(value) is not int or not 0 <= value <= 1000
+ for value in body.domain_priorities.values()
+ ):
+ raise ValueError("Domain priorities must be integers between 0 and 1000")
+ if (
+ source in {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2}
+ and MetadataDomain.ARTWORK in body.domain_priorities
+ ):
+ raise ValueError("This source does not provide artwork")
+ path = body.settings.database_path
+ if path is not None and (
+ source is not MetadataSource.GCD_LOCAL
+ or not PurePosixPath(path).is_absolute()
+ or ".." in PurePosixPath(path).parts
+ or "\x00" in path
+ ):
+ raise ValueError("Only GCD Local accepts an absolute database candidate path")
+ if body.credential is not None and body.clear_credential:
+ raise ValueError("Choose a replacement credential or clear it, not both")
+ if (body.credential is not None or body.clear_credential) and source in {
+ MetadataSource.COMICVINE_LOCAL,
+ MetadataSource.GCD_LOCAL,
+ }:
+ raise ValueError("A local source does not accept credentials")
+ if body.credential is not None:
+ token = body.credential.get_secret_value()
+ if (
+ not token
+ or len(token) > 4096
+ or token.startswith("enc:")
+ or any(char.isspace() for char in token)
+ ):
+ raise ValueError("Enter a non-empty provider token, not an encrypted value")
+
+
+async def save_source_policy(
+ session: AsyncSession,
+ source: MetadataSource,
+ body: SourcePolicyWrite,
+ *,
+ gcd_api_enabled: bool = False,
+) -> SourcePolicyRead:
+ _validate(source, body, gcd_api_enabled=gcd_api_enabled)
+ encrypted = (
+ encrypt_secret(body.credential.get_secret_value()) if body.credential is not None else None
+ )
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(
+ update(MetadataSourceConfig)
+ .where(false())
+ .values(revision=MetadataSourceConfig.revision)
+ )
+ try:
+ async with session.begin_nested():
+ row = await session.scalar(
+ select(MetadataSourceConfig)
+ .where(MetadataSourceConfig.source == source.value)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if (row.revision if row else 0) != body.revision:
+ raise SourceConfigurationConflictError(
+ "Source settings changed; reload before saving"
+ )
+ if row is None:
+ row = MetadataSourceConfig(source=source.value)
+ session.add(row)
+ row.enabled, row.priority = body.enabled, body.priority
+ row.domain_priorities = {
+ key.value: value for key, value in body.domain_priorities.items()
+ }
+ row.settings = body.settings.model_dump(exclude_none=True)
+ row.revision = body.revision + 1
+ row.last_status = None
+ row.last_tested_at = row.last_success_at = None
+ if source is MetadataSource.COMICVINE_API:
+ if body.credential is not None or body.clear_credential:
+ await save_comicvine_api_key(
+ session, body.credential.get_secret_value() if body.credential else ""
+ )
+ elif encrypted is not None or body.clear_credential:
+ row.credential_secret = encrypted if encrypted is not None else None
+ await session.flush()
+ except IntegrityError as exc:
+ raise SourceConfigurationConflictError(
+ "Source settings changed; reload before saving"
+ ) from exc
+ return next(item for item in await read_source_policies(session) if item.source is source)
+
+
+async def load_source_runtime(
+ session: AsyncSession, *, gcd_api_enabled: bool
+) -> list[SourceRuntime]:
+ """Load configuration only. Disabled and flagged sources never decrypt secrets."""
+ from pullbox.services.gcd_local_activation import active_snapshot
+ from pullbox.services.metadata_account_admission import source_account_admission
+
+ admission = source_account_admission(session, gcd_api_enabled=gcd_api_enabled)
+ policies = await read_source_policies(session)
+ rows = {row.source: row for row in await session.scalars(select(MetadataSourceConfig))}
+ result = []
+ for policy in policies:
+ status = policy.configuration_status
+ if policy.source is MetadataSource.GCD_API_V2 and not gcd_api_enabled:
+ status = SourceStatus.FEATURE_DISABLED
+ elif not policy.enabled:
+ status = status or SourceStatus.DISABLED
+ credential = None
+ gcd_snapshot = None
+ if status is None:
+ try:
+ if policy.source is MetadataSource.COMICVINE_API:
+ token = await get_comicvine_api_key(session)
+ credential = SecretStr(token) if token else None
+ elif policy.source in {MetadataSource.METRON_API, MetadataSource.GCD_API_V2}:
+ row = rows.get(policy.source.value)
+ encrypted = row.credential_secret if row else None
+ if encrypted and not is_encrypted(encrypted):
+ raise ValueError("Source credential is not encrypted")
+ token = decrypt_secret(encrypted) if encrypted else ""
+ credential = SecretStr(token) if token else None
+ elif policy.source is MetadataSource.GCD_LOCAL:
+ gcd_snapshot = await active_snapshot(session)
+ if gcd_snapshot is None or gcd_snapshot.path != policy.settings.database_path:
+ status = SourceStatus.UNCONFIGURED
+ gcd_snapshot = None
+ except ValueError:
+ status = SourceStatus.INVALID_CONFIG
+ result.append(SourceRuntime(policy, credential, status, admission, gcd_snapshot))
+ return result
+
+
+async def record_source_health(
+ session: AsyncSession,
+ source: MetadataSource,
+ revision: int,
+ outcome: SourceOutcome,
+ checked_at: datetime,
+) -> bool:
+ """Apply only to the tested configuration, never over a newer probe."""
+ if outcome.source is not source:
+ raise ValueError("Health outcome belongs to another metadata source")
+ result = await session.execute(
+ update(MetadataSourceConfig)
+ .where(
+ MetadataSourceConfig.source == source.value,
+ MetadataSourceConfig.revision == revision,
+ or_(
+ MetadataSourceConfig.last_tested_at.is_(None),
+ MetadataSourceConfig.last_tested_at <= checked_at,
+ ),
+ )
+ .values(
+ last_tested_at=checked_at,
+ last_status=outcome.status.value,
+ last_success_at=checked_at
+ if outcome.status is SourceStatus.OK
+ else MetadataSourceConfig.last_success_at,
+ )
+ .returning(MetadataSourceConfig.id)
+ )
+ return result.scalar_one_or_none() is not None
+
+
+async def save_source_priorities(
+ session: AsyncSession, body: SourcePriorityWrite
+) -> list[SourcePolicyRead]:
+ """Save one revision-checked global and domain ordering transaction."""
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(
+ update(MetadataSourceConfig)
+ .where(false())
+ .values(revision=MetadataSourceConfig.revision)
+ )
+ try:
+ async with session.begin_nested():
+ rows = {
+ row.source: row
+ for row in await session.scalars(
+ select(MetadataSourceConfig)
+ .order_by(MetadataSourceConfig.source)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ }
+ for source, revision in body.revisions.items():
+ row = rows.get(source.value)
+ if (row.revision if row else 0) != revision:
+ raise SourceConfigurationConflictError(
+ "Source settings changed; reload before saving"
+ )
+ # Missing default rows follow the same lock order as existing rows.
+ for source in sorted(MetadataSource):
+ row = rows.get(source.value)
+ if row is None:
+ row = MetadataSourceConfig(
+ source=source.value, enabled=default_policy(source).enabled
+ )
+ session.add(row)
+ row.priority = (body.order.index(source) + 1) * 10
+ row.domain_priorities = {
+ domain.value: (order.index(source) + 1) * 10
+ for domain, order in body.domain_orders.items()
+ if source in order
+ }
+ row.revision = body.revisions[source] + 1
+ await session.flush()
+ except IntegrityError as exc:
+ raise SourceConfigurationConflictError(
+ "Source settings changed; reload before saving"
+ ) from exc
+ return await read_source_policies(session)
diff --git a/src/pullbox/services/metadata_writer_identity.py b/src/pullbox/services/metadata_writer_identity.py
new file mode 100644
index 00000000..b23dae26
--- /dev/null
+++ b/src/pullbox/services/metadata_writer_identity.py
@@ -0,0 +1,258 @@
+"""Evidence adapters for prefetched ComicVine metadata and local import records."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from contextlib import asynccontextmanager
+from dataclasses import asdict, dataclass
+from datetime import date
+from typing import TYPE_CHECKING
+from uuid import UUID, uuid4
+
+from sqlalchemy import false, select, update
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.core.metadata_identity_events import (
+ IdentityEventEvidence,
+ IdentityEventRequest,
+ IdentityEvidenceLocator,
+ IdentityEvidenceRecordKind,
+)
+from pullbox.core.metadata_identity_state import (
+ IdentityReviewRequiredError,
+ IdentityVerificationAction,
+ IdentityVerificationState,
+)
+from pullbox.models import Series
+from pullbox.models.import_job import ImportedSeries, ImportJob, ImportSourceType
+from pullbox.models.metadata_identity import SeriesExternalIdentity
+from pullbox.services.catalog.reader import CatalogIssueSummary, CatalogSeriesMetadata
+from pullbox.services.metadata_identity_attachment import (
+ IdentityAttachmentConflictError,
+ attach_verified_identities,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncIterator, Sequence
+
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ from pullbox.models import Issue
+ from pullbox.providers.base import IssueMetadata, IssueSummary, SeriesMetadata
+
+
+@dataclass(frozen=True)
+class ImportIdentityOrigin:
+ record_id: int | None
+
+
+@dataclass(frozen=True)
+class _LocalEvidence:
+ kind: IdentityEvidenceKind
+ locator: IdentityEvidenceLocator
+ snapshot: dict[str, object]
+
+
+def comicvine_identity(kind: MetadataEntityKind, external_id: str) -> ExternalIdentityRef:
+ try:
+ identity = ExternalIdentityRef(IdentityNamespace.COMICVINE, kind, external_id)
+ if int(identity.external_id) >= 2**63:
+ raise ValueError("Out of range")
+ return identity
+ except ValueError as exc:
+ raise ValidationError("Metadata contains an invalid ComicVine identity.") from exc
+
+
+@asynccontextmanager
+async def metadata_write_scope(session: AsyncSession) -> AsyncIterator[None]:
+ """Roll back metadata as well as ownership if the caller catches a conflict."""
+ if session.get_bind().dialect.name == "sqlite":
+ await session.execute(
+ update(Series).where(false()).values(comicvine_id=Series.comicvine_id)
+ )
+ try:
+ async with session.begin_nested():
+ yield
+ except (IdentityAttachmentConflictError, IdentityReviewRequiredError) as exc:
+ raise ValidationError(
+ "Metadata identity conflicts with an existing match. Review the match before retrying.",
+ details={"reason": "metadata_identity_conflict"},
+ ) from exc
+
+
+async def _import_evidence(
+ session: AsyncSession, origin: ImportIdentityOrigin | None, parent: ExternalIdentityRef
+) -> _LocalEvidence | None:
+ if origin is None:
+ return None
+ if type(origin.record_id) is not int or origin.record_id <= 0:
+ raise ValidationError("Import identity evidence requires a saved review record.")
+ row = (
+ await session.execute(
+ select(
+ ImportedSeries.id,
+ ImportedSeries.import_job_id,
+ ImportedSeries.cv_id,
+ ImportedSeries.user_selected_cv_id,
+ ImportedSeries.cv_match_method,
+ ImportJob.source_type,
+ )
+ .join(ImportJob, ImportJob.id == ImportedSeries.import_job_id)
+ .where(ImportedSeries.id == origin.record_id)
+ )
+ ).one_or_none()
+ if row is None or (row.user_selected_cv_id or row.cv_id) != int(parent.external_id):
+ raise ValidationError("The saved import match changed. Reload the review before retrying.")
+ trusted_mylar = (
+ row.source_type == ImportSourceType.MYLAR3
+ and row.cv_match_method == "mylar3_cv_id"
+ and row.user_selected_cv_id is None
+ )
+ return _LocalEvidence(
+ IdentityEvidenceKind.MYLAR_DATABASE if trusted_mylar else IdentityEvidenceKind.MIGRATION,
+ IdentityEvidenceLocator(IdentityEvidenceRecordKind.IMPORTED_SERIES, row.id),
+ dict(row._mapping),
+ )
+
+
+def _json_value(value: object) -> str:
+ if isinstance(value, date):
+ return value.isoformat()
+ raise TypeError("Unsupported metadata snapshot value")
+
+
+def _request(
+ operation: UUID,
+ local_id: int,
+ identity: ExternalIdentityRef,
+ metadata: SeriesMetadata | IssueMetadata | IssueSummary | None,
+ local: _LocalEvidence | None,
+ parent: ExternalIdentityRef | None = None,
+) -> IdentityEventRequest:
+ snapshot = {
+ "metadata": asdict(metadata) if metadata is not None else None,
+ "local": local.snapshot if local is not None else None,
+ }
+ revision = hashlib.sha256(
+ json.dumps(
+ snapshot,
+ sort_keys=True,
+ separators=(",", ":"),
+ allow_nan=False,
+ default=_json_value,
+ ).encode()
+ ).hexdigest()
+ source = (
+ None
+ if local
+ else (
+ MetadataSource.COMICVINE_LOCAL
+ if isinstance(metadata, CatalogSeriesMetadata | CatalogIssueSummary)
+ else MetadataSource.COMICVINE_API
+ )
+ )
+ return IdentityEventRequest(
+ operation,
+ local_id,
+ IdentityVerificationAction.VERIFY,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(
+ identity, local.kind if local else IdentityEvidenceKind.PROVIDER_RESULT, source
+ ),
+ revision,
+ locator=local.locator if local else None,
+ source_identity=identity if source else None,
+ parent_identity=parent,
+ ),
+ )
+
+
+async def attach_series_metadata_identity(
+ session: AsyncSession,
+ series: Series,
+ metadata: SeriesMetadata,
+ origin: ImportIdentityOrigin | None,
+) -> None:
+ identity = comicvine_identity(MetadataEntityKind.SERIES, metadata.provider_id)
+ local = await _import_evidence(session, origin, identity)
+ await attach_verified_identities(
+ session, [_request(uuid4(), series.id, identity, metadata, local)]
+ )
+
+
+async def attach_issue_summary_identities(
+ session: AsyncSession,
+ series: Series,
+ members: list[tuple[Issue, IssueSummary]],
+ origin: ImportIdentityOrigin | None,
+) -> None:
+ await _attach_issue_identities(session, series, members, origin)
+
+
+async def attach_issue_metadata_identity(
+ session: AsyncSession,
+ series: Series,
+ issue: Issue,
+ metadata: IssueMetadata,
+) -> None:
+ parent = comicvine_identity(MetadataEntityKind.SERIES, metadata.series_provider_id)
+ identity = comicvine_identity(MetadataEntityKind.ISSUE, metadata.provider_id)
+ if series.comicvine_id != int(parent.external_id) or issue.comicvine_id != int(
+ identity.external_id
+ ):
+ raise IdentityAttachmentConflictError("Full issue metadata disagrees with its local target")
+ await _attach_issue_identities(session, series, [(issue, metadata)], None)
+
+
+async def _attach_issue_identities(
+ session: AsyncSession,
+ series: Series,
+ members: Sequence[tuple[Issue, IssueSummary | IssueMetadata]],
+ origin: ImportIdentityOrigin | None,
+) -> None:
+ if not members:
+ return
+ parent = comicvine_identity(MetadataEntityKind.SERIES, str(series.comicvine_id))
+ local = await _import_evidence(session, origin, parent)
+ operation = uuid4()
+ requests = []
+ existing = await session.scalar(
+ select(SeriesExternalIdentity).where(
+ SeriesExternalIdentity.series_id == series.id,
+ SeriesExternalIdentity.identity_namespace == IdentityNamespace.COMICVINE,
+ )
+ )
+ if existing is None:
+ # Existing legacy rows need an honest local backfill, not invented API evidence.
+ parent_local = local or _LocalEvidence(
+ IdentityEvidenceKind.LEGACY_BACKFILL,
+ IdentityEvidenceLocator(IdentityEvidenceRecordKind.SERIES, series.id),
+ {"id": series.id, "comicvine_id": series.comicvine_id},
+ )
+ requests.append(_request(operation, series.id, parent, None, parent_local))
+ elif (
+ existing.external_id != parent.external_id
+ or existing.verification_state != IdentityVerificationState.VERIFIED
+ ):
+ raise IdentityAttachmentConflictError("Issue catalog requires a verified series identity")
+ requests.extend(
+ _request(
+ operation,
+ issue.id,
+ comicvine_identity(MetadataEntityKind.ISSUE, summary.provider_id),
+ summary,
+ local,
+ parent,
+ )
+ for issue, summary in members
+ )
+ await attach_verified_identities(session, requests)
diff --git a/src/pullbox/services/provider_artwork.py b/src/pullbox/services/provider_artwork.py
new file mode 100644
index 00000000..55fba0cb
--- /dev/null
+++ b/src/pullbox/services/provider_artwork.py
@@ -0,0 +1,207 @@
+"""Provider-independent downloading of public metadata artwork."""
+
+import asyncio
+import hashlib
+import io
+import tempfile
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from pathlib import Path
+from urllib.parse import unquote, urlsplit
+from uuid import uuid4
+
+import httpx
+import structlog
+from PIL import Image
+
+from pullbox.core.issue_numbers import normalize_issue_number_text
+
+logger = structlog.get_logger(__name__)
+MAX_ARTWORK_BYTES = 16 * 1024 * 1024
+MAX_ARTWORK_PIXELS = 20_000_000
+
+
+def allowed_artwork_url(url: str) -> bool:
+ """Accept only supported public provider artwork, for browser and server use."""
+ if len(url) > 4096 or any(char.isspace() or ord(char) < 32 for char in url):
+ return False
+ try:
+ parsed = urlsplit(url)
+ if (
+ parsed.scheme != "https"
+ or parsed.port not in {None, 443}
+ or parsed.username
+ or parsed.password
+ or parsed.query
+ or parsed.fragment
+ ):
+ return False
+ path = unquote(parsed.path)
+ if "\\" in path or any(ord(char) < 32 for char in path) or ".." in path.split("/"):
+ return False
+ host = parsed.hostname or ""
+ if host in {"metron.cloud", "static.metron.cloud"}:
+ return path.startswith("/media/")
+ return host in {"comicvine.gamespot.com", "comicvine.com"} or host.endswith(
+ ".cbsistatic.com"
+ )
+ except ValueError:
+ return False
+
+
+def _jpeg_payload(data: bytes) -> bytes:
+ with Image.open(io.BytesIO(data)) as image:
+ if (
+ image.format not in {"JPEG", "PNG", "WEBP"}
+ or image.width * image.height > MAX_ARTWORK_PIXELS
+ ):
+ raise ValueError("Unsupported artwork format or dimensions")
+ image.verify()
+ with Image.open(io.BytesIO(data)) as image:
+ image.load()
+ if image.format == "JPEG":
+ return data
+ output = io.BytesIO()
+ with image.convert("RGB") as converted:
+ converted.save(output, format="JPEG", quality=90)
+ result = output.getvalue()
+ if len(result) > MAX_ARTWORK_BYTES:
+ raise ValueError("Artwork exceeds cache size limit")
+ return result
+
+
+def _stage_cover(destination: Path, data: bytes) -> Path:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ stage: Path | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ dir=destination.parent,
+ prefix=".cover-",
+ delete=False,
+ ) as file:
+ stage = Path(file.name)
+ file.write(data)
+ except BaseException:
+ if stage is not None:
+ stage.unlink(missing_ok=True)
+ raise
+ return stage
+
+
+async def _publish_cover(destination: Path, data: bytes) -> None:
+ # A cancelled thread may still finish its write. Join it, then remove its stage.
+ worker = asyncio.create_task(asyncio.to_thread(_stage_cover, destination, data))
+ stage: Path | None = None
+ try:
+ stage = await asyncio.shield(worker)
+ stage.replace(destination)
+ except asyncio.CancelledError:
+ while not worker.done():
+ try:
+ await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ continue
+ except Exception:
+ break
+ if not worker.cancelled() and worker.exception() is None:
+ stage = worker.result()
+ raise
+ finally:
+ if stage is not None:
+ stage.unlink(missing_ok=True)
+
+
+class ProviderArtworkClient:
+ """Reuse an unauthenticated HTTP client for allowlisted public CDN artwork."""
+
+ def __init__(self, *, transport: httpx.AsyncBaseTransport | None = None) -> None:
+ self._client = httpx.AsyncClient(
+ timeout=10.0,
+ transport=transport,
+ follow_redirects=False,
+ headers={"User-Agent": "Pullbox", "Accept-Encoding": "identity"},
+ )
+
+ async def __aenter__(self) -> "ProviderArtworkClient":
+ return self
+
+ async def __aexit__(self, *exc_info: object) -> None:
+ await self._client.aclose()
+
+ async def download_cover(self, url: str, destination: Path) -> bool:
+ """Publish a verified JPEG atomically, leaving any old cover on failure."""
+ if not allowed_artwork_url(url):
+ logger.warning("provider_artwork_url_rejected")
+ return False
+ try:
+ self._client.cookies.clear()
+ async with asyncio.timeout(20):
+ async with self._client.stream("GET", url) as response:
+ response.raise_for_status()
+ # Refuse HTTP decompression before a decoded chunk can exceed our cap.
+ if (
+ response.headers.get("content-encoding", "identity").strip().lower()
+ != "identity"
+ ):
+ raise ValueError("Unsupported artwork content encoding")
+ if response.headers.get("content-type", "").split(";", 1)[
+ 0
+ ].strip().lower() not in {
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "application/octet-stream",
+ }:
+ raise ValueError("Unsupported artwork content type")
+ length = response.headers.get("content-length")
+ if length is not None and not 0 <= int(length) <= MAX_ARTWORK_BYTES:
+ raise ValueError("Artwork exceeds download size limit")
+ content = bytearray()
+ async for chunk in response.aiter_bytes(64 * 1024):
+ if len(content) + len(chunk) > MAX_ARTWORK_BYTES:
+ raise ValueError("Artwork exceeds download size limit")
+ content.extend(chunk)
+ payload = await asyncio.to_thread(_jpeg_payload, bytes(content))
+ await _publish_cover(destination, payload)
+ except (
+ httpx.HTTPError,
+ OSError,
+ ValueError,
+ TimeoutError,
+ Image.DecompressionBombError,
+ ) as exc:
+ logger.warning("provider_artwork_download_failed", error_type=type(exc).__name__)
+ return False
+ return True
+
+
+@asynccontextmanager
+async def pending_provider_cover(
+ client: ProviderArtworkClient,
+ url: str,
+ destination: Path,
+) -> AsyncIterator[Path | None]:
+ """Keep new artwork invisible until the caller revalidates its DB snapshot."""
+ pending = destination.with_name(f".{uuid4().hex}.{destination.name}")
+ try:
+ yield pending if await client.download_cover(url, pending) else None
+ finally:
+ pending.unlink(missing_ok=True)
+
+
+def issue_cover_stem(issue_number: float, issue_number_text: str | None = None) -> str:
+ """Retain unambiguous legacy names; distinguish suffixes and precise decimals."""
+ raw = issue_number_text or str(issue_number)
+ try:
+ exact = normalize_issue_number_text(raw)
+ number = (
+ f"{int(issue_number):03d}"
+ if issue_number == int(issue_number)
+ else f"{issue_number:06.1f}"
+ )
+ if normalize_issue_number_text(number) == exact:
+ return f"issue_{number}"
+ except (ValueError, OverflowError):
+ exact = raw
+ # Cache identity only, not a credential; bounded for arbitrary issue labels.
+ return "issue_exact_" + hashlib.sha256(exact.encode("utf-8")).hexdigest()
diff --git a/src/pullbox/services/series_metadata_links.py b/src/pullbox/services/series_metadata_links.py
new file mode 100644
index 00000000..76983ad1
--- /dev/null
+++ b/src/pullbox/services/series_metadata_links.py
@@ -0,0 +1,259 @@
+"""Explicit series links reuse saved claims; they never adopt issues or touch files."""
+
+import hashlib
+import json
+from uuid import uuid4
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import NotFoundError
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.core.metadata_identity_events import IdentityEventEvidence, IdentityEventRequest
+from pullbox.core.metadata_identity_state import IdentityVerificationAction as Action
+from pullbox.models import Series
+from pullbox.models.metadata_identity import SeriesExternalIdentity, SeriesIdentityEvent
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.models.publisher import Publisher
+from pullbox.schemas.metadata_identity_review import IdentityReviewRead, IdentityReviewReceiptRead
+from pullbox.schemas.metadata_sources import SeriesPreviewQuery, SourceCapability, SourceStatus
+from pullbox.schemas.series_metadata_links import (
+ SeriesLinkConfirm,
+ SeriesLinkCurrent,
+ SeriesLinkIdentity,
+ SeriesLinkPreview,
+ SeriesLinkSource,
+ SeriesLinksRead,
+)
+from pullbox.services.metadata_discovery import MetadataSourceRegistry, describe_source_policies
+from pullbox.services.metadata_identity_review import (
+ apply_identity_review,
+ preview_identity_review,
+ record_identity_observation,
+)
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_sources import load_source_runtime, read_source_policies
+from pullbox.services.metadata_writer_identity import metadata_write_scope
+
+SOURCE_LABELS = {
+ MetadataSource.COMICVINE_LOCAL: "ComicVine Local",
+ MetadataSource.COMICVINE_API: "ComicVine API",
+ MetadataSource.METRON_API: "Metron",
+ MetadataSource.GCD_LOCAL: "GCD Local",
+ MetadataSource.GCD_API_V2: "GCD API v2",
+}
+
+
+async def _current(session: AsyncSession, series_id: int) -> SeriesLinkCurrent:
+ row = (
+ await session.execute(
+ select(Series.title, Series.year_start, Publisher.name, Series.issue_count)
+ .outerjoin(Publisher, Publisher.id == Series.publisher_id)
+ .where(Series.id == series_id)
+ )
+ ).one_or_none()
+ if row is None:
+ raise NotFoundError("Series", series_id)
+ return SeriesLinkCurrent(
+ title=row.title, year_start=row.year_start, publisher=row.name, issue_count=row.issue_count
+ )
+
+
+async def read_series_links(
+ session: AsyncSession, series_id: int, *, gcd_api_enabled: bool
+) -> SeriesLinksRead:
+ current = await _current(session, series_id)
+ identities = [
+ SeriesLinkIdentity(
+ namespace=item.identity_namespace,
+ external_id=item.external_id,
+ state=item.verification_state,
+ )
+ for item in await session.scalars(
+ select(SeriesExternalIdentity)
+ .where(SeriesExternalIdentity.series_id == series_id)
+ .order_by(SeriesExternalIdentity.identity_namespace)
+ )
+ ]
+ owned = {item.namespace for item in identities}
+ sources = describe_source_policies(
+ await read_source_policies(session), gcd_api_enabled=gcd_api_enabled
+ )
+ return SeriesLinksRead(
+ current=current,
+ identities=identities,
+ sources=[
+ SeriesLinkSource(source=item.source, label=SOURCE_LABELS[item.source])
+ for item in sources
+ if item.enabled
+ and item.availability is None
+ and item.source.identity_namespace not in owned
+ and SourceCapability.SERIES_SEARCH in item.capabilities
+ ],
+ )
+
+
+async def _other_claims(
+ session: AsyncSession, series_id: int, source: MetadataSource
+) -> list[tuple[str, str, str, int]]:
+ claims = [
+ (row.identity_namespace.value, row.external_id, row.verification_state.value, row.revision)
+ for row in await session.scalars(
+ select(SeriesExternalIdentity)
+ .where(
+ SeriesExternalIdentity.series_id == series_id,
+ SeriesExternalIdentity.identity_namespace != source.identity_namespace,
+ )
+ .order_by(SeriesExternalIdentity.identity_namespace)
+ )
+ ]
+ legacy = await session.scalar(select(Series.comicvine_id).where(Series.id == series_id))
+ if legacy and source.identity_namespace is not IdentityNamespace.COMICVINE:
+ claims.append((IdentityNamespace.COMICVINE.value, str(legacy), "legacy", 0))
+ return claims
+
+
+def _revision(
+ current: SeriesLinkCurrent,
+ source: MetadataSource,
+ revision: int,
+ claims: list[tuple[str, str, str, int]],
+) -> str:
+ # A review-freshness digest, never an authorization token or credential.
+ payload = {
+ "purpose": "series-link-v1",
+ "current": current.model_dump(),
+ "source": source.value,
+ "revision": revision,
+ "other_claims": claims,
+ }
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
+
+
+async def _lock(session: AsyncSession, series_id: int) -> None:
+ await session.execute(
+ select(MetadataSourceConfig)
+ .order_by(MetadataSourceConfig.source)
+ .with_for_update(read=True)
+ )
+ await session.execute(select(Series.id).where(Series.id == series_id).with_for_update())
+
+
+async def preview_series_link(
+ session: AsyncSession, series_id: int, query: SeriesPreviewQuery, *, gcd_api_enabled: bool
+) -> SeriesLinkPreview:
+ current = await _current(session, series_id)
+ runtime = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled)
+ registry = MetadataSourceRegistry(
+ runtime, gcd_api_enabled=gcd_api_enabled, read_cache=source_read_cache(session)
+ )
+ source = registry.runtime[query.source]
+ revision = source.policy.revision
+ claims = await _other_claims(session, series_id, query.source)
+ await session.rollback()
+ result = await registry.series(query.source, query.external_id)
+ if result.status is not SourceStatus.OK or result.data is None:
+ raise ValueError(
+ "This provider could not load the series. Check Metadata settings or try again later."
+ )
+ profile = result.data
+ if profile.source is not query.source or profile.external_id != query.external_id:
+ raise ValueError("The provider returned a different series. Search again before linking.")
+ if any(
+ cross.entity_kind is not MetadataEntityKind.SERIES
+ or any(
+ namespace == cross.namespace.value and external_id != cross.external_id
+ for namespace, external_id, *_ in claims
+ )
+ for cross in profile.cross_identities
+ ):
+ raise ValueError(
+ "This provider identifies a different series than your existing match. "
+ "Choose another result; no links were changed."
+ )
+ async with metadata_write_scope(session):
+ await _lock(session, series_id)
+ refreshed = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled)
+ updated = MetadataSourceRegistry(refreshed, gcd_api_enabled=gcd_api_enabled)
+ if (
+ updated.runtime[query.source].policy.revision != revision
+ or updated._unavailable(query.source, capability=SourceCapability.SERIES_DETAILS)
+ or current != await _current(session, series_id)
+ or claims != await _other_claims(session, series_id, query.source)
+ ):
+ raise ValueError("The series or source settings changed. Search again before linking.")
+ identity = ExternalIdentityRef(
+ query.source.identity_namespace, MetadataEntityKind.SERIES, query.external_id
+ )
+ saved = await record_identity_observation(
+ session,
+ IdentityEventRequest(
+ uuid4(),
+ series_id,
+ Action.OBSERVE,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(
+ identity, IdentityEvidenceKind.PROVIDER_RESULT, query.source
+ ),
+ _revision(current, query.source, revision, claims),
+ source_identity=identity,
+ ),
+ ),
+ )
+ review = IdentityReviewRead.model_validate(
+ await preview_identity_review(
+ session, MetadataEntityKind.SERIES, series_id, saved.event_id
+ )
+ )
+ return SeriesLinkPreview(
+ current=current, candidate=profile, source_revision=revision, review=review
+ )
+
+
+async def confirm_series_link(
+ session: AsyncSession,
+ series_id: int,
+ body: SeriesLinkConfirm,
+ *,
+ user_id: int,
+ gcd_api_enabled: bool,
+) -> IdentityReviewReceiptRead:
+ async with metadata_write_scope(session):
+ await _lock(session, series_id)
+ event = await session.get(SeriesIdentityEvent, body.event_id)
+ if event is None or event.series_id != series_id:
+ raise ValueError("This saved match no longer exists. Search again before linking.")
+ try:
+ evidence = json.loads(event.request_json)
+ source = MetadataSource(evidence["origin"]["source_instance"])
+ digest = evidence["evidence_revision"]
+ except (ValueError, KeyError, TypeError) as exc:
+ raise ValueError("Search and preview this provider match before linking it.") from exc
+ current = await _current(session, series_id)
+ claims = await _other_claims(session, series_id, source)
+ runtime = await load_source_runtime(session, gcd_api_enabled=gcd_api_enabled)
+ registry = MetadataSourceRegistry(runtime, gcd_api_enabled=gcd_api_enabled)
+ if (
+ registry.runtime[source].policy.revision != body.source_revision
+ or registry._unavailable(source, capability=SourceCapability.SERIES_DETAILS)
+ or digest != _revision(current, source, body.source_revision, claims)
+ ):
+ raise ValueError("The series or source settings changed. Search again before linking.")
+ receipt = await apply_identity_review(
+ session,
+ MetadataEntityKind.SERIES,
+ series_id,
+ body.event_id,
+ action=Action.CONFIRM,
+ fingerprint=body.fingerprint,
+ review_revision=body.review_revision,
+ actor_user_id=user_id,
+ )
+ return IdentityReviewReceiptRead(event_id=receipt.event_id, replayed=receipt.replayed)
diff --git a/src/pullbox/services/series_service.py b/src/pullbox/services/series_service.py
index 2b279538..8682b2a8 100644
--- a/src/pullbox/services/series_service.py
+++ b/src/pullbox/services/series_service.py
@@ -14,6 +14,7 @@
from pullbox.core.events import EventBus, IssueWanted, SeriesAdded
from pullbox.core.exceptions import NotFoundError, ValidationError
+from pullbox.core.file_publication import rename_path_without_overwrite
from pullbox.core.library_file_ownership import (
referenced_library_files_for_target,
require_mutable_library_target,
@@ -41,7 +42,13 @@
find_imported_series_cover,
purge_series_cover_cache,
)
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+ require_no_archive_publication,
+)
from pullbox.services.library_root_management import validate_managed_library_root
+from pullbox.services.metadata_writer_identity import ImportIdentityOrigin
from pullbox.services.series_delete_targets import (
SeriesDeleteContext as SeriesDeleteContext,
)
@@ -62,6 +69,7 @@
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
+ from pullbox.core.metadata_identity import ExternalIdentityRef
from pullbox.models.import_job import ImportedSeries
from pullbox.services.metadata_service import MetadataService
@@ -75,6 +83,7 @@ class SeriesFolderCreationResult:
folder_path: Path
ownership_boundary_path: Path
created_directory_paths: tuple[Path, ...]
+ created_directory_identities: tuple[tuple[Path, int, int], ...] = ()
def ownership_payload(self) -> dict[str, object]:
return {
@@ -88,6 +97,8 @@ def ownership_payload(self) -> dict[str, object]:
def _create_series_folder_directories(
folder_path: Path,
ownership_boundary_path: Path,
+ *,
+ exclusive_leaf: bool = False,
) -> tuple[Path, ...]:
"""Create and return only strict descendant directory segments created here."""
relative = folder_path.relative_to(ownership_boundary_path)
@@ -106,6 +117,8 @@ def _create_series_folder_directories(
try:
current.mkdir()
except FileExistsError:
+ if exclusive_leaf and current == folder_path:
+ raise
if not current.is_dir():
raise
else:
@@ -126,6 +139,56 @@ def _create_series_folder_directories(
return tuple(created)
+def rollback_series_folder_creation(result: SeriesFolderCreationResult) -> None:
+ """Remove only still-owned, empty directories; never remove library contents."""
+ for path, device, inode in reversed(result.created_directory_identities):
+ try:
+ if path.is_symlink() or path.resolve(strict=True) != path:
+ continue
+ path.relative_to(result.ownership_boundary_path)
+ current = path.stat()
+ if path != result.ownership_boundary_path and (current.st_dev, current.st_ino) == (
+ device,
+ inode,
+ ):
+ path.rmdir()
+ except OSError:
+ logger.debug("series_folder_rollback_retained_directory", path=str(path))
+ except ValueError:
+ logger.warning("series_folder_rollback_boundary_changed", path=str(path))
+
+
+async def _create_series_folder_owned(
+ folder_path: Path, root_path: Path, *, exclusive_leaf: bool
+) -> SeriesFolderCreationResult:
+ def create() -> SeriesFolderCreationResult:
+ paths = _create_series_folder_directories(
+ folder_path, root_path, exclusive_leaf=exclusive_leaf
+ )
+ return SeriesFolderCreationResult(
+ folder_path.resolve(strict=True),
+ root_path.resolve(strict=True),
+ paths,
+ tuple((path, stat.st_dev, stat.st_ino) for path in paths for stat in [path.stat()]),
+ )
+
+ # Cancellation cannot stop mkdir in a worker. Collect its receipt before cleanup.
+ worker = asyncio.create_task(asyncio.to_thread(create))
+ try:
+ return await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ while not worker.done():
+ try:
+ await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ continue
+ except Exception:
+ break
+ if not worker.cancelled() and worker.exception() is None:
+ rollback_series_folder_creation(worker.result())
+ raise
+
+
def _targeted_import_folder_type_hint(
series: Series,
issue_summaries: list[IssueSummary],
@@ -269,7 +332,13 @@ async def add_from_import_review_targeted(
cached_meta = await cached_lookup(self._metadata, cv_id)
if cached_meta is not None:
series_meta = cached_meta
- series = await self._metadata.upsert_series_metadata(session, cv_id, series_meta)
+ identity_origin = ImportIdentityOrigin(import_series.id)
+ series = await self._metadata.upsert_series_metadata(
+ session,
+ cv_id,
+ series_meta,
+ identity_origin=identity_origin,
+ )
series.monitored = search_on_add
series.issue_catalog_state = IssueCatalogState.HYDRATING
series.issue_catalog_error = None
@@ -322,7 +391,12 @@ async def add_from_import_review_targeted(
import_series.diagnostics = diagnostics
if issue_summaries:
- await self._metadata.upsert_issue_summaries(session, series, issue_summaries)
+ await self._metadata.upsert_issue_summaries(
+ session,
+ series,
+ issue_summaries,
+ identity_origin=identity_origin,
+ )
await session.flush()
return series
@@ -489,14 +563,15 @@ async def _load_naming_config(
"colon_replacement": policy.colon_replacement,
}
+ @staticmethod
async def _create_series_folder(
- self,
session: AsyncSession,
series: Series,
library_root_id: int,
- comicvine_id: int,
+ comicvine_id: int | None,
*,
folder_series_type: SeriesType | None = None,
+ source_identity: ExternalIdentityRef | None = None,
) -> SeriesFolderCreationResult:
"""Create a series folder on disk inside the given library root.
@@ -510,7 +585,7 @@ async def _create_series_folder(
await validate_managed_library_root(root)
# Load naming config
- cfg = await self._load_naming_config(session, root)
+ cfg = await SeriesService._load_naming_config(session, root)
# Resolve publisher name for the template
if series.publisher_id:
@@ -527,7 +602,7 @@ async def _create_series_folder(
folder_path = root_path / folder_relative
# Reclaim empty/progress-only collision folders left behind by prior interrupted work.
- if folder_path.exists():
+ if source_identity is None and folder_path.exists():
reclaimed = await asyncio.to_thread(reclaim_transient_series_folder, folder_path)
if reclaimed:
logger.warning(
@@ -537,8 +612,13 @@ async def _create_series_folder(
)
# Handle collision: append [cv-{id}] if folder already exists
- if folder_path.exists():
- folder_name_cv = f"{folder_path.name} [cv-{comicvine_id}]"
+ if folder_path.exists() or folder_path.is_symlink():
+ suffix = (
+ f"{source_identity.namespace.value}-{source_identity.external_id}"
+ if source_identity is not None and source_identity.namespace.value != "comicvine"
+ else f"cv-{comicvine_id}"
+ )
+ folder_name_cv = f"{folder_path.name} [{suffix}]"
folder_path = folder_path.with_name(folder_name_cv)
logger.info(
"series_folder_collision",
@@ -550,10 +630,10 @@ async def _create_series_folder(
# Create each missing segment independently so import rollback can
# distinguish directories created here from pre-existing user paths.
try:
- created_directory_paths = await asyncio.to_thread(
- _create_series_folder_directories,
+ receipt = await _create_series_folder_owned(
folder_path,
root_path,
+ exclusive_leaf=source_identity is not None,
)
except (OSError, ValueError) as exc:
raise ValidationError("Series folder must be created inside its library root") from exc
@@ -568,11 +648,7 @@ async def _create_series_folder(
series_title=series.title,
library_root=root.name,
)
- return SeriesFolderCreationResult(
- folder_path=folder_path.resolve(strict=True),
- ownership_boundary_path=root_path.resolve(strict=True),
- created_directory_paths=created_directory_paths,
- )
+ return receipt
@staticmethod
async def _build_series_folder_name(
@@ -608,6 +684,7 @@ async def rename_series_folder(
Returns:
The new path if renamed, or ``None`` if no rename was needed.
"""
+ await lock_file_mutation_admission(session)
series = await session.get(Series, series_id)
if not series or not series.path or not series.library_root_id:
return None
@@ -633,6 +710,13 @@ async def rename_series_folder(
if current_path == expected_path:
return None
+ await require_no_archive_publication(
+ session,
+ current_path,
+ expected_path,
+ expected_path.with_name(f"{expected_name} [cv-{series.comicvine_id}]"),
+ include_descendants=True,
+ )
await require_mutable_library_target(
session,
current_path,
@@ -666,8 +750,12 @@ async def rename_series_folder(
# Rename on disk
if current_path.is_dir():
try:
- await asyncio.to_thread(expected_path.parent.mkdir, parents=True, exist_ok=True)
- await asyncio.to_thread(current_path.rename, expected_path)
+
+ def rename() -> None:
+ expected_path.parent.mkdir(parents=True, exist_ok=True)
+ rename_path_without_overwrite(current_path, expected_path)
+
+ await finish_short_mutation(asyncio.create_task(asyncio.to_thread(rename)))
series.path = str(expected_path)
logger.info(
"series_folder_renamed",
diff --git a/src/pullbox/services/series_sidecar.py b/src/pullbox/services/series_sidecar.py
new file mode 100644
index 00000000..dc2499d7
--- /dev/null
+++ b/src/pullbox/services/series_sidecar.py
@@ -0,0 +1,515 @@
+"""Explicit, bounded sidecar writes; no provider calls or archive mutations."""
+
+import asyncio
+import hashlib
+import json
+import os
+import stat
+from collections.abc import Iterator, Sequence
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from uuid import uuid4
+
+from pydantic import TypeAdapter
+from sqlalchemy import or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.exceptions import ValidationError
+from pullbox.core.metadata_identity import MetadataEntityKind
+from pullbox.core.series_sidecar import MAX_SIDECAR_BYTES, render_series_sidecar
+from pullbox.models import Issue, LibraryFile, LibraryRoot, Series
+from pullbox.models.library import LibraryFileStorageMode
+from pullbox.schemas.metadata_snapshot import MetadataSnapshot
+from pullbox.schemas.series_sidecar import (
+ SeriesSidecarPreview,
+ SeriesSidecarTargetRead,
+ SeriesSidecarWriteRead,
+)
+from pullbox.services.archive_metadata_binding import _require_verified
+from pullbox.services.library_mutation_coordination import (
+ finish_short_mutation,
+ lock_file_mutation_admission,
+ require_no_archive_publication,
+)
+from pullbox.services.metadata_assembly import assemble_metadata
+from pullbox.services.metadata_series_refresh_state import read_series_refresh_state
+from pullbox.utilities.import_guards import ensure_no_active_import_file_mutation
+
+_MAX_FILES = 10000
+_MAX_FOLDERS = 50
+_COMIC_EXTENSIONS = {".cbz", ".zip", ".cbr", ".rar", ".cb7", ".7z", ".cbt", ".pdf", ".epub"}
+
+
+@dataclass(frozen=True)
+class SidecarLocation:
+ directory: str
+ root: str
+ managed: bool
+ files: tuple[str, ...]
+ reason: str | None = None
+
+
+@dataclass(frozen=True)
+class SidecarBinding:
+ series_id: int
+ snapshot: MetadataSnapshot
+ aliases: tuple[str, ...]
+ updated_at: datetime
+ locations: tuple[SidecarLocation, ...]
+ evidence: bytes
+
+
+@dataclass(frozen=True)
+class SidecarTarget:
+ location: SidecarLocation
+ directories: tuple[tuple[int, int, int], ...]
+ previous: bytes | None
+ fingerprint: tuple[int, ...] | None
+ content: bytes
+ read: SeriesSidecarTargetRead
+
+
+@dataclass(frozen=True)
+class PreparedSeriesSidecar:
+ binding: SidecarBinding
+ targets: tuple[SidecarTarget, ...]
+ preview: SeriesSidecarPreview
+
+
+async def _binding(session: AsyncSession, series_id: int) -> SidecarBinding:
+ if session.new or session.dirty or session.deleted:
+ raise ValueError("Finish pending library changes before previewing series metadata.")
+ state = await read_series_refresh_state(session, series_id, include_issues=False)
+ _require_verified(state.series)
+ series = await session.get(Series, series_id, populate_existing=True)
+ assert series is not None
+ snapshot = assemble_metadata(
+ MetadataEntityKind.SERIES,
+ state.series.identities,
+ (),
+ state.policies,
+ now=series.updated_at,
+ current=state.series.values,
+ previous=state.series.baseline,
+ overrides=state.series.overrides,
+ )
+ rows = (
+ await session.execute(
+ select(LibraryFile.file_path, LibraryFile.library_root_id, LibraryFile.storage_mode)
+ .join(Issue, Issue.id == LibraryFile.issue_id)
+ .where(Issue.series_id == series_id)
+ .order_by(LibraryFile.id)
+ .limit(_MAX_FILES + 1)
+ )
+ ).all()
+ if len(rows) > _MAX_FILES:
+ raise ValueError(
+ "This series exceeds the bounded sidecar limit of 10,000 registered files."
+ )
+ paths = {str(Path(path).parent): root_id for path, root_id, _ in rows}
+ if series.path and series.library_root_id:
+ paths[series.path] = series.library_root_id
+ if len(paths) > _MAX_FOLDERS:
+ raise ValueError("This series exceeds the bounded sidecar limit of 50 folders.")
+ roots = {
+ root.id: root
+ for root in await session.scalars(select(LibraryRoot).order_by(LibraryRoot.id).limit(1001))
+ }
+ if len(roots) > 1000:
+ raise ValueError("Too many library roots to safely resolve series sidecar locations.")
+ related: Sequence[tuple[str, LibraryFileStorageMode, int | None]] = ()
+ if paths:
+ related = (
+ (
+ await session.execute(
+ select(LibraryFile.file_path, LibraryFile.storage_mode, Issue.series_id)
+ .outerjoin(Issue, Issue.id == LibraryFile.issue_id)
+ .where(
+ or_(
+ *(
+ LibraryFile.file_path.startswith(
+ path.rstrip("/") + "/", autoescape=True
+ )
+ for path in paths
+ )
+ )
+ )
+ .order_by(LibraryFile.id)
+ .limit(_MAX_FILES + 1)
+ )
+ )
+ .tuples()
+ .all()
+ )
+ if len(related) > _MAX_FILES:
+ raise ValueError("The selected folders contain too many files for a safe sidecar preview.")
+ other_locations = (
+ tuple(
+ await session.scalars(
+ select(Series.path)
+ .where(
+ Series.id != series_id,
+ or_(
+ Series.path.in_(paths),
+ *(
+ Series.path.startswith(path.rstrip("/") + "/", autoescape=True)
+ for path in paths
+ ),
+ ),
+ )
+ .order_by(Series.path, Series.id)
+ .limit(_MAX_FOLDERS + 1)
+ )
+ )
+ if paths
+ else ()
+ )
+ locations = []
+ for directory, root_id in sorted(paths.items()):
+ root = roots.get(root_id)
+ reason = None
+ if root is None or not root.enabled or not root.allow_managed_writes:
+ reason = (
+ "This root does not allow managed writes. Files kept in place are not modified."
+ )
+ children = [
+ (path, mode, owner)
+ for path, mode, owner in related
+ if Path(path).is_relative_to(directory)
+ ]
+ if any(mode is LibraryFileStorageMode.REFERENCED for _, mode, _ in children):
+ reason = "Files kept in place are not modified. Use a separate managed series folder."
+ elif any(owner != series_id for _, _, owner in children):
+ reason = (
+ "This folder contains files from other or unmatched series. "
+ "Organize it before writing a series sidecar."
+ )
+ matches = [
+ item
+ for item in roots.values()
+ if item.enabled and Path(directory).is_relative_to(item.path)
+ ]
+ if any(
+ path is not None and Path(path).is_relative_to(directory) for path in other_locations
+ ):
+ reason = "Another series uses this folder. Organize it before writing a series sidecar."
+ if root and (
+ Path(directory) == Path(root.path) or len(matches) != 1 or matches[0].id != root_id
+ ):
+ reason = (
+ "This is a shared or ambiguous library location. "
+ "Choose an individual managed series folder."
+ )
+ locations.append(
+ SidecarLocation(
+ directory,
+ root.path if root else "",
+ reason is None,
+ tuple(sorted(path for path, _, owner in children if owner == series_id)),
+ reason,
+ )
+ )
+ evidence = TypeAdapter(type(state)).dump_json(state)
+ evidence += json.dumps(
+ (
+ series.path,
+ series.library_root_id,
+ series.alternate_names,
+ [
+ (root.id, root.path, root.enabled, root.allow_managed_writes)
+ for root in roots.values()
+ ],
+ [tuple(row) for row in related],
+ other_locations,
+ ),
+ sort_keys=True,
+ ).encode()
+ return SidecarBinding(
+ series_id,
+ snapshot,
+ tuple(series.alternate_names),
+ series.updated_at,
+ tuple(locations),
+ evidence,
+ )
+
+
+@contextmanager
+def _directory(location: SidecarLocation) -> Iterator[tuple[int, tuple[tuple[int, int, int], ...]]]:
+ """Reject linked components and pin the publication parent with a descriptor."""
+ root, directory = Path(location.root), Path(location.directory)
+ if (
+ any(
+ not path.is_absolute()
+ or ".." in path.parts
+ or any(ord(c) < 32 or ord(c) == 127 for c in str(path))
+ for path in (root, directory)
+ )
+ or not directory.is_relative_to(root)
+ or directory == root
+ ):
+ raise ValueError("The series folder is outside its managed root or contains unsafe paths.")
+ flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
+ descriptor = os.open("/", flags)
+ fingerprints = []
+ try:
+ for part in directory.parts[1:]:
+ child = os.open(part, flags, dir_fd=descriptor)
+ os.close(descriptor)
+ descriptor = child
+ info = os.fstat(descriptor)
+ fingerprints.append((info.st_dev, info.st_ino, info.st_mode))
+ info = os.fstat(descriptor)
+ if not info.st_mode & 0o222 or not os.access(directory, os.W_OK | os.X_OK):
+ raise ValueError("The series folder is read-only. Check its managed-root permissions.")
+ yield descriptor, tuple(fingerprints)
+ finally:
+ os.close(descriptor)
+
+
+def _existing(descriptor: int) -> tuple[bytes | None, tuple[int, ...] | None]:
+ try:
+ file_descriptor = os.open(
+ "series.json", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor
+ )
+ except FileNotFoundError:
+ return None, None
+ with os.fdopen(file_descriptor, "rb") as stream:
+ info = os.fstat(stream.fileno())
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size > MAX_SIDECAR_BYTES:
+ raise ValueError(
+ "Existing series.json is linked, unsupported, or too large. Review it first."
+ )
+ if not info.st_mode & 0o222 or not os.access("series.json", os.W_OK, dir_fd=descriptor):
+ raise ValueError("Existing series.json is read-only. Check its permissions first.")
+ payload = stream.read(MAX_SIDECAR_BYTES + 1)
+ after = os.fstat(stream.fileno())
+ fingerprint = (
+ info.st_dev,
+ info.st_ino,
+ info.st_size,
+ info.st_mtime_ns,
+ info.st_ctime_ns,
+ info.st_mode,
+ )
+ if fingerprint != (
+ after.st_dev,
+ after.st_ino,
+ after.st_size,
+ after.st_mtime_ns,
+ after.st_ctime_ns,
+ after.st_mode,
+ ):
+ raise ValueError("Existing series.json changed while being read. Preview again.")
+ return payload, fingerprint
+
+
+def _require_registered_folder(descriptor: int, location: SidecarLocation) -> None:
+ registered = {
+ Path(path).name for path in location.files if Path(path).parent == Path(location.directory)
+ }
+ # Do not put a single-series identity on a folder of unregistered comics.
+ with os.scandir(descriptor) as entries:
+ for index, entry in enumerate(entries):
+ if entry.name.casefold() == "series.json" and entry.name != "series.json":
+ raise ValueError(
+ "This folder has a differently cased series.json. Review it first."
+ )
+ if index > _MAX_FILES or (
+ Path(entry.name).suffix.lower() in _COMIC_EXTENSIONS
+ and entry.name not in registered
+ ):
+ raise ValueError(
+ "This folder contains unregistered comic files. "
+ "Rescan or organize it before writing series metadata."
+ )
+
+
+def _inspect(binding: SidecarBinding, location: SidecarLocation) -> SidecarTarget:
+ if not location.managed:
+ return SidecarTarget(
+ location,
+ (),
+ None,
+ None,
+ b"",
+ SeriesSidecarTargetRead(
+ directory=location.directory, action="blocked", reason=location.reason
+ ),
+ )
+ try:
+ with _directory(location) as (descriptor, directories):
+ _require_registered_folder(descriptor, location)
+ previous, fingerprint = _existing(descriptor)
+ content = render_series_sidecar(
+ binding.snapshot, binding.aliases, previous, updated_at=binding.updated_at
+ )
+ unchanged = previous is not None and json.loads(previous) == json.loads(content)
+ read = SeriesSidecarTargetRead(
+ directory=location.directory,
+ action="unchanged" if unchanged else "update" if previous is not None else "create",
+ changes=[]
+ if unchanged
+ else [
+ "Compiled series fields and verified provider IDs",
+ "Saved field provenance and source freshness",
+ "Safe existing custom fields preserved",
+ ],
+ )
+ return SidecarTarget(location, directories, previous, fingerprint, content, read)
+ except (OSError, ValueError, RuntimeError) as exc:
+ message = (
+ str(exc)
+ if isinstance(exc, ValueError)
+ else "This folder or series.json is unavailable, linked, or not writable. "
+ "Check its location and permissions."
+ )
+ return SidecarTarget(
+ location,
+ (),
+ None,
+ None,
+ b"",
+ SeriesSidecarTargetRead(directory=location.directory, action="blocked", reason=message),
+ )
+
+
+async def prepare_series_sidecar(session: AsyncSession, series_id: int) -> PreparedSeriesSidecar:
+ binding = await _binding(session, series_id)
+ await session.commit()
+ targets = tuple(await asyncio.to_thread(_inspect_all, binding))
+ digest = hashlib.sha256(
+ binding.evidence
+ + TypeAdapter(SidecarBinding).dump_json(binding)
+ + TypeAdapter(tuple[SidecarTarget, ...]).dump_json(targets)
+ ).hexdigest()
+ return PreparedSeriesSidecar(
+ binding,
+ targets,
+ SeriesSidecarPreview(
+ series_id=series_id,
+ snapshot=binding.snapshot,
+ aliases=list(binding.aliases),
+ targets=[target.read for target in targets],
+ ready=any(target.read.action in {"create", "update"} for target in targets),
+ review_key=digest,
+ ),
+ )
+
+
+def _inspect_all(binding: SidecarBinding) -> list[SidecarTarget]:
+ return [_inspect(binding, location) for location in binding.locations]
+
+
+def _stage(target: SidecarTarget) -> tuple[int, str]:
+ with _directory(target.location) as (descriptor, directories):
+ if directories != target.directories or _existing(descriptor) != (
+ target.previous,
+ target.fingerprint,
+ ):
+ raise ValueError("The folder or sidecar changed after preview. Preview again.")
+ stage = f".pullbox-series-{uuid4().hex}.tmp"
+ stage_fd = os.open(
+ stage, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=descriptor
+ )
+ try:
+ with os.fdopen(stage_fd, "wb") as stream:
+ stream.write(target.content)
+ stream.flush()
+ os.fchmod(
+ stream.fileno(),
+ stat.S_IMODE(target.fingerprint[-1]) if target.fingerprint else 0o644,
+ )
+ os.fsync(stream.fileno())
+ return os.dup(descriptor), stage
+ except BaseException:
+ os.unlink(stage, dir_fd=descriptor)
+ raise
+
+
+def _publish(target: SidecarTarget, descriptor: int, stage: str) -> None:
+ with _directory(target.location) as (current, directories):
+ if (
+ directories != target.directories
+ or os.fstat(current).st_ino != os.fstat(descriptor).st_ino
+ or _existing(descriptor) != (target.previous, target.fingerprint)
+ ):
+ raise ValueError("The folder or sidecar changed after preview. Preview again.")
+ _require_registered_folder(descriptor, target.location)
+ # Existing files use atomic replacement; a new file must never overwrite a collision.
+ if target.previous is None:
+ os.link(
+ stage,
+ "series.json",
+ src_dir_fd=descriptor,
+ dst_dir_fd=descriptor,
+ follow_symlinks=False,
+ )
+ os.unlink(stage, dir_fd=descriptor)
+ else:
+ os.replace(stage, "series.json", src_dir_fd=descriptor, dst_dir_fd=descriptor)
+
+
+async def write_series_sidecar(
+ session: AsyncSession, series_id: int, review_key: str
+) -> SeriesSidecarWriteRead:
+ prepared = await prepare_series_sidecar(session, series_id)
+ if prepared.preview.review_key != review_key:
+ raise ValueError("The metadata, folders, or sidecars changed after preview. Preview again.")
+ if not prepared.preview.ready:
+ raise ValueError("No writable series sidecars need updating. Review the preview first.")
+ results = []
+ written = 0
+ for target in prepared.targets:
+ if target.read.action not in {"create", "update"}:
+ results.append(target.read)
+ continue
+ # Join the whole short item, including stage cleanup, before releasing its session.
+ result = await finish_short_mutation(
+ asyncio.create_task(_write_target(session, prepared.binding, target))
+ )
+ written += result.action == "unchanged"
+ results.append(result)
+ return SeriesSidecarWriteRead(
+ written=written,
+ unchanged=sum(target.read.action == "unchanged" for target in prepared.targets),
+ targets=results,
+ )
+
+
+async def _write_target(
+ session: AsyncSession, binding: SidecarBinding, target: SidecarTarget
+) -> SeriesSidecarTargetRead:
+ descriptor: int | None = None
+ stage = ""
+ try:
+ stage_descriptor, stage = await asyncio.to_thread(_stage, target)
+ descriptor = stage_descriptor
+ await lock_file_mutation_admission(session)
+ await ensure_no_active_import_file_mutation(session)
+ if await _binding(session, binding.series_id) != binding:
+ raise ValueError("The series metadata or locations changed. Preview again.")
+ await require_no_archive_publication(
+ session, Path(target.location.directory), include_descendants=True
+ )
+ await asyncio.to_thread(_publish, target, stage_descriptor, stage)
+ return target.read.model_copy(update={"action": "unchanged", "changes": []})
+ except (ValueError, ValidationError, OSError) as exc:
+ message = (
+ "Writing this sidecar failed. Its previous contents were preserved; "
+ "check permissions and free space."
+ if isinstance(exc, OSError)
+ else str(exc)
+ )
+ return target.read.model_copy(update={"action": "blocked", "reason": message})
+ finally:
+ await session.rollback()
+ if descriptor is not None:
+ try:
+ os.unlink(stage, dir_fd=descriptor)
+ except FileNotFoundError:
+ pass
+ finally:
+ os.close(descriptor)
diff --git a/src/pullbox/services/story_arc_catalog.py b/src/pullbox/services/story_arc_catalog.py
index e749cd7a..1436db3a 100644
--- a/src/pullbox/services/story_arc_catalog.py
+++ b/src/pullbox/services/story_arc_catalog.py
@@ -10,7 +10,7 @@
from sqlalchemy.exc import IntegrityError
from pullbox.core.issue_numbers import parse_issue_number_text
-from pullbox.models.issue import Issue
+from pullbox.core.metadata_identity import IdentityNamespace, MetadataEntityKind, MetadataSource
from pullbox.models.library import LibraryFile
from pullbox.models.story_arc import (
IssueStoryArc,
@@ -20,7 +20,25 @@
StoryArcResolutionState,
StoryArcSourceKind,
)
+from pullbox.services.metadata_arc_baselines import persist_arc_metadata
+from pullbox.services.metadata_arc_catalog import (
+ MAX_CATALOG_MEMBERS,
+ MAX_CATALOG_PARENTS,
+ project_source_arc_catalog,
+)
+from pullbox.services.metadata_arc_refresh import ArcMetadataRefresh, require_arc_refresh_state
+from pullbox.services.metadata_assembly import MetadataAssemblyError
+from pullbox.services.metadata_baselines import MetadataBaselineConflictError
+from pullbox.services.story_arc_catalog_evidence import (
+ record_catalog_crosswalks,
+ require_crosswalk_ownership,
+)
+from pullbox.services.story_arc_catalog_identity import (
+ catalog_owners,
+ require_catalog_source_revision,
+)
from pullbox.services.story_arc_catalog_persistence import (
+ attach_arc_identity,
canonical_root,
publisher_id,
seed_members,
@@ -31,8 +49,8 @@
StoryArcCatalogPreview,
StoryArcCatalogRefreshPreview,
StoryArcCatalogRefreshResult,
+ catalog_provider_id,
catalog_snapshot,
- exact_provider_id,
snapshot_fingerprint,
)
from pullbox.services.story_arc_placement_integration import (
@@ -49,10 +67,13 @@
from sqlalchemy.ext.asyncio import AsyncSession
+ from pullbox.models.issue import Issue
from pullbox.providers.base import IssueMetadata, SeriesMetadata
from pullbox.providers.story_arcs import StoryArcMetadata, StoryArcSearchResult
__all__ = [
+ "MAX_CATALOG_MEMBERS",
+ "MAX_CATALOG_PARENTS",
"StoryArcCatalogError",
"StoryArcCatalogPreview",
"StoryArcCatalogRefreshPreview",
@@ -60,9 +81,6 @@
"StoryArcCatalogService",
]
-MAX_CATALOG_MEMBERS = 2_000
-MAX_CATALOG_PARENTS = 200
-
class _CatalogProvider(Protocol):
async def search_story_arcs_page(
@@ -82,55 +100,56 @@ class StoryArcCatalogService:
dispatch searches. UI adapters commit adoption before scheduling acquisition.
"""
- def __init__(self, provider: _CatalogProvider) -> None:
+ def __init__(
+ self,
+ provider: _CatalogProvider | None = None,
+ *,
+ source: MetadataSource = MetadataSource.COMICVINE_API,
+ source_revision: int | None = None,
+ ) -> None:
self.provider = provider
+ self.source = source
+ self.source_revision = source_revision
self.domain = StoryArcService()
async def search(
self, query: str, *, limit: int = 20, offset: int = 0
) -> tuple[list[StoryArcSearchResult], int]:
+ if self.provider is None:
+ raise StoryArcCatalogError("provider_required", "Use the source registry to search")
return await self.provider.search_story_arcs_page(query, limit=limit, offset=offset)
async def find_existing(
self, session: AsyncSession, provider_ids: Sequence[str]
) -> dict[str, int]:
- ids = [exact_provider_id(value) for value in provider_ids]
- rows = await session.execute(
- select(StoryArc.comicvine_id, StoryArc.id).where(StoryArc.comicvine_id.in_(ids))
+ return await catalog_owners(
+ session, MetadataEntityKind.STORY_ARC, provider_ids, self.source
)
- existing = {str(provider_id): arc_id for provider_id, arc_id in rows}
- identities = await session.execute(
- select(
- StoryArcExternalIdentity.external_id, StoryArcExternalIdentity.story_arc_id
- ).where(
- StoryArcExternalIdentity.source == "comicvine",
- StoryArcExternalIdentity.namespace == "story_arc",
- StoryArcExternalIdentity.external_id.in_(provider_ids),
- )
- )
- for provider_id, arc_id in identities:
- if provider_id in existing and existing[provider_id] != arc_id:
- raise StoryArcCatalogError(
- "identity_conflict", "Provider story arc has conflicting local identities"
- )
- existing[provider_id] = arc_id
- return existing
async def preview(
self, provider_id: str, *, known_series_provider_ids: Collection[str] = ()
) -> StoryArcCatalogPreview:
- exact_provider_id(provider_id)
+ if self.provider is None:
+ raise StoryArcCatalogError("provider_required", "Use the source registry to preview")
+ catalog_provider_id(provider_id, self.source)
metadata = await self.provider.get_story_arc(provider_id)
if metadata.provider_id != provider_id:
raise StoryArcCatalogError(
"identity_conflict", "Provider returned a different story arc"
)
ids = metadata.issue_provider_ids
- self._validate_ids(ids)
- preview = StoryArcCatalogPreview(metadata=metadata, issues=(), series=(), fingerprint="")
+ self._validate_ids(ids, self.source)
+ preview = StoryArcCatalogPreview(
+ metadata=metadata,
+ issues=(),
+ series=(),
+ fingerprint="",
+ source=self.source,
+ source_revision=self.source_revision,
+ )
if metadata.membership_complete:
issues = tuple(await self.provider.get_story_arc_issues(ids)) if ids else ()
- self._validate_hydration(ids, issues)
+ self._validate_hydration(ids, issues, self.source)
parent_ids = tuple(dict.fromkeys(issue.series_provider_id for issue in issues))
if len(parent_ids) > MAX_CATALOG_PARENTS:
raise StoryArcCatalogError(
@@ -141,6 +160,7 @@ async def preview(
if parent_id in known_series_provider_ids:
continue
parent = await self.provider.get_series(parent_id)
+ catalog_provider_id(parent.provider_id, self.source)
if parent.provider_id != parent_id or not parent.title.strip():
raise StoryArcCatalogError(
"identity_conflict", "Provider returned a different parent series"
@@ -192,9 +212,11 @@ async def add(
await self._enclose_savepoint(session)
try:
async with session.begin_nested():
+ await require_catalog_source_revision(session, preview)
+ await require_crosswalk_ownership(session, preview)
existing_identity = await session.scalar(
select(StoryArcExternalIdentity.id).where(
- StoryArcExternalIdentity.source == "comicvine",
+ StoryArcExternalIdentity.source == preview.source.identity_namespace.value,
StoryArcExternalIdentity.namespace == "story_arc",
StoryArcExternalIdentity.external_id == preview.metadata.provider_id,
)
@@ -203,7 +225,6 @@ async def add(
raise StoryArcCatalogError(
"identity_conflict", "This provider story arc identity is already assigned"
)
- issues = await seed_members(session, preview, root, order)
arc = await self.domain.create(
session,
name=preview.metadata.title,
@@ -214,23 +235,20 @@ async def add(
sync_enabled=policy.synchronize,
source_kind=StoryArcSourceKind.PROVIDER,
)
- arc.comicvine_id = exact_provider_id(preview.metadata.provider_id)
- arc.comicvine_url = preview.metadata.comicvine_url
+ arc.comicvine_url = (
+ preview.metadata.comicvine_url
+ if preview.source.identity_namespace is IdentityNamespace.COMICVINE
+ else None
+ )
arc.cover_url = preview.metadata.cover_url
arc.publisher_id = await publisher_id(session, preview.metadata.publisher)
arc.target_library_root_id = policy.target_library_root_id
arc.policy_schema_version = STORY_ARC_PLACEMENT_POLICY_SCHEMA_VERSION
arc.policy_snapshot = policy.snapshot
- session.add(
- StoryArcExternalIdentity(
- story_arc_id=arc.id,
- source="comicvine",
- namespace="story_arc",
- external_id=preview.metadata.provider_id,
- source_url=preview.metadata.comicvine_url,
- evidence={"snapshot_fingerprint": preview.fingerprint},
- )
- )
+ await attach_arc_identity(session, arc, preview)
+ await persist_arc_metadata(session, arc, preview, created=True)
+ issues = await seed_members(session, preview, root, order)
+ await record_catalog_crosswalks(session, preview)
for position, provider_id in enumerate(order, start=1):
member = await self._member(
session, arc, preview, issues[provider_id], provider_id, position
@@ -250,6 +268,11 @@ async def add(
await enqueue_story_arc_sync_work(session, library_file)
await initialize_catalog_placements(session, arc)
return arc
+ except (MetadataBaselineConflictError, MetadataAssemblyError) as exc:
+ raise StoryArcCatalogError(
+ "metadata_baseline_conflict",
+ "Metadata baseline needs review; preview the arc again",
+ ) from exc
except IntegrityError as exc:
raise StoryArcCatalogError(
"identity_conflict", "Catalog identities changed; refresh the preview"
@@ -278,13 +301,15 @@ async def preview_refresh(
for row in rows
if row.source_kind is StoryArcSourceKind.PROVIDER
and row.source_arc_id == preview.metadata.provider_id
+ and row.evidence.get("provider", "comicvine") == preview.source.identity_namespace.value
}
- canonical_ids = await session.scalars(
- select(Issue.comicvine_id)
- .join(IssueStoryArc, IssueStoryArc.issue_id == Issue.id)
- .where(IssueStoryArc.story_arc_id == arc.id, Issue.comicvine_id.is_not(None))
+ owners = await catalog_owners(
+ session, MetadataEntityKind.ISSUE, preview.metadata.issue_provider_ids, preview.source
)
- represented_ids = existing | {str(value) for value in canonical_ids}
+ member_issue_ids = {row.issue_id for row in rows if row.issue_id is not None}
+ represented_ids = existing | {
+ key for key, owner in owners.items() if owner in member_issue_ids
+ }
incoming = set(preview.metadata.issue_provider_ids)
return StoryArcCatalogRefreshPreview(
arc.id,
@@ -298,6 +323,10 @@ async def preview_refresh(
row.source_issue_id
for row in rows
if row.source_issue_id in existing
+ and row.source_kind is StoryArcSourceKind.PROVIDER
+ and row.source_arc_id == preview.metadata.provider_id
+ and row.evidence.get("provider", "comicvine")
+ == preview.source.identity_namespace.value
and row.source_issue_id not in incoming
and row.source_issue_id is not None
),
@@ -311,7 +340,11 @@ async def refresh(
*,
expected_revision: int,
library_root_id: int | None = None,
+ replace_metadata: bool = True,
+ metadata_refresh: ArcMetadataRefresh | None = None,
) -> StoryArcCatalogRefreshResult:
+ if metadata_refresh is not None and metadata_refresh.before.entity.local_id != story_arc_id:
+ raise StoryArcCatalogError("metadata_changed", "Story arc changed; refresh the review")
delta = await self.preview_refresh(session, story_arc_id, preview)
if isinstance(expected_revision, bool) or delta.revision != expected_revision:
raise StoryArcCatalogError("revision_conflict", "Story arc changed; refresh the review")
@@ -324,6 +357,10 @@ async def refresh(
await self._enclose_savepoint(session)
try:
async with session.begin_nested():
+ if metadata_refresh is not None:
+ await require_arc_refresh_state(session, metadata_refresh)
+ await require_catalog_source_revision(session, preview)
+ await require_crosswalk_ownership(session, preview)
claimed = await session.execute(
update(StoryArc)
.where(StoryArc.id == arc.id, StoryArc.revision == expected_revision)
@@ -333,9 +370,19 @@ async def refresh(
raise StoryArcCatalogError(
"revision_conflict", "Story arc changed; refresh the review"
)
+ await attach_arc_identity(session, arc, preview)
+ await persist_arc_metadata(
+ session,
+ arc,
+ preview,
+ created=False,
+ replace_managed=replace_metadata,
+ candidates=metadata_refresh.fetched.candidates if metadata_refresh else None,
+ )
issues = await seed_members(
session, preview, root, preview.metadata.issue_provider_ids
)
+ await record_catalog_crosswalks(session, preview)
rows = list(
(
await session.scalars(
@@ -348,6 +395,8 @@ async def refresh(
if (
row.source_kind is StoryArcSourceKind.PROVIDER
and row.source_arc_id == preview.metadata.provider_id
+ and row.evidence.get("provider", "comicvine")
+ == preview.source.identity_namespace.value
and row.source_issue_id in issues
and row.issue_id is not None
and row.issue_id != issues[row.source_issue_id].id
@@ -370,12 +419,32 @@ async def refresh(
pending = tuple(
row.id for row in rows if row.evidence.get("catalog_review_required") is True
) + tuple(created)
- arc.cover_url = preview.metadata.cover_url
+ if preview.source_evidence is None:
+ arc.cover_url = preview.metadata.cover_url
self._diagnostics(arc, preview, root.id, delta.removed_issue_provider_ids, pending)
+ if metadata_refresh is not None:
+ arc.diagnostics = {
+ **arc.diagnostics,
+ "metadata_refresh": {
+ "checked_at": datetime.now(UTC).isoformat(),
+ "outcomes": [
+ item.model_dump(
+ mode="json",
+ include={"source", "status", "retry_after_seconds"},
+ )
+ for item in metadata_refresh.fetched.outcomes
+ ],
+ },
+ }
await session.flush()
return StoryArcCatalogRefreshResult(
arc, tuple(created), delta.removed_issue_provider_ids
)
+ except (MetadataBaselineConflictError, MetadataAssemblyError) as exc:
+ raise StoryArcCatalogError(
+ "metadata_baseline_conflict",
+ "Metadata baseline needs review; preview the arc again",
+ ) from exc
except IntegrityError as exc:
raise StoryArcCatalogError(
"identity_conflict", "Catalog identities changed; refresh the preview"
@@ -387,9 +456,11 @@ async def _arc(
arc = await session.get(StoryArc, story_arc_id)
if arc is None or arc.lifecycle is not StoryArcLifecycle.ACTIVE:
raise StoryArcCatalogError("arc_unavailable", "Story arc is unavailable or archived")
- if arc.comicvine_id != exact_provider_id(preview.metadata.provider_id):
+ owners = await self.find_existing(session, [preview.metadata.provider_id])
+ if owners.get(preview.metadata.provider_id) != arc.id:
raise StoryArcCatalogError(
- "identity_conflict", "Provider snapshot belongs to a different story arc"
+ "identity_conflict",
+ "Story arc identity needs review; the snapshot has no matching current owner",
)
return arc
@@ -425,10 +496,10 @@ async def _member(
if parent is not None:
member.source_series_name = parent.title
member.source_publisher = parent.publisher
- member.resolution_method = "exact_comicvine_id"
+ member.resolution_method = f"exact_{preview.source.identity_namespace.value}_id"
member.resolution_confidence = 1.0
member.evidence = {
- "provider": "comicvine",
+ "provider": preview.source.identity_namespace.value,
"snapshot_fingerprint": preview.fingerprint,
"order_basis": preview.order_basis,
"provider_response_ordinal": member.source_ordinal,
@@ -473,7 +544,9 @@ def _diagnostics(
}
@staticmethod
- def _validate_ids(ids: Sequence[str]) -> None:
+ def _validate_ids(
+ ids: Sequence[str], source: MetadataSource = MetadataSource.COMICVINE_API
+ ) -> None:
if len(ids) > MAX_CATALOG_MEMBERS:
raise StoryArcCatalogError(
"catalog_limit_exceeded", "This arc exceeds the supported membership limit"
@@ -483,16 +556,20 @@ def _validate_ids(ids: Sequence[str]) -> None:
"identity_conflict", "Provider returned duplicate arc members"
)
for value in ids:
- exact_provider_id(value)
+ catalog_provider_id(value, source)
@staticmethod
- def _validate_hydration(ids: Sequence[str], issues: Sequence[IssueMetadata]) -> None:
+ def _validate_hydration(
+ ids: Sequence[str],
+ issues: Sequence[IssueMetadata],
+ source: MetadataSource = MetadataSource.COMICVINE_API,
+ ) -> None:
if tuple(issue.provider_id for issue in issues) != tuple(ids):
raise StoryArcCatalogError(
"incomplete_hydration", "Provider did not hydrate the exact complete arc membership"
)
for issue in issues:
- exact_provider_id(issue.series_provider_id)
+ catalog_provider_id(issue.series_provider_id, source)
try:
parse_issue_number_text(issue.issue_number_text or issue.issue_number)
except ValueError as exc:
@@ -501,6 +578,21 @@ def _validate_hydration(ids: Sequence[str], issues: Sequence[IssueMetadata]) ->
) from exc
def _validate_preview(self, preview: StoryArcCatalogPreview) -> None:
+ if preview.source_evidence is not None:
+ if preview.source_revision is None:
+ raise StoryArcCatalogError(
+ "source_changed", "Source snapshot needs a current policy revision"
+ )
+ projected = project_source_arc_catalog(preview.source_evidence, preview.source_revision)
+ if catalog_snapshot(projected) != catalog_snapshot(preview):
+ raise StoryArcCatalogError(
+ "snapshot_changed",
+ "Catalog projection disagrees with source evidence; preview the arc again",
+ )
+ if preview.source is not self.source:
+ raise StoryArcCatalogError(
+ "identity_conflict", "Catalog snapshot belongs to another source"
+ )
if snapshot_fingerprint(preview) != preview.fingerprint:
raise StoryArcCatalogError(
"snapshot_changed", "Catalog snapshot changed; review it again"
@@ -509,5 +601,34 @@ def _validate_preview(self, preview: StoryArcCatalogPreview) -> None:
raise StoryArcCatalogError(
"incomplete_membership", "Provider membership is incomplete; nothing was added"
)
- self._validate_ids(preview.metadata.issue_provider_ids)
- self._validate_hydration(preview.metadata.issue_provider_ids, preview.issues)
+ count = preview.metadata.declared_issue_count
+ if (
+ not preview.metadata.title.strip()
+ or len(preview.metadata.title) > 500
+ or (count is not None and (type(count) is not int or count != len(preview.issues)))
+ ):
+ raise StoryArcCatalogError(
+ "incomplete_membership",
+ "Provider profile and membership disagree; review the arc again",
+ )
+ parent_ids = [parent.provider_id for parent in preview.series]
+ if len(parent_ids) != len(set(parent_ids)) or len(parent_ids) > MAX_CATALOG_PARENTS:
+ raise StoryArcCatalogError(
+ "identity_conflict", "Provider returned duplicate or excessive parent series"
+ )
+ required_parents = {issue.series_provider_id for issue in preview.issues}
+ for parent in preview.series:
+ catalog_provider_id(parent.provider_id, preview.source)
+ if (
+ parent.provider_id not in required_parents
+ or not parent.title.strip()
+ or len(parent.title) > 500
+ ):
+ raise StoryArcCatalogError(
+ "identity_conflict", "Provider returned an invalid parent series"
+ )
+ catalog_provider_id(preview.metadata.provider_id, preview.source)
+ self._validate_ids(preview.metadata.issue_provider_ids, preview.source)
+ self._validate_hydration(
+ preview.metadata.issue_provider_ids, preview.issues, preview.source
+ )
diff --git a/src/pullbox/services/story_arc_catalog_evidence.py b/src/pullbox/services/story_arc_catalog_evidence.py
new file mode 100644
index 00000000..f0ab8fc0
--- /dev/null
+++ b/src/pullbox/services/story_arc_catalog_evidence.py
@@ -0,0 +1,91 @@
+"""Retain cross-provider observations without silently acquiring foreign ownership."""
+
+from collections import defaultdict
+from collections.abc import Iterator
+from uuid import UUID, uuid5
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+)
+from pullbox.core.metadata_identity_events import IdentityEventEvidence, IdentityEventRequest
+from pullbox.core.metadata_identity_state import IdentityVerificationAction
+from pullbox.schemas.metadata_sources import (
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+)
+from pullbox.services.metadata_arc_catalog import source_record_identities
+from pullbox.services.metadata_identity_review import record_identity_observation
+from pullbox.services.story_arc_catalog_identity import catalog_owners
+from pullbox.services.story_arc_catalog_types import StoryArcCatalogError, StoryArcCatalogPreview
+
+
+def _groups(
+ preview: StoryArcCatalogPreview,
+) -> Iterator[
+ tuple[
+ MetadataEntityKind,
+ tuple[ProviderIssueRead | ProviderSeriesRead | ProviderStoryArcRead, ...],
+ ]
+]:
+ evidence = preview.source_evidence
+ if evidence is not None:
+ yield MetadataEntityKind.STORY_ARC, (evidence.arc,)
+ yield MetadataEntityKind.SERIES, evidence.series
+ yield MetadataEntityKind.ISSUE, evidence.issues
+
+
+async def require_crosswalk_ownership(
+ session: AsyncSession, preview: StoryArcCatalogPreview
+) -> None:
+ """Unverified foreign ownership requires review, never duplicate creation."""
+ for kind, records in _groups(preview):
+ native = await catalog_owners(
+ session, kind, [row.external_id for row in records], preview.source
+ )
+ claims: dict[IdentityNamespace, dict[str, str]] = defaultdict(dict)
+ for row in records:
+ for identity in source_record_identities(row, kind)[1:]:
+ previous = claims[identity.namespace].setdefault(
+ identity.external_id, row.external_id
+ )
+ if previous != row.external_id:
+ raise StoryArcCatalogError(
+ "identity_conflict",
+ "Provider cross-identities repeat a target; review the match",
+ )
+ for namespace, mapping in claims.items():
+ owners = await catalog_owners(session, kind, list(mapping), namespace)
+ if any(native.get(mapping[external]) != owner for external, owner in owners.items()):
+ raise StoryArcCatalogError(
+ "identity_conflict",
+ "A provider cross-identity already has a library owner; review the match",
+ )
+
+
+async def record_catalog_crosswalks(session: AsyncSession, preview: StoryArcCatalogPreview) -> None:
+ for kind, records in _groups(preview):
+ targets = await catalog_owners(
+ session, kind, [row.external_id for row in records], preview.source
+ )
+ for row in records:
+ identities = source_record_identities(row, kind)
+ for identity in identities[1:]:
+ request = IdentityEventRequest(
+ uuid5(UUID("e5975ab0-13f6-4e1f-b7af-c3a00ccab88b"), preview.fingerprint),
+ targets[row.external_id],
+ IdentityVerificationAction.OBSERVE,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(
+ identity, IdentityEvidenceKind.PROVIDER_CROSSWALK, preview.source
+ ),
+ preview.fingerprint,
+ source_identity=identities[0],
+ ),
+ )
+ await record_identity_observation(session, request)
diff --git a/src/pullbox/services/story_arc_catalog_identity.py b/src/pullbox/services/story_arc_catalog_identity.py
new file mode 100644
index 00000000..46d45e46
--- /dev/null
+++ b/src/pullbox/services/story_arc_catalog_identity.py
@@ -0,0 +1,113 @@
+"""Source-scoped ownership checks for the existing arc adoption transaction."""
+
+from collections.abc import Sequence
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.models import Issue, Series, StoryArc, StoryArcExternalIdentity
+from pullbox.models.metadata_identity import IssueExternalIdentity, SeriesExternalIdentity
+from pullbox.models.metadata_source import MetadataSourceConfig
+from pullbox.services.story_arc_catalog_types import (
+ StoryArcCatalogError,
+ StoryArcCatalogPreview,
+ catalog_provider_id,
+ exact_provider_id,
+)
+
+
+async def catalog_owners(
+ session: AsyncSession,
+ kind: MetadataEntityKind,
+ provider_ids: Sequence[str],
+ source: MetadataSource | IdentityNamespace,
+) -> dict[str, int]:
+ """Include retained ownership and legacy CV keys; never match by title or number."""
+ mappings: dict[
+ MetadataEntityKind,
+ tuple[
+ type[Series] | type[Issue] | type[StoryArc],
+ type[SeriesExternalIdentity]
+ | type[IssueExternalIdentity]
+ | type[StoryArcExternalIdentity],
+ str,
+ ],
+ ] = {
+ MetadataEntityKind.SERIES: (Series, SeriesExternalIdentity, "series_id"),
+ MetadataEntityKind.ISSUE: (Issue, IssueExternalIdentity, "issue_id"),
+ MetadataEntityKind.STORY_ARC: (StoryArc, StoryArcExternalIdentity, "story_arc_id"),
+ }
+ model, active_model, owner_key = mappings[kind]
+ active = active_model.__table__
+ namespace = source.identity_namespace if isinstance(source, MetadataSource) else source
+ scope = (
+ (active.c.source == namespace.value, active.c.namespace == "story_arc")
+ if (kind is MetadataEntityKind.STORY_ARC)
+ else (active.c.identity_namespace == namespace,)
+ )
+ if isinstance(source, MetadataSource):
+ ids = list(dict.fromkeys(catalog_provider_id(value, source) for value in provider_ids))
+ else:
+ ids = list(
+ dict.fromkeys(
+ ExternalIdentityRef(namespace, kind, value).external_id for value in provider_ids
+ )
+ )
+ if namespace is IdentityNamespace.COMICVINE:
+ for value in ids:
+ exact_provider_id(value)
+ result: dict[str, int] = {}
+ for offset in range(0, len(ids), 200):
+ batch = ids[offset : offset + 200]
+ rows = list(
+ await session.execute(
+ select(active.c.external_id, active.c[owner_key]).where(
+ *scope, active.c.external_id.in_(batch)
+ )
+ )
+ )
+ if namespace is IdentityNamespace.COMICVINE:
+ rows.extend(
+ await session.execute(
+ select(model.comicvine_id, model.id).where(
+ model.comicvine_id.in_([int(value) for value in batch])
+ )
+ )
+ )
+ for value, owner in rows:
+ key = str(value)
+ if key in result and result[key] != owner:
+ raise StoryArcCatalogError(
+ "identity_conflict", "Provider identity has conflicting local owners"
+ )
+ result[key] = owner
+ return result
+
+
+async def require_catalog_source_revision(
+ session: AsyncSession, preview: StoryArcCatalogPreview
+) -> None:
+ # Legacy CV callers predate source settings. New source-bound snapshots always
+ # carry a revision and hold a shared policy lock through the caller's commit.
+ if preview.source_revision is None and preview.source is MetadataSource.COMICVINE_API:
+ return
+ if type(preview.source_revision) is not int or preview.source_revision < 1:
+ raise StoryArcCatalogError(
+ "source_changed", "Preview the story arc with current source settings"
+ )
+ config = await session.scalar(
+ select(MetadataSourceConfig)
+ .where(MetadataSourceConfig.source == preview.source.value)
+ .with_for_update(read=True)
+ .execution_options(populate_existing=True)
+ )
+ if config is None or not config.enabled or config.revision != preview.source_revision:
+ raise StoryArcCatalogError(
+ "source_changed", "Metadata source settings changed; preview the story arc again"
+ )
diff --git a/src/pullbox/services/story_arc_catalog_persistence.py b/src/pullbox/services/story_arc_catalog_persistence.py
index b7bcd268..31e4cdd8 100644
--- a/src/pullbox/services/story_arc_catalog_persistence.py
+++ b/src/pullbox/services/story_arc_catalog_persistence.py
@@ -5,6 +5,7 @@
from datetime import date
from pathlib import Path
from typing import TYPE_CHECKING
+from uuid import UUID, uuid5
from sqlalchemy import func, select
@@ -12,13 +13,32 @@
from pullbox.core.issue_numbers import parse_issue_number_text
from pullbox.core.library_naming import build_series_relative_path
from pullbox.core.library_policy import load_effective_library_ingest_policy
+from pullbox.core.metadata_identity import (
+ ExactIdentityEvidence,
+ ExternalIdentityRef,
+ IdentityEvidenceKind,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
+from pullbox.core.metadata_identity_events import IdentityEventEvidence, IdentityEventRequest
+from pullbox.core.metadata_identity_state import (
+ IdentityReviewRequiredError,
+ IdentityVerificationAction,
+)
from pullbox.core.naming import classify_series_type, detect_issue_type_from_metadata_title
from pullbox.core.type_semantics import canonical_issue_type_for_series_type
from pullbox.models.issue import Issue, IssueStatus, IssueType
from pullbox.models.library import LibraryRoot
from pullbox.models.publisher import Publisher
from pullbox.models.series import IssueCatalogState, Series, SeriesStatus, SeriesType
+from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity
from pullbox.services.library_root_management import validate_managed_library_root
+from pullbox.services.metadata_identity_attachment import (
+ IdentityAttachmentConflictError,
+ attach_verified_identities,
+)
+from pullbox.services.story_arc_catalog_identity import catalog_owners
from pullbox.services.story_arc_catalog_types import StoryArcCatalogError, exact_provider_id
if TYPE_CHECKING:
@@ -27,6 +47,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from pullbox.providers.base import SeriesMetadata
+ from pullbox.schemas.metadata_sources import ProviderSeriesRead
from pullbox.services.story_arc_catalog_types import StoryArcCatalogPreview
@@ -73,28 +94,52 @@ async def seed_members(
series_metadata = {series.provider_id: series for series in preview.series}
result: dict[str, Issue] = {}
parents: dict[str, Series] = {}
+ new_parents: list[Series] = []
+ new_issues: list[Issue] = []
+ parent_owners = await catalog_owners(
+ session,
+ MetadataEntityKind.SERIES,
+ [metadata_by_id[key].series_provider_id for key in provider_ids],
+ preview.source,
+ )
+ issue_owners = await catalog_owners(
+ session, MetadataEntityKind.ISSUE, provider_ids, preview.source
+ )
for provider_id in provider_ids:
metadata = metadata_by_id[provider_id]
parent_key = metadata.series_provider_id
parent = parents.get(parent_key)
if parent is None:
- parent = await session.scalar(
- select(Series).where(Series.comicvine_id == exact_provider_id(parent_key))
- )
+ owner_id = parent_owners.get(parent_key)
+ parent = await session.get(Series, owner_id) if owner_id is not None else None
if parent is None:
parent_metadata = series_metadata.get(parent_key)
if parent_metadata is None:
raise StoryArcCatalogError(
"parent_metadata_missing", "Canonical parent changed; refresh the preview"
)
- parent = await _new_series(session, parent_metadata, root)
+ profile = (
+ next(
+ (
+ row
+ for row in preview.source_evidence.series
+ if row.external_id == parent_key
+ ),
+ None,
+ )
+ if preview.source_evidence
+ else None
+ )
+ parent = await _new_series(
+ session, parent_metadata, root, preview.source, profile=profile
+ )
+ new_parents.append(parent)
parents[parent_key] = parent
number, exact_number = parse_issue_number_text(
metadata.issue_number_text or metadata.issue_number
)
- issue = await session.scalar(
- select(Issue).where(Issue.comicvine_id == exact_provider_id(provider_id))
- )
+ owner_id = issue_owners.get(provider_id)
+ issue = await session.get(Issue, owner_id) if owner_id is not None else None
if issue is not None:
if issue.series_id != parent.id or issue.effective_issue_number_text != exact_number:
raise StoryArcCatalogError(
@@ -117,7 +162,9 @@ async def seed_members(
detected_type = canonical_issue_type_for_series_type(parent.series_type)
issue = Issue(
series_id=parent.id,
- comicvine_id=exact_provider_id(provider_id),
+ comicvine_id=exact_provider_id(provider_id)
+ if preview.source.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
issue_number=number,
issue_number_text=exact_number,
title=metadata.title,
@@ -125,32 +172,145 @@ async def seed_members(
release_date=_date(metadata.release_date),
store_date=_date(metadata.store_date),
cover_url=metadata.cover_url,
- comicvine_url=metadata.comicvine_url,
+ comicvine_url=metadata.comicvine_url
+ if preview.source.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
page_count=metadata.page_count,
- metadata_source="comicvine",
+ metadata_source="comicvine"
+ if preview.source is MetadataSource.COMICVINE_API
+ else preview.source.value,
issue_type=detected_type,
status=IssueStatus.SKIPPED,
manual_skip=False,
)
session.add(issue)
await session.flush()
+ new_issues.append(issue)
result[provider_id] = issue
+ await _attach_member_identities(session, preview, parents, result)
+ from pullbox.services.metadata_arc_baselines import persist_seeded_metadata
+
+ await persist_seeded_metadata(session, preview, new_parents, new_issues)
return result
-async def _new_series(session: AsyncSession, metadata: SeriesMetadata, root: LibraryRoot) -> Series:
+async def _attach_member_identities(
+ session: AsyncSession,
+ preview: StoryArcCatalogPreview,
+ parents: dict[str, Series],
+ issues: dict[str, Issue],
+) -> None:
+ """Persist verified preview evidence without another provider or file read."""
+ operation_id = uuid5(UUID("e5975ab0-13f6-4e1f-b7af-c3a00ccab88b"), preview.fingerprint)
+ parent_ids = {row.id: key for key, row in parents.items()}
+ requests = []
+ for kind, rows in ((MetadataEntityKind.SERIES, parents), (MetadataEntityKind.ISSUE, issues)):
+ for provider_id, row in rows.items():
+ identity = ExternalIdentityRef(preview.source.identity_namespace, kind, provider_id)
+ parent = (
+ ExternalIdentityRef(
+ preview.source.identity_namespace,
+ MetadataEntityKind.SERIES,
+ parent_ids[row.series_id],
+ )
+ if isinstance(row, Issue)
+ else None
+ )
+ requests.append(
+ IdentityEventRequest(
+ operation_id,
+ row.id,
+ IdentityVerificationAction.VERIFY,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(
+ identity,
+ IdentityEvidenceKind.PROVIDER_RESULT,
+ preview.source,
+ ),
+ preview.fingerprint,
+ source_identity=identity,
+ parent_identity=parent,
+ ),
+ )
+ )
+ try:
+ await attach_verified_identities(session, requests, require_current_ownership=True)
+ except (IdentityAttachmentConflictError, IdentityReviewRequiredError) as exc:
+ raise StoryArcCatalogError(
+ "identity_conflict", "Canonical identity needs review before adding these members"
+ ) from exc
+
+
+async def attach_arc_identity(
+ session: AsyncSession, arc: StoryArc, preview: StoryArcCatalogPreview
+) -> None:
+ """Record the validated preview without treating an old receipt as current proof."""
+ identity = ExternalIdentityRef(
+ preview.source.identity_namespace,
+ MetadataEntityKind.STORY_ARC,
+ preview.metadata.provider_id,
+ )
+ request = IdentityEventRequest(
+ uuid5(UUID("e5975ab0-13f6-4e1f-b7af-c3a00ccab88b"), preview.fingerprint),
+ arc.id,
+ IdentityVerificationAction.VERIFY,
+ IdentityEventEvidence(
+ ExactIdentityEvidence(identity, IdentityEvidenceKind.PROVIDER_RESULT, preview.source),
+ preview.fingerprint,
+ source_identity=identity,
+ ),
+ )
+ try:
+ await attach_verified_identities(session, [request], require_current_ownership=True)
+ except (IdentityAttachmentConflictError, IdentityReviewRequiredError) as exc:
+ raise StoryArcCatalogError(
+ "identity_conflict", "Story arc identity needs review before updating its catalog"
+ ) from exc
+ owner = await session.scalar(
+ select(StoryArcExternalIdentity).where(
+ StoryArcExternalIdentity.story_arc_id == arc.id,
+ StoryArcExternalIdentity.source == preview.source.identity_namespace.value,
+ StoryArcExternalIdentity.namespace == "story_arc",
+ )
+ )
+ assert owner is not None
+ if (
+ preview.source.identity_namespace is IdentityNamespace.COMICVINE
+ and preview.metadata.comicvine_url is not None
+ ):
+ owner.source_url = preview.metadata.comicvine_url
+ if preview.source_evidence is not None and preview.source_evidence.arc.resource_url:
+ owner.source_url = preview.source_evidence.arc.resource_url
+ owner.evidence = {**owner.evidence, "snapshot_fingerprint": preview.fingerprint}
+ await session.refresh(arc, ["comicvine_id"])
+
+
+async def _new_series(
+ session: AsyncSession,
+ metadata: SeriesMetadata,
+ root: LibraryRoot,
+ source: MetadataSource,
+ *,
+ profile: ProviderSeriesRead | None = None,
+) -> Series:
series = Series(
- comicvine_id=exact_provider_id(metadata.provider_id),
+ comicvine_id=exact_provider_id(metadata.provider_id)
+ if source.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
title=metadata.title,
sort_title=metadata.sort_title or metadata.title,
year_start=metadata.year_start,
year_end=metadata.year_end,
description=metadata.description,
cover_url=metadata.cover_url,
- comicvine_url=metadata.comicvine_url,
+ comicvine_url=metadata.comicvine_url
+ if source.identity_namespace is IdentityNamespace.COMICVINE
+ else None,
issue_count=metadata.issue_count or 0,
status=SeriesStatus.ENDED if metadata.status == "ended" else SeriesStatus.CONTINUING,
- metadata_source="comicvine_partial",
+ metadata_source="comicvine_partial"
+ if source is MetadataSource.COMICVINE_API
+ else f"{source.value}_partial",
monitored=False,
issue_catalog_state=IssueCatalogState.PARTIAL,
series_type=SeriesType(
@@ -163,13 +323,26 @@ async def _new_series(session: AsyncSession, metadata: SeriesMetadata, root: Lib
),
library_root_id=root.id,
)
+ if profile is not None:
+ series.status = (
+ SeriesStatus(profile.status)
+ if profile.status is not None and profile.status in SeriesStatus
+ else SeriesStatus.UNKNOWN
+ )
+ if profile.series_type is not None and profile.series_type in SeriesType:
+ series.series_type = SeriesType(profile.series_type)
identifier = await publisher_id(session, metadata.publisher)
series.publisher = await session.get(Publisher, identifier) if identifier is not None else None
session.add(series)
policy = await load_effective_library_ingest_policy(session, root)
path = Path(root.path) / build_series_relative_path(series, policy)
if await _path_claimed(session, path):
- path = path.with_name(f"{path.name} [cv-{metadata.provider_id}]")
+ label = (
+ "cv"
+ if source.identity_namespace is IdentityNamespace.COMICVINE
+ else source.identity_namespace.value
+ )
+ path = path.with_name(f"{path.name} [{label}-{metadata.provider_id}]")
if await _path_claimed(session, path):
raise StoryArcCatalogError(
"canonical_path_collision", "Canonical series path is already in use"
diff --git a/src/pullbox/services/story_arc_catalog_types.py b/src/pullbox/services/story_arc_catalog_types.py
index 1b0cae6d..f935ed1d 100644
--- a/src/pullbox/services/story_arc_catalog_types.py
+++ b/src/pullbox/services/story_arc_catalog_types.py
@@ -7,12 +7,39 @@
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING
+from pullbox.core.metadata_identity import (
+ ExternalIdentityRef,
+ IdentityNamespace,
+ MetadataEntityKind,
+ MetadataSource,
+)
from pullbox.services.story_arc_service import StoryArcValidationError
if TYPE_CHECKING:
from pullbox.models.story_arc import StoryArc
from pullbox.providers.base import IssueMetadata, SeriesMetadata
from pullbox.providers.story_arcs import StoryArcMetadata
+ from pullbox.schemas.metadata_sources import (
+ ProviderIssueRead,
+ ProviderSeriesRead,
+ ProviderStoryArcRead,
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class SourceArcCatalogEvidence:
+ """Normalized source evidence retained alongside the legacy catalog projection."""
+
+ arc: ProviderStoryArcRead
+ issues: tuple[ProviderIssueRead, ...]
+ series: tuple[ProviderSeriesRead, ...]
+
+ def snapshot(self) -> dict[str, object]:
+ return {
+ "arc": self.arc.model_dump(mode="json"),
+ "issues": [issue.model_dump(mode="json") for issue in self.issues],
+ "series": [parent.model_dump(mode="json") for parent in self.series],
+ }
class StoryArcCatalogError(StoryArcValidationError):
@@ -31,6 +58,9 @@ class StoryArcCatalogPreview:
issues: tuple[IssueMetadata, ...]
series: tuple[SeriesMetadata, ...]
fingerprint: str
+ source: MetadataSource = MetadataSource.COMICVINE_API
+ source_revision: int | None = None
+ source_evidence: SourceArcCatalogEvidence | None = None
@property
def membership_complete(self) -> bool:
@@ -59,8 +89,8 @@ class StoryArcCatalogRefreshResult:
def catalog_snapshot(preview: StoryArcCatalogPreview) -> dict[str, object]:
"""Only provider-public data is stored; no credentials or local file paths."""
metadata = preview.metadata
- return {
- "provider": "comicvine",
+ snapshot: dict[str, object] = {
+ "provider": preview.source.identity_namespace.value,
"provider_id": metadata.provider_id,
"title": metadata.title,
"description": metadata.description,
@@ -75,6 +105,11 @@ def catalog_snapshot(preview: StoryArcCatalogPreview) -> dict[str, object]:
"issues": [asdict(issue) for issue in preview.issues],
"series": [asdict(series) for series in preview.series],
}
+ if preview.source is not MetadataSource.COMICVINE_API or preview.source_revision is not None:
+ snapshot.update(source=preview.source.value, source_revision=preview.source_revision)
+ if preview.source_evidence is not None:
+ snapshot["source_evidence"] = preview.source_evidence.snapshot()
+ return snapshot
def snapshot_fingerprint(preview: StoryArcCatalogPreview) -> str:
@@ -90,3 +125,20 @@ def exact_provider_id(value: str) -> int:
if number < 1 or str(number) != value or number > 2**63 - 1:
raise StoryArcCatalogError("invalid_identity", "Provider identity must be a positive ID")
return number
+
+
+def catalog_provider_id(value: str, source: MetadataSource) -> str:
+ """Keep native IDs as text; only compatibility CV IDs have an integer bound."""
+ try:
+ identity = ExternalIdentityRef(
+ source.identity_namespace, MetadataEntityKind.STORY_ARC, value
+ )
+ if identity.external_id != value:
+ raise ValueError
+ if identity.namespace is IdentityNamespace.COMICVINE:
+ exact_provider_id(value)
+ except ValueError as exc:
+ raise StoryArcCatalogError(
+ "invalid_identity", "Provider identity must be a canonical positive ID"
+ ) from exc
+ return value
diff --git a/src/pullbox/tasks/import_archive_recovery.py b/src/pullbox/tasks/import_archive_recovery.py
new file mode 100644
index 00000000..085bdff5
--- /dev/null
+++ b/src/pullbox/tasks/import_archive_recovery.py
@@ -0,0 +1,92 @@
+"""Bounded restart recovery for import-owned archive publications."""
+
+from uuid import UUID
+
+import structlog
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState
+from pullbox.services.archive_metadata_binding import ArchiveMetadataBindingError
+from pullbox.services.archive_metadata_publication import (
+ ArchivePublicationError,
+ _clean_session,
+ inspect_archive_publication,
+ load_archive_publication,
+)
+from pullbox.services.import_archive_recovery import recover_import_archive_publication
+
+logger = structlog.get_logger(__name__)
+RECOVERY_PAGE_SIZE = 32
+
+
+async def recover_import_archive_publications(
+ session: AsyncSession, *, job_id: int | None = None
+) -> int:
+ """Own the supplied clean session across short recovery transactions.
+
+ This is called only by startup/enrichment and rollback orchestration, which
+ already own commits. Release all DB transactions before hashing; never use
+ elapsed time or a missing worker as authority to republish a stage.
+ """
+ _clean_session(session)
+ if session.in_nested_transaction():
+ raise ArchivePublicationError("recovery_requires_owned_session")
+ active = (PublicationState.INTENDED, PublicationState.PUBLISHED)
+ ceiling = await session.scalar(
+ select(func.max(ArchiveMetadataPublication.id)).where(
+ ArchiveMetadataPublication.state.in_(active)
+ )
+ )
+ if ceiling is None:
+ return 0
+ await session.commit()
+ cursor = recovered = 0
+ while cursor < ceiling:
+ page = (
+ await session.execute(
+ select(ArchiveMetadataPublication.id, ArchiveMetadataPublication.operation_id)
+ .where(
+ ArchiveMetadataPublication.id > cursor,
+ ArchiveMetadataPublication.id <= ceiling,
+ ArchiveMetadataPublication.state.in_(active),
+ )
+ .order_by(ArchiveMetadataPublication.id)
+ .limit(RECOVERY_PAGE_SIZE)
+ )
+ ).all()
+ await session.commit()
+ if not page:
+ break
+ for row_id, operation in page:
+ cursor = row_id
+ try:
+ try:
+ operation_id = UUID(operation)
+ except ValueError:
+ raise ArchivePublicationError("invalid_journal") from None
+ receipt = await load_archive_publication(session, operation_id)
+ await session.commit()
+ if receipt is None or receipt.plan.import_owner is None:
+ continue
+ if job_id is not None and receipt.plan.import_owner.job_id != job_id:
+ continue
+ inspection = await inspect_archive_publication(receipt)
+ result = await recover_import_archive_publication(session, receipt, inspection)
+ await session.commit()
+ recovered += int(result != receipt)
+ logger.info(
+ "import_archive_publication_recovered",
+ publication_id=row_id,
+ job_id=receipt.plan.import_owner.job_id,
+ outcome=result.state.value,
+ )
+ except (ArchivePublicationError, ArchiveMetadataBindingError, OSError) as exc:
+ await session.rollback()
+ reason = "filesystem_unavailable" if isinstance(exc, OSError) else exc.code
+ logger.warning(
+ "import_archive_publication_recovery_deferred",
+ publication_id=row_id,
+ reason=reason,
+ )
+ return recovered
diff --git a/src/pullbox/tasks/import_metadata_writing.py b/src/pullbox/tasks/import_metadata_writing.py
new file mode 100644
index 00000000..40e0b044
--- /dev/null
+++ b/src/pullbox/tasks/import_metadata_writing.py
@@ -0,0 +1,166 @@
+"""Run import-owned paired archive writing using the existing publication lifecycle."""
+
+import asyncio
+from collections.abc import Mapping
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+from uuid import uuid4
+from zipfile import ZipFile
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from pullbox.core.archive_metadata import MAX_METADATA_BYTES, read_archive_metadata
+from pullbox.core.file_safety import (
+ get_archive_size_limit_bytes,
+ is_dangerous_file_blocking_enabled,
+ is_resource_safety_exception_allowed,
+)
+from pullbox.core.metadata_identity import MetadataSource
+from pullbox.models import LibraryFile
+from pullbox.models.import_job import (
+ ImportControlRequest,
+ ImportedFile,
+ ImportJob,
+ ImportJobAction,
+ ImportJobStatus,
+)
+from pullbox.models.library import FileFormat
+from pullbox.providers.base import IssueMetadata
+from pullbox.providers.metadata.comicvine_normalization import issue as normalize_comicvine_issue
+from pullbox.services.archive_metadata_binding import (
+ archive_primary_identity,
+ assemble_bound_archive_metadata,
+ inspect_archive_metadata_target,
+ read_archive_metadata_binding,
+)
+from pullbox.services.archive_metadata_finalization import finalize_archive_publication
+from pullbox.services.archive_metadata_publication import (
+ ArchivePublicationError,
+ inspect_archive_publication,
+ prepare_archive_publication,
+ publish_archive_publication,
+ record_archive_publication,
+)
+from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata
+from pullbox.services.catalog.reader import CatalogIssueMetadata
+from pullbox.services.import_archive_publication import bind_import_archive_publication
+from pullbox.utilities.executors.archive_metadata_staging import stage_cbz_metadata_interruptible
+
+if TYPE_CHECKING:
+ from pullbox.schemas.metadata_sources import ProviderIssueRead
+
+
+async def write_imported_archive_metadata(
+ factory: async_sessionmaker[AsyncSession],
+ imported_file_id: int,
+ prefetched: Mapping[int, IssueMetadata],
+) -> bool:
+ """Handle a queued CBZ, or leave other formats to their existing writer.
+
+ This task owns its short sessions. A rejected CBZ never falls back to the
+ single-document writer. Reference-only and unverified targets fail closed.
+ """
+ async with factory() as session:
+ file = await session.get(ImportedFile, imported_file_id)
+ if file is None or file.library_file_id is None:
+ raise ArchivePublicationError("import_owner_missing")
+ library = await session.get(LibraryFile, file.library_file_id)
+ if library is not None and library.file_format is not FileFormat.CBZ:
+ return False
+ binding = await read_archive_metadata_binding(
+ session, file.library_file_id, expected_issue_id=file.matched_issue_id
+ )
+ job_id = file.import_job_id
+ details = file.diagnostics.get("comicinfo_enrichment", {})
+ action_id = details.get("action_id") if isinstance(details, dict) else None
+ actions = (
+ [action_id]
+ if type(action_id) is int and action_id > 0
+ else list(
+ await session.scalars(
+ select(ImportJobAction.id)
+ .where(
+ ImportJobAction.import_job_id == job_id,
+ ImportJobAction.action_type == "library_file_registered",
+ ImportJobAction.payload["imported_file_id"].as_integer() == file.id,
+ )
+ .limit(2)
+ )
+ )
+ )
+ if len(actions) != 1:
+ raise ArchivePublicationError("import_owner_missing_or_ambiguous")
+ limit = await get_archive_size_limit_bytes(session)
+ block_dangerous = await is_dangerous_file_blocking_enabled(session)
+ allowed_resource_exception = is_resource_safety_exception_allowed(file.diagnostics)
+
+ async def check_control() -> None:
+ async with factory() as session:
+ job = await session.get(ImportJob, job_id)
+ if (
+ job is None
+ or job.status is not ImportJobStatus.COMPLETED
+ or job.control_request is not ImportControlRequest.NONE
+ ):
+ raise asyncio.CancelledError
+
+ await check_control()
+ target = await inspect_archive_metadata_target(binding)
+ files = await asyncio.to_thread(
+ read_archive_metadata, target.path, "cbz", max_solid_scan_bytes=MAX_METADATA_BYTES
+ )
+ archive = reconcile_archive_metadata(files)
+ cv_id = binding.metadata.issues[0].comicvine_id
+ cached = prefetched.get(cv_id) if cv_id is not None else None
+ candidates: tuple[ProviderIssueRead, ...] = ()
+ if cached is not None:
+ source = (
+ MetadataSource.COMICVINE_LOCAL
+ if isinstance(cached, CatalogIssueMetadata)
+ else MetadataSource.COMICVINE_API
+ )
+ if any(policy.source is source and policy.enabled for policy in binding.metadata.policies):
+ candidates = (normalize_comicvine_issue(source, cached),)
+ series, issue = assemble_bound_archive_metadata(
+ binding, archive, now=datetime.now(UTC), issue_candidates=candidates
+ )
+ if allowed_resource_exception:
+
+ def approved_budget() -> int:
+ with ZipFile(target.path) as source:
+ # The exception applies only to this captured file, not global policy.
+ return (
+ sum(member.file_size for member in source.infolist()) + 2 * MAX_METADATA_BYTES
+ )
+
+ limit = max(limit, await asyncio.to_thread(approved_budget))
+ await asyncio.to_thread(target.check_unchanged)
+
+ async with stage_cbz_metadata_interruptible(
+ target.path,
+ target.path.parent,
+ series,
+ issue,
+ max_uncompressed_bytes=limit,
+ block_dangerous=block_dangerous,
+ primary_identity=archive_primary_identity(binding, archive),
+ previous_series=binding.metadata.series.baseline,
+ previous_issue=binding.metadata.issues[0].baseline,
+ cancellation_check=check_control,
+ ) as staged:
+ plan = await prepare_archive_publication(target, staged, series, issue)
+ async with factory() as session:
+ plan = await bind_import_archive_publication(
+ session, plan, imported_file_id=imported_file_id, action_id=actions[0]
+ )
+ operation = uuid4()
+ async with factory.begin() as session:
+ await record_archive_publication(session, plan, operation)
+ await check_control()
+ async with factory.begin() as session:
+ receipt = await publish_archive_publication(session, operation)
+ inspection = await inspect_archive_publication(receipt)
+ async with factory.begin() as session:
+ await finalize_archive_publication(session, receipt, inspection)
+ return True
diff --git a/src/pullbox/tasks/metadata_sweep_state.py b/src/pullbox/tasks/metadata_sweep_state.py
index 1398f9fb..d97800f3 100644
--- a/src/pullbox/tasks/metadata_sweep_state.py
+++ b/src/pullbox/tasks/metadata_sweep_state.py
@@ -16,6 +16,7 @@
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
+ from sqlalchemy.sql.elements import ColumnElement
TASK_IDS = ("sync_new_issues", "refresh_metadata")
@@ -48,12 +49,13 @@ async def load_sweep(session: AsyncSession, task_id: str) -> MetadataSweep:
return MetadataSweep()
-async def start_sweep(session: AsyncSession, task_id: str) -> MetadataSweep:
+async def start_sweep(
+ session: AsyncSession, task_id: str, *, eligible: ColumnElement[bool] | None = None
+) -> MetadataSweep:
state = await load_sweep(session, task_id)
if not state.active:
- upper = await session.scalar(
- select(func.max(Series.id)).where(Series.comicvine_id.isnot(None))
- )
+ predicate = eligible if eligible is not None else Series.comicvine_id.isnot(None)
+ upper = await session.scalar(select(func.max(Series.id)).where(predicate))
state = MetadataSweep(upper_bound=upper or 0, active=True)
await save_sweep(session, task_id, state)
await session.commit()
@@ -86,10 +88,19 @@ def schedule_sweep(task_id: str, state: MetadataSweep) -> None:
async def recover_metadata_sweep_schedules() -> None:
"""Restore interrupted batches without waiting for tomorrow's cron."""
+ from pullbox.config import get_settings
from pullbox.database import get_session_factory
+ from pullbox.services.metadata_series_retry import retry_deadline, retry_runtime
async with get_session_factory()() as session:
+ runtime = await retry_runtime(
+ session, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled
+ )
for task_id in TASK_IDS:
state = await load_sweep(session, task_id)
+ if not state.active:
+ deadline = await retry_deadline(session, task_id, runtime)
+ if deadline is not None:
+ state = MetadataSweep(active=True, retry_at=deadline.timestamp())
if state.active:
schedule_sweep(task_id, state)
diff --git a/src/pullbox/tasks/metadata_task.py b/src/pullbox/tasks/metadata_task.py
index bc47378b..f2a336ec 100644
--- a/src/pullbox/tasks/metadata_task.py
+++ b/src/pullbox/tasks/metadata_task.py
@@ -1,267 +1,76 @@
-"""Metadata background tasks — syncs new issues and refreshes stale metadata.
-
-Two scheduled tasks:
-- ``sync_new_issues`` (daily cron) — fetches issue lists for
- ComicVine-backed series, creates new Issue records, and refreshes stale
- series metadata changes (status, description, publisher). When new issues
- are set to WANTED by monitoring criteria, a one-shot search is scheduled.
-- ``refresh_metadata`` (cron, default 03:15) — re-fetches series metadata
- when it exceeds the configured staleness threshold.
-"""
+"""Source-aware metadata sweeps with bounded batches and atomic library progress."""
from __future__ import annotations
import asyncio
import time
-from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
-from typing import TYPE_CHECKING
import structlog
-from sqlalchemy import func, or_, select
+from sqlalchemy import or_, select
from pullbox.config import PullboxSettings, get_settings
from pullbox.core.exceptions import ProviderError
-from pullbox.core.log_deduper import log_deduped_warning
-from pullbox.core.sqlite_lock import is_sqlite_locked_error
-
-if TYPE_CHECKING:
- from sqlalchemy.ext.asyncio import AsyncSession
-
-from pullbox.core.comicvine_key import get_comicvine_api_key
from pullbox.core.scheduler import TaskExecutionResult, get_scheduler
+from pullbox.core.sqlite_lock import is_sqlite_locked_error
from pullbox.database import get_session_factory
-from pullbox.models.issue import Issue, IssueStatus
-from pullbox.models.series import IssueCatalogState, Series, SeriesStatus
-from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider
-from pullbox.services.metadata_service import MetadataService
-from pullbox.tasks.metadata_sweep_state import load_sweep, save_sweep, schedule_sweep, start_sweep
+from pullbox.models.series import IssueCatalogState, Series
+from pullbox.providers.metadata.comicvine import ComicVineError
+from pullbox.schemas.metadata_sources import SourceStatus
+from pullbox.services.metadata_daily_sync import sync_scheduled_issue_catalog
+from pullbox.services.metadata_scheduled_refresh import (
+ ScheduledSeriesRefresh,
+ refresh_scheduled_series,
+ scheduled_series_eligibility,
+)
+from pullbox.services.metadata_series_refresh import SeriesRefreshError, refresh_series_artwork
+from pullbox.services.metadata_series_retry import (
+ admit_retry,
+ prune_retries,
+ retry_candidates,
+ retry_deadline,
+ retry_runtime,
+ settle_retry,
+)
+from pullbox.tasks.metadata_sweep_state import save_sweep, schedule_sweep, start_sweep
logger = structlog.get_logger(__name__)
-_RECENT_ISSUE_SYNC_LIMIT = 100
_METADATA_BATCH_SIZE = 25
_METADATA_BATCH_SECONDS = 120.0
_METADATA_SERIES_SECONDS = 900.0
-_STANDARD_ISSUE_CHECK_INTERVAL = timedelta(hours=24)
-_ENDED_MONITORED_ISSUE_CHECK_INTERVAL = timedelta(days=14)
-_ENDED_UNMONITORED_ISSUE_CHECK_INTERVAL = timedelta(days=30)
-
-
-async def _create_metadata_service(
- api_key: str,
- settings: PullboxSettings,
- session: AsyncSession,
-) -> MetadataService:
- """Build a ComicVineProvider + MetadataService from an API key and settings."""
- from pullbox.services.cover_resolver import resolve_covers_dir
-
- provider = ComicVineProvider(
- api_key=api_key,
- rate_limit=settings.comicvine_rate_limit,
- )
- covers_dir = await resolve_covers_dir(session)
- return MetadataService(
- provider,
- covers_dir=covers_dir,
- refresh_days=settings.metadata_refresh_days,
- )
-
-
-@dataclass
-class _SeriesSnapshot:
- """Snapshot of series fields for change detection."""
-
- status: str
- description: str | None
- publisher_id: int | None
-
-
-def _take_snapshot(series: Series) -> _SeriesSnapshot:
- """Capture current series metadata for later comparison."""
- return _SeriesSnapshot(
- status=str(series.status),
- description=series.description,
- publisher_id=series.publisher_id,
- )
-
-
-def _detect_changes(
- series: Series,
- before: _SeriesSnapshot,
- log: structlog.stdlib.BoundLogger,
-) -> tuple[bool, bool]:
- """Compare series against its snapshot. Returns (status_changed, metadata_changed)."""
- status_changed = False
- metadata_changed = False
-
- current_status = str(series.status)
- if current_status != before.status:
- status_changed = True
- log.info(
- "series_status_changed",
- old_status=before.status,
- new_status=current_status,
- )
-
- if series.description != before.description:
- metadata_changed = True
- log.debug("series_description_updated")
-
- if series.publisher_id != before.publisher_id:
- metadata_changed = True
- log.debug("series_publisher_updated")
-
- return status_changed, metadata_changed
-
-
-def _metadata_refresh_due(series: Series, refresh_days: int) -> bool:
- """Return True when series-level metadata is missing or stale."""
- refreshed = series.metadata_last_refreshed
- if refreshed is None:
- return True
- if refreshed.tzinfo is None:
- refreshed = refreshed.replace(tzinfo=UTC)
- return (datetime.now(UTC) - refreshed).days >= refresh_days
def _metadata_refresh_days(settings: PullboxSettings) -> int:
- """Read metadata refresh days defensively for tests and runtime overrides."""
try:
return int(settings.metadata_refresh_days)
except (TypeError, ValueError):
return 30
-def _can_bootstrap_complete_catalog(series: Series, local_issue_count: int) -> bool:
- """Return True when a legacy complete catalog can safely start with recent sync."""
- provider_issue_count = int(series.issue_count or 0)
- return (
- series.issue_catalog_state == IssueCatalogState.COMPLETE
- and series.issue_catalog_last_synced_at is None
- and provider_issue_count > 0
- and local_issue_count >= provider_issue_count
- )
-
-
-def _issue_catalog_full_sync_due(
- series: Series,
- refresh_days: int,
- *,
- local_issue_count: int = 0,
-) -> bool:
- """Return True when scheduled issue sync should fetch the full issue list."""
- if refresh_days <= 0:
- return True
- if series.issue_catalog_state != IssueCatalogState.COMPLETE:
- return True
- synced = series.issue_catalog_last_synced_at
- if synced is None:
- return not _can_bootstrap_complete_catalog(series, local_issue_count)
- if synced.tzinfo is None:
- synced = synced.replace(tzinfo=UTC)
- return (datetime.now(UTC) - synced).days >= refresh_days
-
-
-def _issue_check_interval_for_series(series: Series) -> timedelta:
- """Return the normal issue-check cadence for a complete catalog."""
- if series.status == SeriesStatus.ENDED:
- if series.monitored:
- return _ENDED_MONITORED_ISSUE_CHECK_INTERVAL
- return _ENDED_UNMONITORED_ISSUE_CHECK_INTERVAL
- return _STANDARD_ISSUE_CHECK_INTERVAL
-
-
-def _issue_catalog_check_due(series: Series, *, now: datetime | None = None) -> bool:
- """Return True when a series should spend a ComicVine request on issue checks."""
- if series.issue_catalog_state != IssueCatalogState.COMPLETE:
- return True
-
- checked = series.issue_catalog_last_checked_at
- if checked is None:
- return True
- if checked.tzinfo is None:
- checked = checked.replace(tzinfo=UTC)
-
- current_time = now or datetime.now(UTC)
- interval = _issue_check_interval_for_series(series)
- return current_time - checked >= interval
-
-
-async def _sync_issue_catalog_for_series(
- metadata_svc: MetadataService,
- session: AsyncSession,
- series: Series,
- *,
- full_refresh_days: int,
- local_issue_count: int = 0,
-) -> tuple[list[Issue], str]:
- """Sync one series' issue catalog using full or recent ComicVine issue fetches."""
- bootstrap_complete_catalog = _can_bootstrap_complete_catalog(series, local_issue_count)
- checked_at = datetime.now(UTC)
- if _issue_catalog_full_sync_due(
- series,
- full_refresh_days,
- local_issue_count=local_issue_count,
- ):
- created = await metadata_svc.fetch_issues_for_series(session, series.id)
- mode = "full"
- series.issue_catalog_last_synced_at = checked_at
- series.issue_catalog_last_checked_at = checked_at
- else:
- created = await metadata_svc.fetch_recent_issues_for_series(
- session,
- series.id,
- limit=_RECENT_ISSUE_SYNC_LIMIT,
- )
- mode = "recent"
- series.issue_catalog_last_checked_at = checked_at
- if bootstrap_complete_catalog:
- series.issue_catalog_last_synced_at = checked_at
-
- series.issue_catalog_state = IssueCatalogState.COMPLETE
- series.issue_catalog_error = None
- return created, mode
-
-
-async def _sync_one_series(
- metadata_svc: MetadataService,
- session: AsyncSession,
- series: Series,
- *,
- refresh_days: int,
- local_issue_count: int,
-) -> tuple[list[Issue], str, bool, bool]:
- before = _take_snapshot(series)
- log = logger.bind(series_id=series.id, title=series.title)
- status_changed = metadata_changed = False
- if series.comicvine_id and _metadata_refresh_due(series, refresh_days):
- await metadata_svc.fetch_series(session, series.comicvine_id, download_cover=False)
- # Provider waits must never retain the publisher/series writer lock.
- await session.commit()
- await session.refresh(series)
- status_changed, metadata_changed = _detect_changes(series, before, log)
- if series.cover_url:
- await metadata_svc.download_series_cover(series, series.cover_url)
- await session.commit()
-
- if not _issue_catalog_check_due(series):
- return [], "skipped", status_changed, metadata_changed
- created, mode = await _sync_issue_catalog_for_series(
- metadata_svc,
- session,
- series,
- full_refresh_days=refresh_days,
- local_issue_count=local_issue_count,
- )
- return created, mode, status_changed, metadata_changed
-
-
def _provider_pause_seconds(exc: Exception) -> float | None:
if is_sqlite_locked_error(exc):
return 60
if isinstance(exc, TimeoutError):
return 300
+ if isinstance(exc, SeriesRefreshError):
+ if exc.retry_after_seconds is not None:
+ return float(max(60, exc.retry_after_seconds))
+ delays = [
+ max(
+ 60,
+ outcome.retry_after_seconds
+ or (3600 if outcome.status is SourceStatus.RATE_LIMITED else 300),
+ )
+ for outcome in exc.outcomes
+ if outcome.status
+ in {
+ SourceStatus.RATE_LIMITED,
+ SourceStatus.TIMEOUT,
+ SourceStatus.UNAVAILABLE,
+ }
+ ]
+ return float(min(delays)) if delays else None
details = exc.details or {} if isinstance(exc, ProviderError) else {}
status = exc.status_code if isinstance(exc, ComicVineError) else details.get("status_code")
retryable = exc.retryable if isinstance(exc, ComicVineError) else details.get("retryable")
@@ -278,172 +87,183 @@ def _provider_pause_seconds(exc: Exception) -> float | None:
async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult:
settings = get_settings()
factory = get_session_factory()
- series_to_search: list[int] = []
started = time.monotonic()
async with factory() as session:
- api_key = await get_comicvine_api_key(session)
- if not api_key:
- state = await load_sweep(session, task_id)
- if state.active:
- state.active = False
- state.retry_at = 0
- await save_sweep(session, task_id, state)
- await session.commit()
- schedule_sweep(task_id, state)
- log_deduped_warning(
- logger,
- f"{task_id}_missing_comicvine_key",
- key=f"{task_id}_missing_comicvine_key",
- action_required="Configure a ComicVine API key to enable metadata sync.",
- )
- return TaskExecutionResult(status="completed")
-
- state = await start_sweep(session, task_id)
- schedule_sweep(task_id, state)
- if state.retry_at > datetime.now(UTC).timestamp():
- schedule_sweep(task_id, state)
- return TaskExecutionResult(status="waiting")
-
+ eligible = await scheduled_series_eligibility(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ state = await start_sweep(session, task_id, eligible=eligible)
refresh_days = _metadata_refresh_days(settings)
+ base = [eligible]
+ if task_id == "refresh_metadata":
+ base.append(Series.monitored.is_(True))
+ await prune_retries(
+ session,
+ task_id,
+ base[0]
+ & (Series.monitored.is_(True) if task_id == "refresh_metadata" else Series.id > 0),
+ )
+ runtime = await retry_runtime(session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled)
predicates = [
- Series.comicvine_id.isnot(None),
+ *base,
Series.id > state.cursor,
Series.id <= state.upper_bound,
+ Series.issue_catalog_state != IssueCatalogState.HYDRATING,
]
if task_id == "refresh_metadata":
- predicates.extend(
- [
- Series.monitored.is_(True),
- or_(
- Series.metadata_last_refreshed.is_(None),
- Series.metadata_last_refreshed
- < datetime.now(UTC) - timedelta(days=refresh_days),
- ),
- ]
+ predicates.append(
+ or_(
+ Series.metadata_last_refreshed.is_(None),
+ Series.metadata_last_refreshed
+ < datetime.now(UTC) - timedelta(days=refresh_days),
+ )
)
ids = list(
- (
+ await session.scalars(
+ select(Series.id)
+ .where(*predicates)
+ .order_by(Series.id)
+ .limit(_METADATA_BATCH_SIZE + 1)
+ )
+ )
+ pending = await retry_candidates(session, task_id, runtime, limit=_METADATA_BATCH_SIZE)
+ due = (
+ list(
await session.scalars(
select(Series.id)
- .where(*predicates)
+ .where(
+ *base,
+ Series.id.in_(pending),
+ Series.issue_catalog_state != IssueCatalogState.HYDRATING,
+ )
.order_by(Series.id)
- .limit(_METADATA_BATCH_SIZE + 1)
)
- ).all()
+ )
+ if pending
+ else []
)
- if not ids:
- state.active = False
- state.retry_at = 0
+ if not ids and not due:
+ deadline = await retry_deadline(session, task_id, runtime)
+ state.active = deadline is not None
+ state.retry_at = deadline.timestamp() if deadline else 0
await save_sweep(session, task_id, state)
await session.commit()
schedule_sweep(task_id, state)
- return TaskExecutionResult(status="completed")
+ return TaskExecutionResult(status="waiting" if state.active else "completed")
- counts = {
- int(series_id): int(count)
- for series_id, count in (
- await session.execute(
- select(Issue.series_id, func.count(Issue.id))
- .where(Issue.series_id.in_(ids[:_METADATA_BATCH_SIZE]))
- .group_by(Issue.series_id)
- )
- ).all()
- }
- metadata_svc = await _create_metadata_service(api_key, settings, session)
+ schedule_sweep(task_id, state)
+ if state.retry_at > datetime.now(UTC).timestamp() and not due:
+ await session.commit()
+ return TaskExecutionResult(status="waiting")
await session.commit()
- processed = failed = new_issues = 0
+ # Reserve space for both fresh work and overdue retries, avoiding starvation.
+ quota = max(1, _METADATA_BATCH_SIZE // 2) if due else _METADATA_BATCH_SIZE
+ work = [(sid, False) for sid in ids[:quota]]
+ selected = {sid for sid, _ in work}
+ work.extend((sid, True) for sid in due if sid not in selected)
+ processed = normal_done = failed = new_issues = 0
paused = False
- try:
- for series_id in ids[:_METADATA_BATCH_SIZE]:
- if processed and time.monotonic() - started >= _METADATA_BATCH_SECONDS:
- break
- series = await session.get(Series, series_id)
- if series is None:
- state.cursor = series_id
- await save_sweep(session, task_id, state)
- await session.commit()
- processed += 1
- continue
- try:
- previous_cursor = state.cursor
- search_after_commit = False
- async with asyncio.timeout(_METADATA_SERIES_SECONDS):
- if task_id == "refresh_metadata":
- await metadata_svc.refresh_series(
- session,
- series_id,
- commit_before_provider_wait=True,
- )
- else:
- created, _mode, _sc, _mc = await _sync_one_series(
- metadata_svc,
- session,
- series,
- refresh_days=refresh_days,
- local_issue_count=counts.get(series_id, 0),
- )
- new_issues += len(created)
- if created and series.monitored:
- wanted = False
- for issue in created:
- if issue.status == IssueStatus.SKIPPED:
- issue.status = IssueStatus.WANTED
- wanted = True
- if wanted:
- search_after_commit = True
- state.cursor = series_id
- state.retry_at = 0
- await save_sweep(session, task_id, state)
- await session.commit()
- if search_after_commit:
- series_to_search.append(series_id)
- processed += 1
- except Exception as exc:
- await session.rollback()
- state.cursor = previous_cursor
- # Rollback expires ORM objects; use the stable ID, not their fields.
- pause_seconds = _provider_pause_seconds(exc)
- if pause_seconds is not None:
- state.retry_at = (
- datetime.now(UTC) + timedelta(seconds=pause_seconds)
- ).timestamp()
- await save_sweep(session, task_id, state)
- await session.commit()
- paused = True
- logger.warning(
- "metadata_sweep_paused",
- task_id=task_id,
- series_id=series_id,
- retry_seconds=pause_seconds,
- failure_type=type(exc).__name__,
+ for series_id, retry_only in work[:_METADATA_BATCH_SIZE]:
+ if processed and time.monotonic() - started >= _METADATA_BATCH_SECONDS:
+ break
+ previous_cursor = state.cursor
+ admission = await admit_retry(
+ session,
+ task_id,
+ series_id,
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ retry_only=retry_only,
+ )
+ refreshed: ScheduledSeriesRefresh | None = None
+ committing = False
+ try:
+ async with asyncio.timeout(_METADATA_SERIES_SECONDS):
+ refreshed = (
+ await refresh_scheduled_series(
+ session, series_id, registry=admission.registry
+ )
+ if task_id == "refresh_metadata"
+ else await sync_scheduled_issue_catalog(
+ session,
+ series_id,
+ refresh_days=refresh_days,
+ registry=admission.registry,
)
- break
- failed += 1
- processed += 1
+ )
+ committing = True
+ await settle_retry(
+ session, task_id, series_id, admission, outcomes=refreshed.outcomes
+ )
+ if not retry_only:
state.cursor = series_id
+ state.retry_at = 0
+ await save_sweep(session, task_id, state)
+ await session.commit()
+ except Exception as exc:
+ refreshed = None
+ await session.rollback()
+ state.cursor = previous_cursor
+ if committing:
+ # A failed atomic write must leave the previous cursor/retry intact.
+ raise
+ if is_sqlite_locked_error(exc):
+ state.retry_at = (datetime.now(UTC) + timedelta(seconds=60)).timestamp()
await save_sweep(session, task_id, state)
await session.commit()
+ paused = True
+ logger.warning(
+ "metadata_sweep_paused",
+ task_id=task_id,
+ series_id=series_id,
+ retry_seconds=60,
+ failure_type=type(exc).__name__,
+ )
+ break
+ outcomes = exc.outcomes if isinstance(exc, SeriesRefreshError) else ()
+ await settle_retry(
+ session,
+ task_id,
+ series_id,
+ admission,
+ outcomes=outcomes,
+ retry_seconds=_provider_pause_seconds(exc) if not outcomes else None,
+ )
+ if not retry_only:
+ state.cursor = series_id
+ state.retry_at = 0
+ await save_sweep(session, task_id, state)
+ await session.commit()
+ failed += 1
+ if isinstance(exc, SeriesRefreshError):
+ logger.warning(
+ f"{task_id}_series_deferred"
+ if _provider_pause_seconds(exc) or outcomes
+ else f"{task_id}_series_failed",
+ series_id=series_id,
+ reason=str(exc),
+ source_statuses={item.source.value: item.status.value for item in outcomes},
+ )
+ else:
logger.exception(f"{task_id}_series_failed", series_id=series_id)
-
- state.active = paused or processed < len(ids)
- await save_sweep(session, task_id, state)
- await session.commit()
- finally:
- close = getattr(metadata_svc, "close", None)
- if close is not None:
- await close()
-
- if series_to_search:
- from pullbox.tasks.search_task import search_series_issues
-
- for sid in series_to_search:
- get_scheduler()._scheduler.add_job(
- search_series_issues,
- trigger="date",
- args=[sid],
- id=f"search_new_{sid}_{int(time.time())}",
- misfire_grace_time=300,
- )
+ processed += 1
+ normal_done += int(not retry_only)
+ # Issue writes, deferred source work and sweep progress precede search.
+ if refreshed is not None:
+ new_issues += refreshed.added
+ if refreshed.search_wanted:
+ _schedule_new_issue_search(series_id)
+ if refreshed.cover_url:
+ await refresh_series_artwork(
+ session, series_id, refreshed.cover_url, refreshed.covers
+ )
+
+ deadline = await retry_deadline(session, task_id, runtime)
+ more = normal_done < len(ids)
+ state.active = paused or more or deadline is not None
+ if not paused:
+ state.retry_at = 0 if more or deadline is None else deadline.timestamp()
+ await save_sweep(session, task_id, state)
+ await session.commit()
schedule_sweep(task_id, state)
logger.info(
f"{task_id}_batch_complete",
@@ -457,6 +277,18 @@ async def _run_metadata_sweep(task_id: str) -> TaskExecutionResult:
return TaskExecutionResult(status="waiting" if state.active else "completed")
+def _schedule_new_issue_search(series_id: int) -> None:
+ from pullbox.tasks.search_task import search_series_issues
+
+ get_scheduler()._scheduler.add_job(
+ search_series_issues,
+ trigger="date",
+ args=[series_id],
+ id=f"search_new_{series_id}_{int(time.time())}",
+ misfire_grace_time=300,
+ )
+
+
async def sync_new_issues() -> TaskExecutionResult:
"""Resume a bounded all-series issue sweep without monopolizing the scheduler."""
return await _run_metadata_sweep("sync_new_issues")
diff --git a/src/pullbox/tasks/story_arc_metadata_task.py b/src/pullbox/tasks/story_arc_metadata_task.py
index f08e4af2..8f8e7919 100644
--- a/src/pullbox/tasks/story_arc_metadata_task.py
+++ b/src/pullbox/tasks/story_arc_metadata_task.py
@@ -1,24 +1,32 @@
-"""Daily, bounded provider membership discovery for monitored story arcs."""
+"""Daily, bounded source membership discovery for monitored story arcs."""
from __future__ import annotations
+import asyncio
from datetime import UTC, datetime
from typing import TYPE_CHECKING
import structlog
-from sqlalchemy import func, select
+from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import SQLAlchemyError
-from pullbox.core.comicvine_key import get_comicvine_api_key
+from pullbox.config import get_settings
+from pullbox.core.metadata_identity import IdentityNamespace, MetadataSource
+from pullbox.core.metadata_identity_state import IdentityVerificationState
from pullbox.core.scheduler import scheduled_task
from pullbox.database import get_session_factory
-from pullbox.models.issue import Issue
from pullbox.models.library import LibraryRoot
-from pullbox.models.series import Series
-from pullbox.models.story_arc import IssueStoryArc, StoryArc, StoryArcLifecycle
-from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider
+from pullbox.models.story_arc import StoryArc, StoryArcExternalIdentity, StoryArcLifecycle
+from pullbox.schemas.metadata_arc_catalog import ArcCatalogSelection
+from pullbox.schemas.metadata_sources import MetadataDomain, SourceCapability
from pullbox.services.import_activity import has_active_import_scheduler_protection
-from pullbox.services.story_arc_catalog import MAX_CATALOG_PARENTS, StoryArcCatalogService
+from pullbox.services.metadata_arc_catalog import StoryArcSourceError, fetch_source_arc_catalog
+from pullbox.services.metadata_arc_commands import catalog_writer
+from pullbox.services.metadata_arc_refresh import fetch_arc_metadata, read_arc_refresh_state
+from pullbox.services.metadata_discovery import MetadataSourceRegistry
+from pullbox.services.metadata_read_cache import source_read_cache
+from pullbox.services.metadata_sources import load_source_runtime
+from pullbox.services.story_arc_catalog_types import StoryArcCatalogError
from pullbox.services.story_arc_service import StoryArcServiceError
if TYPE_CHECKING:
@@ -27,123 +35,239 @@
logger = structlog.get_logger(__name__)
_PAGE_SIZE = 25
+_NAMESPACES = tuple({source.identity_namespace.value for source in MetadataSource})
def _active_monitored() -> tuple[ColumnElement[bool], ...]:
+ verified = (
+ select(StoryArcExternalIdentity.id)
+ .where(
+ StoryArcExternalIdentity.story_arc_id == StoryArc.id,
+ StoryArcExternalIdentity.namespace == "story_arc",
+ StoryArcExternalIdentity.source.in_(_NAMESPACES),
+ StoryArcExternalIdentity.verification_state == IdentityVerificationState.VERIFIED,
+ )
+ .exists()
+ )
return (
StoryArc.monitored.is_(True),
StoryArc.lifecycle == StoryArcLifecycle.ACTIVE,
- StoryArc.comicvine_id.is_not(None),
+ or_(StoryArc.comicvine_id.is_not(None), verified),
+ )
+
+
+async def _targets(
+ session: AsyncSession, arc: StoryArc, registry: MetadataSourceRegistry
+) -> tuple[ArcCatalogSelection, ...]:
+ claims = list(
+ await session.scalars(
+ select(StoryArcExternalIdentity).where(
+ StoryArcExternalIdentity.story_arc_id == arc.id,
+ StoryArcExternalIdentity.namespace == "story_arc",
+ StoryArcExternalIdentity.source.in_(_NAMESPACES),
+ )
+ )
+ )
+ identities = {
+ claim.source: claim.external_id
+ for claim in claims
+ if claim.verification_state is IdentityVerificationState.VERIFIED
+ }
+ # A retained canonical claim takes precedence over a legacy compatibility
+ # key, including a stale/conflicted claim that blocks automatic refresh.
+ if arc.comicvine_id and not any(
+ claim.source == IdentityNamespace.COMICVINE for claim in claims
+ ):
+ identities[IdentityNamespace.COMICVINE] = str(arc.comicvine_id)
+ return tuple(
+ ArcCatalogSelection(
+ source=source,
+ external_id=identities[source.identity_namespace],
+ source_revision=registry.runtime[source].policy.revision,
+ )
+ for source in registry.ordered_sources(domain=MetadataDomain.STORY_ARCS)
+ if source.identity_namespace in identities
+ and registry.runtime[source].policy.revision > 0
+ and all(
+ registry.source_availability(source, capability) is None
+ for capability in (
+ SourceCapability.STORY_ARC_DETAILS,
+ SourceCapability.STORY_ARC_ISSUES,
+ SourceCapability.SERIES_DETAILS,
+ )
+ )
+ )
+
+
+async def _registry(session: AsyncSession) -> MetadataSourceRegistry:
+ flag = get_settings().metadata_gcd_api_v2_enabled
+ return MetadataSourceRegistry(
+ await load_source_runtime(session, gcd_api_enabled=flag),
+ gcd_api_enabled=flag,
+ read_cache=source_read_cache(session),
+ revalidate_reads=True,
)
-async def _refresh_arc(
- factory: async_sessionmaker[AsyncSession], service: StoryArcCatalogService, arc_id: int
-) -> int:
- """Snapshot, fetch outside the session, recheck consent, then persist."""
+async def _record_failure(
+ factory: async_sessionmaker[AsyncSession],
+ arc_id: int,
+ revision: int,
+ targets: tuple[ArcCatalogSelection, ...],
+ code: str,
+) -> None:
async with factory() as session:
arc = await session.scalar(
- select(StoryArc).where(StoryArc.id == arc_id, *_active_monitored())
+ select(StoryArc)
+ .where(StoryArc.id == arc_id, StoryArc.revision == revision, *_active_monitored())
+ .with_for_update()
)
- if arc is None:
- return 0
- provider_id, revision = str(arc.comicvine_id), arc.revision
- known_parents = tuple(
- str(value)
- for value in await session.scalars(
- select(Series.comicvine_id)
- .join(Issue, Issue.series_id == Series.id)
- .join(IssueStoryArc, IssueStoryArc.issue_id == Issue.id)
- .where(IssueStoryArc.story_arc_id == arc_id, Series.comicvine_id.is_not(None))
- .distinct()
- .limit(MAX_CATALOG_PARENTS)
+ if arc is None or await _targets(session, arc, await _registry(session)) != targets:
+ return
+ await session.execute(
+ update(StoryArc)
+ .where(StoryArc.id == arc_id, StoryArc.revision == revision, *_active_monitored())
+ .values(
+ diagnostics={
+ **arc.diagnostics,
+ "provider_refresh_error": {
+ "code": code,
+ "checked_at": datetime.now(UTC).isoformat(),
+ },
+ }
)
)
- preview = await service.preview(provider_id, known_series_provider_ids=known_parents)
- if await has_active_import_scheduler_protection(factory):
- return 0
+ await session.commit()
+
+
+async def _refresh_arc(factory: async_sessionmaker[AsyncSession], arc_id: int) -> int | None:
+ """Read exact ownership, release the reader, fetch, then recheck consent."""
async with factory() as session:
arc = await session.scalar(
select(StoryArc).where(StoryArc.id == arc_id, *_active_monitored())
)
- if arc is None or arc.revision != revision:
- return 0
- # Older imports may lack an arc-specific future destination. Only the
- # explicitly configured default managed root is a safe fallback. Never
- # infer it from an import source or the separate arc-copy directory.
- default_roots = list(
- await session.scalars(
- select(LibraryRoot.id)
- .where(LibraryRoot.is_default_managed_destination.is_(True))
- .limit(2)
- )
+ if arc is None:
+ return None
+ revision = arc.revision
+ registry = await _registry(session)
+ targets = await _targets(session, arc, registry)
+ before = await read_arc_refresh_state(session, arc_id)
+ if not targets:
+ return None
+ try:
+ deadline = asyncio.get_running_loop().time() + 120
+ failure = None
+ preview = None
+ for target in targets:
+ if await has_active_import_scheduler_protection(factory):
+ return None
+ remaining = deadline - asyncio.get_running_loop().time()
+ if remaining <= 0:
+ raise StoryArcCatalogError("source_timeout", "Story arc update timed out")
+ try:
+ preview = await fetch_source_arc_catalog(
+ registry,
+ target.source,
+ target.external_id,
+ source_revision=target.source_revision,
+ timeout=min(120, remaining),
+ )
+ break
+ except StoryArcSourceError as exc:
+ failure = exc
+ if preview is None:
+ assert failure is not None
+ raise failure
+ if await has_active_import_scheduler_protection(factory):
+ return None
+ metadata_refresh = await fetch_arc_metadata(
+ registry, before, preview, replace_managed=False
)
- result = await service.refresh(
- session,
- arc_id,
- preview,
- expected_revision=revision,
- library_root_id=default_roots[0] if len(default_roots) == 1 else None,
+ if await has_active_import_scheduler_protection(factory):
+ return None
+ async with factory() as session:
+ arc = await session.scalar(
+ select(StoryArc).where(StoryArc.id == arc_id, *_active_monitored())
+ )
+ if arc is None or arc.revision != revision:
+ return None
+ if await _targets(session, arc, await _registry(session)) != targets:
+ return None
+ # Only the explicit managed default may fill a legacy missing root.
+ # Never infer it from a source or separate arc-copy folder.
+ default_roots = list(
+ await session.scalars(
+ select(LibraryRoot.id)
+ .where(LibraryRoot.is_default_managed_destination.is_(True))
+ .limit(2)
+ )
+ )
+ result = await catalog_writer(preview).refresh(
+ session,
+ arc_id,
+ preview,
+ expected_revision=revision,
+ library_root_id=default_roots[0] if len(default_roots) == 1 else None,
+ replace_metadata=False,
+ metadata_refresh=metadata_refresh,
+ )
+ await session.commit()
+ # The wanted sweep rechecks monitoring, skips, dates and duplicates.
+ return len(result.added_membership_ids)
+ except (StoryArcServiceError, SQLAlchemyError) as exc:
+ await _record_failure(
+ factory, arc_id, revision, targets, getattr(exc, "code", "provider_unavailable")
)
- await session.commit()
- # The shared wanted sweep observes new members after this commit and
- # rechecks monitoring, explicit skips, dates, and duplicate downloads.
- return len(result.added_membership_ids)
+ raise
async def sync_story_arc_metadata() -> None:
- """Refresh each eligible arc once, isolating provider failures per arc."""
+ """Refresh each eligible arc once, isolating source failures per arc."""
factory = get_session_factory()
if await has_active_import_scheduler_protection(factory):
return
async with factory() as session:
- api_key = await get_comicvine_api_key(session)
ceiling = await session.scalar(select(func.max(StoryArc.id)).where(*_active_monitored()))
- if not api_key or ceiling is None:
+ if ceiling is None:
return
- provider = ComicVineProvider(api_key=api_key)
- service = StoryArcCatalogService(provider)
- cursor = refreshed = added = failed = 0
- try:
- while cursor < ceiling:
- async with factory() as session:
- ids = list(
- await session.scalars(
- select(StoryArc.id)
- .where(StoryArc.id > cursor, StoryArc.id <= ceiling, *_active_monitored())
- .order_by(StoryArc.id)
- .limit(_PAGE_SIZE)
- )
+ cursor = refreshed = added = failed = skipped = 0
+ while cursor < ceiling:
+ async with factory() as session:
+ ids = list(
+ await session.scalars(
+ select(StoryArc.id)
+ .where(StoryArc.id > cursor, StoryArc.id <= ceiling, *_active_monitored())
+ .order_by(StoryArc.id)
+ .limit(_PAGE_SIZE)
)
- if not ids:
- break
- for arc_id in ids:
- if await has_active_import_scheduler_protection(factory):
- return
- cursor = arc_id
- try:
- added += await _refresh_arc(factory, service, arc_id)
+ )
+ if not ids:
+ break
+ for arc_id in ids:
+ if await has_active_import_scheduler_protection(factory):
+ return
+ cursor = arc_id
+ try:
+ result = await _refresh_arc(factory, arc_id)
+ if result is None:
+ skipped += 1
+ else:
+ added += result
refreshed += 1
- except (ComicVineError, StoryArcServiceError, SQLAlchemyError) as exc:
- failed += 1
- code = getattr(exc, "code", "provider_unavailable")
- logger.warning(
- "story_arc_metadata_refresh_failed", story_arc_id=arc_id, category=code
- )
- async with factory() as session:
- arc = await session.get(StoryArc, arc_id)
- if arc is not None:
- arc.diagnostics = {
- **arc.diagnostics,
- "provider_refresh_error": {
- "code": str(code),
- "checked_at": datetime.now(UTC).isoformat(),
- },
- }
- await session.commit()
- finally:
- await provider.close()
- logger.info("story_arc_metadata_refresh_done", refreshed=refreshed, added=added, failed=failed)
+ except (StoryArcServiceError, SQLAlchemyError) as exc:
+ failed += 1
+ logger.warning(
+ "story_arc_metadata_refresh_failed",
+ story_arc_id=arc_id,
+ category=getattr(exc, "code", "provider_unavailable"),
+ )
+ logger.info(
+ "story_arc_metadata_refresh_done",
+ refreshed=refreshed,
+ added=added,
+ failed=failed,
+ skipped=skipped,
+ )
@scheduled_task(
diff --git a/src/pullbox/ui/import_follow_up.py b/src/pullbox/ui/import_follow_up.py
index 871ed729..3d0a050b 100644
--- a/src/pullbox/ui/import_follow_up.py
+++ b/src/pullbox/ui/import_follow_up.py
@@ -18,6 +18,7 @@
from pullbox.models.series import IssueCatalogState, Series
from pullbox.models.story_arc import ImportedStoryArcStatus
from pullbox.models.story_arc_import import ImportedStoryArc
+from pullbox.services.import_metadata_follow_up import failed_metadata_write_filter
from pullbox.ui.import_orphaned_routes import load_import_orphaned_context
from pullbox.ui.import_results_context import load_import_results_context
@@ -113,6 +114,12 @@ def _follow_up_job_filter() -> ColumnElement[bool]:
misplaced_files,
unresolved_story_arcs,
failed_metadata,
+ select(ImportedFile.id)
+ .where(
+ ImportedFile.import_job_id == ImportJob.id,
+ failed_metadata_write_filter(),
+ )
+ .exists(),
)
diff --git a/src/pullbox/ui/import_results_context.py b/src/pullbox/ui/import_results_context.py
index b1c9efba..31b6111c 100644
--- a/src/pullbox/ui/import_results_context.py
+++ b/src/pullbox/ui/import_results_context.py
@@ -1083,6 +1083,9 @@ async def load_import_results_context(
files_no_match - orphaned_file_no_match_count,
0,
)
+ from pullbox.services.import_metadata_follow_up import count_failed_metadata_writes
+
+ archive_metadata_failed_count = await count_failed_metadata_writes(session, job_id)
catalog_sync_series = await _load_catalog_sync_series(session, job_id)
catalog_sync_failed_count = sum(
1
@@ -1103,6 +1106,7 @@ async def load_import_results_context(
+ int(job.status is ImportJobStatus.FAILED and files_safety_blocked > 0)
+ int(story_arc_results_summary["story_arcs_follow_up_count"] > 0)
+ int(catalog_sync_failed_count > 0)
+ + int(archive_metadata_failed_count > 0)
)
rollback_incomplete = bool(
rollback_journal_summary["rollback_manual_recovery_count"]
@@ -1133,6 +1137,7 @@ async def load_import_results_context(
"orphaned_file_no_match_count": orphaned_file_no_match_count,
"identified_series_file_no_match_count": identified_series_file_no_match_count,
"catalog_sync_pending_count": catalog_sync_pending_count,
+ "archive_metadata_failed_count": archive_metadata_failed_count,
"catalog_sync_failed_count": catalog_sync_failed_count,
"catalog_sync_attention_count": len(catalog_sync_series),
"catalog_sync_series": catalog_sync_series,
diff --git a/src/pullbox/ui/import_routes.py b/src/pullbox/ui/import_routes.py
index 2095d7bc..3829f57c 100644
--- a/src/pullbox/ui/import_routes.py
+++ b/src/pullbox/ui/import_routes.py
@@ -116,6 +116,71 @@ def _ctx(request: Request, user: object | None = None, **kwargs: object) -> dict
return dict(context)
+@router.get(
+ "/import/{job_id}/metadata-writes", response_class=HTMLResponse, include_in_schema=False
+)
+async def import_metadata_write_follow_up(
+ job_id: int,
+ request: Request,
+ user: AuthenticatedUser,
+ session: DbSession,
+ page: int = Query(1, ge=1),
+) -> Response:
+ from pullbox.services.import_metadata_follow_up import load_metadata_write_follow_up
+
+ return _templates().TemplateResponse(
+ request,
+ "partials/import_metadata_write_follow_up.html",
+ _ctx(request, user, **await load_metadata_write_follow_up(session, job_id, page=page)),
+ )
+
+
+@router.post(
+ "/import/{job_id}/files/{file_id}/retry-metadata",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def import_retry_metadata_write(
+ job_id: int,
+ file_id: int,
+ request: Request,
+ user: InteractiveOperatorUser,
+ session: DbSession,
+ page: int = Query(1, ge=1),
+) -> Response:
+ from pullbox.api.deps import get_request_session_factory
+ from pullbox.composition.services import build_import_service
+ from pullbox.services.import_metadata_follow_up import (
+ load_metadata_write_follow_up,
+ retry_import_metadata_write,
+ )
+
+ message, error = "Metadata writing is already queued or complete.", None
+ try:
+ service = await build_import_service(session)
+ queued = await retry_import_metadata_write(session, job_id, file_id, actor=user.username)
+ await session.commit()
+ if queued:
+ service.schedule_comicinfo_enrichment(
+ get_request_session_factory(request), job_id=job_id
+ )
+ message = "Metadata retry queued. Follow its progress in the activity menu."
+ except ValidationError as exc:
+ await session.rollback()
+ error = exc.message
+ return _templates().TemplateResponse(
+ request,
+ "partials/import_metadata_write_follow_up.html",
+ _ctx(
+ request,
+ user,
+ metadata_message=message if error is None else None,
+ metadata_error=error,
+ **await load_metadata_write_follow_up(session, job_id, page=page),
+ ),
+ )
+
+
def _object_to_int(value: object, default: int = 0) -> int:
if isinstance(value, bool):
return int(value)
diff --git a/src/pullbox/ui/metadata_arc_search.py b/src/pullbox/ui/metadata_arc_search.py
new file mode 100644
index 00000000..9a7624bf
--- /dev/null
+++ b/src/pullbox/ui/metadata_arc_search.py
@@ -0,0 +1,111 @@
+"""Source labels and fresh exact ownership for the existing Add Story Arc page."""
+
+from urllib.parse import urlencode
+
+from fastapi import Request
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pullbox.config import get_settings
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.schemas.metadata_sources import (
+ SourceCapability,
+ SourceStatus,
+ StoryArcDiscoveryQuery,
+ StoryArcDiscoveryRead,
+)
+from pullbox.services.metadata_arc_search import collect_arc_candidates
+from pullbox.services.metadata_discovery import MetadataSourceRegistry, describe_source_policies
+from pullbox.services.metadata_search_cache import MetadataSearchBusyError, discovery_cache_key
+from pullbox.services.metadata_sources import load_source_runtime
+from pullbox.services.provider_artwork import allowed_artwork_url
+from pullbox.services.story_arc_catalog_identity import catalog_owners
+from pullbox.ui.metadata_series_search import SOURCE_LABELS, source_messages
+from pullbox.ui.series_routes import _request_search_cache
+
+
+async def arc_search_context(
+ request: Request,
+ session: AsyncSession,
+ *,
+ q: str,
+ page: int,
+ source: MetadataSource | None,
+ base_url: str,
+) -> dict[str, object]:
+ flag = get_settings().metadata_gcd_api_v2_enabled
+ runtime = await load_source_runtime(session, gcd_api_enabled=flag)
+ descriptors = describe_source_policies([item.policy for item in runtime], gcd_api_enabled=flag)
+ capable = [row for row in descriptors if SourceCapability.STORY_ARC_SEARCH in row.capabilities]
+ selected = [source] if source is not None else [row.source for row in capable if row.enabled]
+ query = q.strip()
+ snapshot = StoryArcDiscoveryRead(results=[], sources=[])
+ error = ""
+ await session.rollback()
+ if len(query) >= 2 and selected:
+ search = StoryArcDiscoveryQuery(query=query, sources=selected)
+ registry = MetadataSourceRegistry(runtime, gcd_api_enabled=flag)
+ try:
+ snapshot = await _request_search_cache(request).get_arcs(
+ discovery_cache_key(search, runtime, catalog_generation=None, gcd_api_enabled=flag),
+ lambda: collect_arc_candidates(registry, search),
+ )
+ except MetadataSearchBusyError as exc:
+ error = str(exc)
+ if not snapshot.results and any(
+ row.status not in {SourceStatus.OK, SourceStatus.EMPTY} for row in snapshot.sources
+ ):
+ error = "Story Arc search failed. Check the source status below and retry."
+ total = len(snapshot.results)
+ total_pages = max(1, (total + 19) // 20)
+ page = min(page, total_pages)
+ visible = snapshot.results[(page - 1) * 20 : page * 20]
+ owners = {}
+ for transport in {row.source for row in visible}:
+ found = await catalog_owners(
+ session,
+ MetadataEntityKind.STORY_ARC,
+ [row.external_id for row in visible if row.source is transport],
+ transport,
+ )
+ owners.update({(transport.identity_namespace, key): value for key, value in found.items()})
+ results = []
+ for row in visible:
+ results.append(
+ {
+ "metadata": {
+ **row.model_dump(),
+ "provider_id": row.external_id,
+ "cover_url": row.image_url
+ if row.image_url and allowed_artwork_url(row.image_url)
+ else None,
+ },
+ "existing_id": owners.get((row.identity_namespace, row.external_id)),
+ "source_label": SOURCE_LABELS[row.source],
+ "dom_id": f"arc-result-{row.source.value}-{row.external_id}",
+ "preview_url": f"/story-arcs/catalog/{row.source.value}/{row.external_id}",
+ }
+ )
+ params = {"q": query, **({"source": source.value} if source else {})}
+ return {
+ "query": query,
+ "catalog_query": query,
+ "results": results,
+ "total": total,
+ "page": page,
+ "total_pages": total_pages,
+ "shown_count": len(results),
+ "in_library_count": sum(bool(row["existing_id"]) for row in results),
+ "error_message": error,
+ "source_notices": source_messages(snapshot) if len(query) >= 2 else [],
+ "source_options": [
+ ("", "All enabled sources"),
+ *[(r.source.value, SOURCE_LABELS[r.source]) for r in capable],
+ ],
+ "source_filter": source.value if source else "",
+ "search_source_label": SOURCE_LABELS[source] if source else "All enabled sources",
+ "pagination_base_url": f"{base_url}?{urlencode(params)}",
+ "next_url": f"{base_url}?{urlencode({**params, 'page': page + 1})}"
+ if page < total_pages
+ else "",
+ "previous_url": f"{base_url}?{urlencode({**params, 'page': page - 1})}" if page > 1 else "",
+ }
diff --git a/src/pullbox/ui/metadata_series_search.py b/src/pullbox/ui/metadata_series_search.py
new file mode 100644
index 00000000..9f54f3fe
--- /dev/null
+++ b/src/pullbox/ui/metadata_series_search.py
@@ -0,0 +1,313 @@
+"""Provider-aware Add Series presentation; never attach identities from search."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING
+from urllib.parse import urlsplit
+
+from sqlalchemy import and_, or_, select
+
+from pullbox.core.metadata_identity import IdentityNamespace, MetadataSource
+from pullbox.core.metadata_identity_state import IdentityVerificationState
+from pullbox.core.naming import format_series_folder
+from pullbox.models.metadata_identity import SeriesExternalIdentity
+from pullbox.models.series import Series
+from pullbox.providers.metadata.gcd_local import GcdLocalSource
+from pullbox.providers.metadata.sources import catalog_search_cache_token
+from pullbox.schemas.metadata_sources import (
+ ProviderSeriesRead,
+ SeriesDiscoveryQuery,
+ SeriesDiscoveryRead,
+ SourceOutcome,
+ SourceStatus,
+ StoryArcDiscoveryRead,
+)
+from pullbox.services.catalog.contract import CatalogError
+from pullbox.services.metadata_discovery import MetadataSourceError, MetadataSourceRegistry
+from pullbox.services.metadata_search_cache import MetadataSearchCache, discovery_cache_key
+from pullbox.services.provider_artwork import allowed_artwork_url
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ from pullbox.services.metadata_sources import SourceRuntime
+
+SOURCE_LABELS = {
+ MetadataSource.COMICVINE_LOCAL: "ComicVine local",
+ MetadataSource.COMICVINE_API: "ComicVine API",
+ MetadataSource.METRON_API: "Metron",
+ MetadataSource.GCD_LOCAL: "GCD local",
+ MetadataSource.GCD_API_V2: "GCD API",
+}
+
+_OUTCOME_TEXT = {
+ SourceStatus.EMPTY: "no matches",
+ SourceStatus.DISABLED: "disabled in Metadata settings",
+ SourceStatus.FEATURE_DISABLED: "not enabled for this release",
+ SourceStatus.NOT_IMPLEMENTED: "not available yet",
+ SourceStatus.UNCONFIGURED: "needs credentials in Metadata settings",
+ SourceStatus.INVALID_CONFIG: "check its configuration in Metadata settings",
+ SourceStatus.AUTHENTICATION_FAILED: "credentials were rejected; check Metadata settings",
+ SourceStatus.RATE_LIMITED: "rate limited; try again later",
+ SourceStatus.TIMEOUT: "timed out; try again later",
+ SourceStatus.UNAVAILABLE: "unavailable; check its status in Metadata settings",
+ SourceStatus.INCOMPATIBLE_RESPONSE: "returned an unreadable response",
+ SourceStatus.UNSUPPORTED: "does not support series search",
+}
+
+
+def _resource_url(row: ProviderSeriesRead) -> str | None:
+ if not row.resource_url:
+ return None
+ try:
+ url = urlsplit(row.resource_url)
+ hosts, path = {
+ IdentityNamespace.COMICVINE: (
+ {"comicvine.gamespot.com", "comicvine.com"},
+ f"/volume/4050-{row.external_id}/",
+ ),
+ IdentityNamespace.METRON: ({"metron.cloud"}, f"/series/{row.external_id}/"),
+ IdentityNamespace.GCD: (
+ {"www.comics.org", "comics.org"},
+ f"/series/{row.external_id}/",
+ ),
+ }.get(row.identity_namespace, (set(), ""))
+ if (
+ url.scheme == "https"
+ and url.hostname in hosts
+ and url.port in {None, 443}
+ and url.path == path
+ and not url.username
+ and not url.password
+ and not url.query
+ and not url.fragment
+ and not any(char.isspace() or ord(char) < 32 for char in row.resource_url)
+ ):
+ return row.resource_url
+ except ValueError:
+ pass
+ return None
+
+
+def source_messages(snapshot: SeriesDiscoveryRead | StoryArcDiscoveryRead) -> list[str]:
+ if not snapshot.sources:
+ return ["No search sources are enabled. Enable a source in Metadata settings."]
+ messages = []
+ for outcome in snapshot.sources:
+ detail = _OUTCOME_TEXT.get(outcome.status)
+ if outcome.status is SourceStatus.UNSUPPORTED and isinstance(
+ snapshot, StoryArcDiscoveryRead
+ ):
+ detail = "does not support Story Arc search"
+ if detail is None and outcome.truncated:
+ detail = "showing a limited result set; narrow the search for more"
+ if detail is None and outcome.rejected_results:
+ detail = "some results could not be read"
+ if detail is not None:
+ messages.append(f"{SOURCE_LABELS[outcome.source]}: {detail}.")
+ return messages
+
+
+async def search_snapshot(
+ query: SeriesDiscoveryQuery,
+ runtime: Sequence[SourceRuntime],
+ cache: MetadataSearchCache,
+ *,
+ gcd_api_enabled: bool,
+) -> SeriesDiscoveryRead:
+ registry = MetadataSourceRegistry(runtime, gcd_api_enabled=gcd_api_enabled)
+ local_selected = any(
+ item.policy.source is MetadataSource.COMICVINE_LOCAL
+ and item.policy.enabled
+ and (query.sources is None or item.policy.source in query.sources)
+ for item in runtime
+ )
+ generation = None
+ cacheable = True
+ gcd_runtime = next(
+ (
+ item
+ for item in runtime
+ if item.policy.source is MetadataSource.GCD_LOCAL
+ and item.policy.enabled
+ and (query.sources is None or item.policy.source in query.sources)
+ ),
+ None,
+ )
+ gcd_generation = None
+ if gcd_runtime is not None:
+ try:
+ gcd_generation = await GcdLocalSource(gcd_runtime.gcd_snapshot).cache_token()
+ except MetadataSourceError:
+ gcd_generation, cacheable = "unreadable", False
+ if local_selected:
+ try:
+ async with asyncio.timeout(8):
+ generation = await catalog_search_cache_token()
+ except (CatalogError, OSError, ValueError, TimeoutError, MetadataSourceError):
+ # A broken local catalog must not suppress healthy remote results.
+ generation = "unreadable"
+ cacheable = False
+
+ async def load() -> SeriesDiscoveryRead:
+ result = (
+ await registry.discover(query)
+ if query.search_mode == "preview"
+ else await registry.discover_all(query)
+ )
+ if generation != "unreadable" and local_selected:
+ try:
+ async with asyncio.timeout(8):
+ changed = await catalog_search_cache_token() != generation
+ except (CatalogError, OSError, ValueError, TimeoutError, MetadataSourceError):
+ changed = True
+ if changed:
+ # Never present mixed generations as one complete local snapshot,
+ # or hide healthy remote candidates because the catalog changed.
+ result = result.model_copy(deep=True)
+ result.results = [
+ row
+ for row in result.results
+ if row.source is not MetadataSource.COMICVINE_LOCAL
+ ]
+ for row in result.results:
+ row.also_from = [
+ source
+ for source in row.also_from
+ if source is not MetadataSource.COMICVINE_LOCAL
+ ]
+ result.sources = [
+ SourceOutcome(
+ source=outcome.source, status=SourceStatus.UNAVAILABLE, truncated=True
+ )
+ if outcome.source is MetadataSource.COMICVINE_LOCAL
+ else outcome
+ for outcome in result.sources
+ ]
+ if gcd_runtime is not None and gcd_generation != "unreadable":
+ try:
+ changed = (
+ await GcdLocalSource(gcd_runtime.gcd_snapshot).cache_token() != gcd_generation
+ )
+ except MetadataSourceError:
+ changed = True
+ if changed:
+ result = result.model_copy(deep=True)
+ result.results = [
+ row for row in result.results if row.source is not MetadataSource.GCD_LOCAL
+ ]
+ for row in result.results:
+ row.also_from = [
+ source for source in row.also_from if source is not MetadataSource.GCD_LOCAL
+ ]
+ result.sources = [
+ SourceOutcome(
+ source=outcome.source, status=SourceStatus.INVALID_CONFIG, truncated=True
+ )
+ if outcome.source is MetadataSource.GCD_LOCAL
+ else outcome
+ for outcome in result.sources
+ ]
+ return result
+
+ return await cache.get(
+ discovery_cache_key(
+ query,
+ runtime,
+ catalog_generation=generation,
+ gcd_api_enabled=gcd_api_enabled,
+ gcd_generation=gcd_generation,
+ ),
+ load,
+ cache_result=cacheable,
+ )
+
+
+async def format_source_series_results(
+ session: AsyncSession,
+ results: list[ProviderSeriesRead],
+ *,
+ folder_template: str,
+ replace_illegal: bool,
+ colon_replacement: str,
+) -> list[dict[str, object]]:
+ """Decorate only the visible page with fresh, exact local ownership."""
+ if not results:
+ return []
+ identities = {(item.identity_namespace, item.external_id) for item in results}
+ claims = (
+ await session.scalars(
+ select(SeriesExternalIdentity).where(
+ or_(
+ *[
+ and_(
+ SeriesExternalIdentity.identity_namespace == namespace,
+ SeriesExternalIdentity.external_id == external_id,
+ )
+ for namespace, external_id in identities
+ ]
+ )
+ )
+ )
+ ).all()
+ owners = {
+ (claim.identity_namespace, claim.external_id): (
+ claim.series_id,
+ claim.verification_state is IdentityVerificationState.VERIFIED,
+ )
+ for claim in claims
+ }
+ cv_ids = [
+ int(external_id)
+ for namespace, external_id in identities
+ if namespace is IdentityNamespace.COMICVINE and int(external_id) < 2**63
+ ]
+ if cv_ids:
+ # An explicit generic claim takes precedence over the legacy bridge.
+ rows = (
+ await session.execute(
+ select(Series.id, Series.comicvine_id, SeriesExternalIdentity.id)
+ .outerjoin(
+ SeriesExternalIdentity,
+ and_(
+ SeriesExternalIdentity.series_id == Series.id,
+ SeriesExternalIdentity.identity_namespace == IdentityNamespace.COMICVINE,
+ ),
+ )
+ .where(Series.comicvine_id.in_(cv_ids))
+ )
+ ).all()
+ for series_id, cv_id, claim_id in rows:
+ key = (IdentityNamespace.COMICVINE, str(cv_id))
+ if key not in owners:
+ owners[key] = (series_id, claim_id is None)
+ formatted = []
+ for row in results:
+ owner = owners.get((row.identity_namespace, row.external_id))
+ formatted.append(
+ {
+ **row.model_dump(mode="json"),
+ "source_label": SOURCE_LABELS[row.source],
+ "dom_id": f"metadata-result-{row.identity_namespace.value}-{row.external_id}",
+ "publisher_name": row.publisher,
+ "resource_url": _resource_url(row),
+ "cover_url": row.image_url
+ if row.image_url and allowed_artwork_url(row.image_url)
+ else None,
+ "already_added": bool(owner and owner[1]),
+ "identity_needs_review": bool(owner and not owner[1]),
+ "library_series_id": owner[0] if owner else None,
+ "folder_preview": format_series_folder(
+ title=row.title,
+ year=row.year_start,
+ publisher=row.publisher,
+ template=folder_template,
+ replace_illegal=replace_illegal,
+ colon_replacement=colon_replacement,
+ ),
+ }
+ )
+ return formatted
diff --git a/src/pullbox/ui/series_detail_routes.py b/src/pullbox/ui/series_detail_routes.py
index 37573dd9..feb56ff0 100644
--- a/src/pullbox/ui/series_detail_routes.py
+++ b/src/pullbox/ui/series_detail_routes.py
@@ -375,6 +375,10 @@ async def issue_detail(
deep_open_requested = read == "1"
open_reader_on_load = deep_open_requested and issue_reading.primary_label is not None
+ from pullbox.services.metadata_credits import read_issue_credits
+
+ credits = await read_issue_credits(session, [issue.id])
+
return _templates().TemplateResponse(
request,
"pages/issue_detail.html",
@@ -382,6 +386,7 @@ async def issue_detail(
request,
user,
issue=issue,
+ issue_metadata_credits=credits.get(issue.id, ()),
issue_story_arc_origin=story_arc_origin,
issue_detail_back_url=issue_detail_back_url,
reader_enabled=reader_enabled,
@@ -392,6 +397,42 @@ async def issue_detail(
)
+@issue_router.get(
+ "/htmx/issues/{issue_id}/metadata",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def htmx_issue_metadata(
+ request: Request,
+ issue_id: int,
+ user: AuthenticatedUser,
+ session: DbSession,
+) -> Response:
+ """Render committed metadata only; no provider calls or full-page replacement."""
+ from pullbox.services.metadata_credits import read_issue_credits
+
+ issue = await _load_issue_detail_record(session, issue_id)
+ if issue is None:
+ return Response(status_code=404)
+ reader_enabled = get_settings().reader_enabled
+ reading = await _issue_reading_view(
+ session, user_id=user.id, issue=issue, reader_enabled=reader_enabled
+ )
+ credits = await read_issue_credits(session, [issue.id])
+ return _templates().TemplateResponse(
+ request,
+ "partials/issue_metadata_content.html",
+ _ctx(
+ request,
+ user,
+ issue=issue,
+ issue_metadata_credits=credits.get(issue.id, ()),
+ reader_enabled=reader_enabled,
+ issue_reading=reading,
+ ),
+ )
+
+
@issue_router.get(
"/htmx/issues/{issue_id}/reading",
response_class=HTMLResponse,
diff --git a/src/pullbox/ui/series_routes.py b/src/pullbox/ui/series_routes.py
index cd6d9b07..f5c8469c 100644
--- a/src/pullbox/ui/series_routes.py
+++ b/src/pullbox/ui/series_routes.py
@@ -4,7 +4,7 @@
import time
from collections.abc import Awaitable, Callable, Mapping, Sequence
from datetime import date, timedelta
-from typing import Any
+from typing import Annotated, Any, Literal
from urllib.parse import urlencode
import structlog
@@ -12,29 +12,38 @@
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import ColumnElement, Float, String, case, cast, func, select
-from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import contains_eager
from starlette.responses import Response
-from pullbox.api.deps import AuthenticatedUser, DbSession, get_request_session_factory
+from pullbox.api.deps import AuthenticatedUser, DbSession
+from pullbox.config import get_settings
+from pullbox.core.metadata_identity import MetadataSource
from pullbox.models.issue import Issue, IssueStatus
from pullbox.models.library import LibraryFile, LibraryRoot
from pullbox.models.publisher import Publisher
from pullbox.models.series import IssueCatalogState, Series, SeriesStatus
+from pullbox.models.user import User
+from pullbox.schemas.metadata_sources import SeriesDiscoveryQuery, SourceCapability
from pullbox.services.cover_url_service import build_series_cover_url
from pullbox.services.library_root_management import list_library_roots
+from pullbox.services.metadata_discovery import describe_source_policies
+from pullbox.services.metadata_search_cache import MetadataSearchBusyError, MetadataSearchCache
+from pullbox.services.metadata_sources import load_source_runtime, read_source_policies
from pullbox.services.reading_query_service import load_series_reading_aggregates
-from pullbox.ui.comicvine_provider import open_comicvine_ui_provider
from pullbox.ui.comicvine_series_search import (
ADD_SERIES_PER_PAGE,
- COMICVINE_SERIES_SEARCH_LIMIT,
COMICVINE_SERIES_SORT_OPTIONS,
- format_comicvine_series_results,
- load_existing_series_by_cv_id,
normalize_comicvine_series_sort,
parse_comicvine_series_query,
sort_comicvine_series_results,
)
+from pullbox.ui.metadata_series_search import (
+ SOURCE_LABELS,
+ format_source_series_results,
+ search_snapshot,
+ source_messages,
+)
logger = structlog.get_logger(__name__)
@@ -524,8 +533,9 @@ async def add_series_page(
sort: str | None = Query("relevance"),
page: int = Query(1, ge=1),
search_mode: str | None = Query(None),
+ source: Annotated[MetadataSource | Literal["all"], Query()] = "all",
) -> Response:
- """Render the add series page with ComicVine search."""
+ """Render Add Series with shared, source-aware metadata discovery."""
roots = [
root
for root in await list_library_roots(session)
@@ -553,8 +563,12 @@ async def add_series_page(
sort,
page,
search_mode=search_mode,
- session_factory=get_request_session_factory(request),
+ source=source if isinstance(source, MetadataSource) else None,
+ cache=_request_search_cache(request),
)
+ if isinstance(user, User) and user in session:
+ # Search releases its read transaction before provider I/O.
+ await session.refresh(user)
template_context = _ctx(
request,
@@ -582,6 +596,16 @@ def normalize_add_series_sort(sort: str | None) -> str:
return normalize_comicvine_series_sort(sort)
+def _request_search_cache(request: Request) -> MetadataSearchCache:
+ state = getattr(getattr(request, "app", None), "state", None)
+ cache = getattr(state, "metadata_search_cache", None)
+ if not isinstance(cache, MetadataSearchCache):
+ cache = MetadataSearchCache()
+ if state is not None:
+ state.metadata_search_cache = cache
+ return cache
+
+
def sort_add_series_results(
results: list[Any],
sort: str,
@@ -599,11 +623,13 @@ async def load_add_series_search_context(
page: int = 1,
*,
search_mode: str | None = None,
- session_factory: async_sessionmaker[AsyncSession] | None = None,
+ source: MetadataSource | None = None,
+ cache: MetadataSearchCache | None = None,
) -> dict[str, object]:
- from pullbox.services.catalog.reader import get_catalog_reader
-
- local_catalog = get_catalog_reader().available
+ settings = get_settings()
+ descriptors = describe_source_policies(
+ await read_source_policies(session), gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
per_page = ADD_SERIES_PER_PAGE
normalized_query = (query or "").strip()
normalized_sort = normalize_add_series_sort(sort)
@@ -618,7 +644,16 @@ async def load_add_series_search_context(
) or 0
base_context: dict[str, object] = {
- "local_catalog": local_catalog,
+ "local_catalog": source is MetadataSource.COMICVINE_LOCAL,
+ "search_source": source.value if source else "all",
+ "search_source_label": SOURCE_LABELS[source] if source else "metadata sources",
+ "search_source_options": [("all", "All enabled sources")]
+ + [
+ (item.source.value, SOURCE_LABELS[item.source])
+ for item in descriptors
+ if SourceCapability.SERIES_SEARCH in item.capabilities
+ ],
+ "search_source_messages": [],
"search_query": normalized_query,
"add_series_sort": normalized_sort,
"is_preview_search": False,
@@ -642,15 +677,19 @@ async def load_add_series_search_context(
if preview_mode and not _is_add_series_preview_query_ready(parsed_query.title_query):
return base_context
- full_search_url = "/series/add?" + urlencode(
- {
- "q": normalized_query,
- "sort": normalized_sort,
- }
- )
+ search_params = {"q": normalized_query, "sort": normalized_sort}
+ if source is not None:
+ search_params["source"] = source.value
+ full_search_url = "/series/add?" + urlencode(search_params)
base_context["add_series_full_search_url"] = full_search_url
try:
+ runtime = await load_source_runtime(
+ session, gcd_api_enabled=settings.metadata_gcd_api_v2_enabled
+ )
+ base_context["search_source_revisions"] = {
+ item.policy.source.value: item.policy.revision for item in runtime
+ }
naming_config = await _system_config_values(
session,
(
@@ -662,49 +701,45 @@ async def load_add_series_search_context(
folder_template = naming_config.get("series_folder_template", "{Series} ({Year})")
replace_illegal = naming_config.get("replace_illegal_characters", "true") == "true"
colon_replacement = naming_config.get("colon_replacement", "dash")
- async with open_comicvine_ui_provider(
- session,
- session_factory=session_factory,
- prefer_catalog=True,
- ) as provider:
- if preview_mode:
- cv_results, _total_results = await provider.search_series_page(
- parsed_query.title_query,
- parsed_query.year_hint,
- limit=per_page,
- )
- else:
- cv_results, _total_results = await provider.search_series_globally(
- parsed_query.title_query,
- max_results=COMICVINE_SERIES_SEARCH_LIMIT,
- )
- searchable_total = len(cv_results)
+ await session.rollback()
+ snapshot = await search_snapshot(
+ SeriesDiscoveryQuery(
+ query=parsed_query.title_query,
+ year=parsed_query.year_hint,
+ sources=[source] if source else None,
+ search_mode="preview" if preview_mode else "full",
+ limit_per_source=per_page if preview_mode else 100,
+ ),
+ runtime,
+ cache or MetadataSearchCache(),
+ gcd_api_enabled=settings.metadata_gcd_api_v2_enabled,
+ )
+ base_context["search_source_messages"] = source_messages(snapshot)
+ searchable_total = len(snapshot.results)
total_pages = max(1, (searchable_total + per_page - 1) // per_page)
resolved_page = min(requested_page, total_pages)
sorted_results = sort_add_series_results(
- list(cv_results),
+ list(snapshot.results),
normalized_sort,
query=parsed_query.title_query,
year_hint=parsed_query.year_hint,
)
page_start = (resolved_page - 1) * per_page
visible_results = sorted_results[page_start : page_start + per_page]
- existing_series_by_cv_id = await load_existing_series_by_cv_id(session, visible_results)
- search_results = format_comicvine_series_results(
+ search_results = await format_source_series_results(
+ session,
visible_results,
- existing_series_by_cv_id=existing_series_by_cv_id,
folder_template=folder_template,
replace_illegal=replace_illegal,
colon_replacement=colon_replacement,
)
+ except MetadataSearchBusyError as exc:
+ base_context["search_error"] = str(exc)
+ return base_context
except Exception:
- logger.exception("comicvine_search_failed", query=normalized_query)
- base_context["search_error"] = (
- "Local catalog search failed. Check its status in Metadata settings."
- if local_catalog
- else "ComicVine search failed. Check your API key in settings."
- )
+ logger.warning("metadata_series_search_failed")
+ base_context["search_error"] = "Search could not finish. Retry or check Metadata settings."
return base_context
in_library_count = sum(1 for item in search_results if bool(item.get("already_added")))
@@ -719,7 +754,8 @@ async def load_add_series_search_context(
"search_in_library_count": in_library_count,
"search_page": resolved_page,
"search_total_pages": total_pages,
- "search_pagination_base_url": full_search_url,
+ "search_pagination_base_url": full_search_url
+ + ("&search_mode=preview" if preview_mode else ""),
}
)
return base_context
@@ -734,16 +770,20 @@ async def htmx_search_series(
sort: str | None = Query("relevance"),
page: int = Query(1, ge=1),
search_mode: str | None = Query(None),
+ source: Annotated[MetadataSource | Literal["all"], Query()] = "all",
) -> Response:
- """Search ComicVine and return results as an HTMX partial."""
+ """Search selected metadata sources and return an HTMX results bundle."""
add_series_search_ctx = await load_add_series_search_context(
session,
q,
sort,
page,
search_mode=search_mode,
- session_factory=get_request_session_factory(request),
+ source=source if isinstance(source, MetadataSource) else None,
+ cache=_request_search_cache(request),
)
+ if isinstance(user, User) and user in session:
+ await session.refresh(user)
return _templates().TemplateResponse(
request,
"partials/add_series_results_bundle.html",
diff --git a/src/pullbox/ui/settings_routes.py b/src/pullbox/ui/settings_routes.py
index 3204092c..445ee041 100644
--- a/src/pullbox/ui/settings_routes.py
+++ b/src/pullbox/ui/settings_routes.py
@@ -280,6 +280,15 @@ async def load_settings_tab(request: Request, session: DbSession, tab: str) -> d
active_key = await get_comicvine_api_key(session)
ctx["has_comicvine_key"] = bool(active_key)
ctx["obfuscated_key"] = obfuscate_api_key(active_key)
+ from pullbox.services.metadata_source_status import source_status
+
+ ctx["metadata_source_seed"] = [
+ policy.model_dump(mode="json")
+ for policy in await source_status(
+ session,
+ gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled,
+ )
+ ]
elif tab == "search":
result = await session.execute(select(SystemConfig).order_by(SystemConfig.key))
ctx["configs"] = {c.key: c.value for c in result.scalars().all()}
diff --git a/src/pullbox/ui/standalone_shell.py b/src/pullbox/ui/standalone_shell.py
index 411e0d81..80d9abf8 100644
--- a/src/pullbox/ui/standalone_shell.py
+++ b/src/pullbox/ui/standalone_shell.py
@@ -22,6 +22,8 @@
_STATIC_DIR / "js" / "story-arc-preview.js",
_STATIC_DIR / "js" / "story-arc-detail.js",
_STATIC_DIR / "js" / "series-rescan.js",
+ _STATIC_DIR / "js" / "metadata-sources.js",
+ _STATIC_DIR / "js" / "series-metadata-links.js",
)
diff --git a/src/pullbox/ui/static/css/input.css b/src/pullbox/ui/static/css/input.css
index f44ec675..77b014d8 100644
--- a/src/pullbox/ui/static/css/input.css
+++ b/src/pullbox/ui/static/css/input.css
@@ -1267,6 +1267,7 @@
color: var(--pb-text-secondary);
}
.add-series-modal-title {
+ overflow-wrap: anywhere;
font-family: "Syne", sans-serif;
font-size: 1rem;
font-weight: 800;
@@ -1299,6 +1300,7 @@
color: var(--pb-text-secondary);
}
.add-series-modal-preview {
+ overflow-wrap: anywhere;
padding: 0.625rem 0.75rem;
border-radius: 0.625rem;
border: 1px solid var(--pb-border-subtle);
diff --git a/src/pullbox/ui/static/css/tailwind.css b/src/pullbox/ui/static/css/tailwind.css
index b862fb67..d0acdce6 100644
--- a/src/pullbox/ui/static/css/tailwind.css
+++ b/src/pullbox/ui/static/css/tailwind.css
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
-@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.header-add-menu{flex-shrink:0;display:inline-flex;position:relative}.header-add-menu__primary{gap:calc(var(--spacing) * 2);white-space:nowrap;border-top-right-radius:0;border-bottom-right-radius:0;padding-right:.625rem}.header-add-menu__trigger{gap:calc(var(--spacing) * 2);white-space:nowrap;border-left:1px solid var(--pb-text-inverse);border-top-left-radius:0;border-bottom-left-radius:0;justify-content:space-between;min-width:7rem;padding-inline:.5rem}@supports (color:color-mix(in lab, red, red)){.header-add-menu__trigger{border-left:1px solid color-mix(in srgb, var(--pb-text-inverse) 26%, transparent)}}.header-add-menu__chevron{width:.875rem;height:.875rem;transition:transform .15s}.header-add-menu__panel{z-index:50;padding:calc(var(--spacing) * 1.5);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-width:17rem;box-shadow:var(--pb-shadow-2);border-radius:.75rem;position:absolute;top:calc(100% + .5rem);left:0}.header-add-menu__item{align-items:center;gap:calc(var(--spacing) * 2.5);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);min-height:2.5rem;color:var(--pb-text);border-radius:.5rem;transition:color .15s,background-color .15s;display:flex}.header-add-menu__item:hover,.header-add-menu__item:focus-visible,.header-add-menu__item--selected{color:var(--pb-interactive);background:var(--pb-interactive-dim)}.header-add-menu__item--disabled{color:var(--pb-text-ter);cursor:not-allowed;opacity:.72}.header-add-menu__item--disabled:hover{color:var(--pb-text-ter);background:0 0}.header-add-menu__item-label{flex:1;min-width:0}.header-add-menu__check{color:var(--pb-success)}.header-add-menu__soon{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;text-transform:uppercase;color:var(--pb-text-ter);flex-shrink:0}.header-add-menu__item svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}@media (prefers-reduced-motion:reduce){.header-add-menu__chevron,.header-add-menu__item{transition:none}}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.import-review-issue-choices-viewport{overscroll-behavior:contain;max-height:27.25rem;overflow:auto}.import-review-issue-choices-viewport .downloads-table th{z-index:1;position:sticky;top:0}.import-review-issue-choices-viewport .downloads-table td{padding-block:.25rem}.import-review-issue-choices-viewport .downloads-table tbody tr{height:2.5rem}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;justify-content:center;align-items:center;display:inline-flex;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-auto{inset:auto}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100dvh-6rem\)\]{max-height:calc(100dvh - 6rem)}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30\%\]{width:30%}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[34\%\]{width:34%}.w-\[35\%\]{width:35%}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[150px\]{width:150px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[560px\]{max-width:560px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[820px\]{min-width:820px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-\[360px\]{flex-basis:360px}.basis-\[420px\]{flex-basis:420px}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-full{border-radius:3.40282e38px!important}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/5{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/5{background-color:color-mix(in oklab, var(--pb-success) 5%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[22px\]{font-size:22px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-pretty{text-wrap:pretty}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-current{color:currentColor}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-pb-interactive{--tw-ring-color:var(--pb-interactive)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[border-color\,background-color\,box-shadow\]{transition-property:border-color,background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-pb-interactive:focus-visible{outline-color:var(--pb-interactive)}.focus-visible\:outline-white:focus-visible{outline-color:var(--color-white)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:text-pb-text-dim:disabled{color:var(--pb-text-dim)}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,3fr\)\]{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text-primary);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.import-review-row-menu-item{width:100%;color:var(--pb-text);text-align:left;cursor:pointer;background:0 0;border:0;border-radius:6px;padding:8px 12px;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .14s,color .14s;display:block}.import-review-row-menu-item:hover,.import-review-row-menu-item:focus-visible{background:var(--pb-interactive-dim);color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}
+@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.header-add-menu{flex-shrink:0;display:inline-flex;position:relative}.header-add-menu__primary{gap:calc(var(--spacing) * 2);white-space:nowrap;border-top-right-radius:0;border-bottom-right-radius:0;padding-right:.625rem}.header-add-menu__trigger{gap:calc(var(--spacing) * 2);white-space:nowrap;border-left:1px solid var(--pb-text-inverse);border-top-left-radius:0;border-bottom-left-radius:0;justify-content:space-between;min-width:7rem;padding-inline:.5rem}@supports (color:color-mix(in lab, red, red)){.header-add-menu__trigger{border-left:1px solid color-mix(in srgb, var(--pb-text-inverse) 26%, transparent)}}.header-add-menu__chevron{width:.875rem;height:.875rem;transition:transform .15s}.header-add-menu__panel{z-index:50;padding:calc(var(--spacing) * 1.5);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-width:17rem;box-shadow:var(--pb-shadow-2);border-radius:.75rem;position:absolute;top:calc(100% + .5rem);left:0}.header-add-menu__item{align-items:center;gap:calc(var(--spacing) * 2.5);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);min-height:2.5rem;color:var(--pb-text);border-radius:.5rem;transition:color .15s,background-color .15s;display:flex}.header-add-menu__item:hover,.header-add-menu__item:focus-visible,.header-add-menu__item--selected{color:var(--pb-interactive);background:var(--pb-interactive-dim)}.header-add-menu__item--disabled{color:var(--pb-text-ter);cursor:not-allowed;opacity:.72}.header-add-menu__item--disabled:hover{color:var(--pb-text-ter);background:0 0}.header-add-menu__item-label{flex:1;min-width:0}.header-add-menu__check{color:var(--pb-success)}.header-add-menu__soon{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;text-transform:uppercase;color:var(--pb-text-ter);flex-shrink:0}.header-add-menu__item svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}@media (prefers-reduced-motion:reduce){.header-add-menu__chevron,.header-add-menu__item{transition:none}}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{overflow-wrap:anywhere;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{overflow-wrap:anywhere;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.import-review-issue-choices-viewport{overscroll-behavior:contain;max-height:27.25rem;overflow:auto}.import-review-issue-choices-viewport .downloads-table th{z-index:1;position:sticky;top:0}.import-review-issue-choices-viewport .downloads-table td{padding-block:.25rem}.import-review-issue-choices-viewport .downloads-table tbody tr{height:2.5rem}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;justify-content:center;align-items:center;display:inline-flex;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-auto{inset:auto}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100dvh-5rem\)\]{max-height:calc(100dvh - 5rem)}.max-h-\[calc\(100dvh-6rem\)\]{max-height:calc(100dvh - 6rem)}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30\%\]{width:30%}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[34\%\]{width:34%}.w-\[35\%\]{width:35%}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[150px\]{width:150px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[560px\]{max-width:560px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[820px\]{min-width:820px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-\[360px\]{flex-basis:360px}.basis-\[420px\]{flex-basis:420px}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-full{border-radius:3.40282e38px!important}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/5{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/5{background-color:color-mix(in oklab, var(--pb-success) 5%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[22px\]{font-size:22px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-pretty{text-wrap:pretty}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-current{color:currentColor}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-pb-interactive{--tw-ring-color:var(--pb-interactive)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[border-color\,background-color\,box-shadow\]{transition-property:border-color,background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-dim:hover{background-color:var(--pb-interactive-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-pb-interactive:focus-visible{outline-color:var(--pb-interactive)}.focus-visible\:outline-white:focus-visible{outline-color:var(--color-white)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:text-pb-text-dim:disabled{color:var(--pb-text-dim)}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,3fr\)\]{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text-primary);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.import-review-row-menu-item{width:100%;color:var(--pb-text);text-align:left;cursor:pointer;background:0 0;border:0;border-radius:6px;padding:8px 12px;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .14s,color .14s;display:block}.import-review-row-menu-item:hover,.import-review-row-menu-item:focus-visible{background:var(--pb-interactive-dim);color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}
diff --git a/src/pullbox/ui/static/js/issue-file-metadata.js b/src/pullbox/ui/static/js/issue-file-metadata.js
new file mode 100644
index 00000000..4eb90929
--- /dev/null
+++ b/src/pullbox/ui/static/js/issue-file-metadata.js
@@ -0,0 +1,100 @@
+/* Preview and approve one existing managed CBZ; the server owns all paths. */
+function issueFileMetadata(issueId) {
+ var endpoint = "/api/v1/issues/" + issueId + "/file-metadata";
+ return {
+ open: false, busy: false, error: "", preview: null, job: null, choices: {}, choicesDirty: false,
+ timer: null, disposed: false, trigger: null, generation: 0, jobGeneration: 0,
+ init: function () { this.loadJob(); },
+ destroy: function () { this.disposed = true; this.generation += 1; clearTimeout(this.timer); },
+ request: async function (url, body) {
+ var response = await fetch(url, body === undefined ? { cache: "no-store" } : {
+ method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": readCsrfTokenFromBody() }, body: JSON.stringify(body),
+ });
+ var data = await response.json();
+ if (!response.ok) throw new Error(_extractApiErrorMessage(response, data, "Could not check file metadata. Try again."));
+ return data;
+ },
+ active: function () { return !!this.job && ["QUEUED", "RUNNING", "PAUSING", "CANCELLING"].includes(this.job.state); },
+ loadJob: async function () {
+ clearTimeout(this.timer);
+ var generation = this.jobGeneration;
+ var wasActive = this.active();
+ try {
+ var data = await this.request(endpoint + "/job");
+ if (this.disposed || generation !== this.jobGeneration) return;
+ this.job = data.job;
+ if (wasActive && this.job && this.job.state === "COMPLETED") {
+ window.dispatchEvent(new CustomEvent("issue-file-metadata-complete"));
+ var target = document.getElementById("issue-metadata-content");
+ if (target) await htmx.ajax("GET", "/htmx/issues/" + issueId + "/metadata", { target: target, swap: "morph:outerHTML" });
+ }
+ } catch (error) { if (!this.disposed) this.error = error.message; }
+ if (!this.disposed && this.active()) this.timer = setTimeout(() => this.loadJob(), 1000);
+ },
+ show: async function () {
+ this.trigger = document.activeElement;
+ this.open = true;
+ this.$nextTick(() => this.$refs.dialog.focus({ preventScroll: true }));
+ if (!this.job || ["FAILED", "CANCELLED"].includes(this.job.state)) await this.loadPreview();
+ },
+ close: function () {
+ this.open = false;
+ this.generation += 1;
+ if (this.trigger && this.trigger.isConnected) this.trigger.focus({ preventScroll: true });
+ },
+ choose: function (key, source) {
+ this.choices[key] = source;
+ this.choicesDirty = true;
+ },
+ canWrite: function () {
+ return !!this.preview && this.preview.ready !== false && !this.choicesDirty;
+ },
+ loadPreview: async function (reset = true) {
+ if (this.busy) return;
+ clearTimeout(this.timer);
+ this.jobGeneration += 1;
+ var generation = ++this.generation;
+ this.busy = true; this.error = "";
+ this.choicesDirty = true;
+ var choices = reset ? {} : Object.assign({}, this.choices);
+ try {
+ var data = await this.request(endpoint + "/preview", Object.keys(choices).length ? { choices: choices } : {});
+ if (this.disposed || generation !== this.generation) return;
+ this.preview = data; this.job = null;
+ this.choices = {};
+ (data.conflicts || []).forEach(conflict => { if (conflict.selected) this.choices[conflict.key] = conflict.selected; });
+ this.choicesDirty = false;
+ } catch (error) { if (generation === this.generation) this.error = error.message; }
+ finally { if (!this.disposed) this.busy = false; }
+ },
+ write: async function () {
+ if (this.busy || !this.canWrite() || this.active()) return;
+ this.busy = true; this.error = "";
+ try {
+ var approval = { review_key: this.preview.review_key };
+ if (Object.keys(this.choices).length) approval.choices = Object.assign({}, this.choices);
+ var result = await this.request(endpoint + "/write", approval);
+ if (this.disposed) return;
+ this.jobGeneration += 1;
+ this.job = { id: result.job_id, state: result.state, percent: 0, message: "Queued", error: null };
+ await this.loadJob();
+ } catch (error) { if (!this.disposed) this.error = error.message; }
+ finally { if (!this.disposed) this.busy = false; }
+ },
+ cancel: async function () {
+ if (this.busy || !this.active()) return;
+ this.busy = true;
+ try { await this.request("/api/v1/utilities/jobs/" + this.job.id + "/cancel", {}); await this.loadJob(); }
+ catch (error) { if (!this.disposed) this.error = error.message; }
+ finally { if (!this.disposed) this.busy = false; }
+ },
+ trapFocus: function (event) {
+ if (!this.open) return;
+ var elements = Array.from(this.$refs.dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), a[href], [tabindex="0"]')).filter(el => el.getClientRects().length > 0);
+ var first = elements[0], last = elements[elements.length - 1];
+ if (!first) { event.preventDefault(); return; }
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === this.$refs.dialog)) { event.preventDefault(); last.focus(); }
+ else if (!event.shiftKey && (document.activeElement === last || document.activeElement === this.$refs.dialog)) { event.preventDefault(); first.focus(); }
+ },
+ };
+}
diff --git a/src/pullbox/ui/static/js/metadata-sources.js b/src/pullbox/ui/static/js/metadata-sources.js
new file mode 100644
index 00000000..9fc60a44
--- /dev/null
+++ b/src/pullbox/ui/static/js/metadata-sources.js
@@ -0,0 +1,386 @@
+function gcdLocalSettings(seed, csrf) {
+ return {
+ gcdBase: seed, gcdPath: seed?.settings?.database_path || '', gcdBusy: false,
+ gcdError: '', gcdMessage: '', gcdController: null, gcdAlive: true,
+ destroy() { this.gcdAlive = false; this.gcdController?.abort(); },
+ cancelGcd() { this.gcdController?.abort(); },
+ prioritiesUpdated(detail) {
+ const policy = detail?.policies?.find(item => item.source === 'gcd_local');
+ if (policy && this.gcdBase?.revision === detail.previousRevisions?.gcd_local) {
+ this.gcdBase = JSON.parse(JSON.stringify(policy));
+ }
+ },
+ async reloadGcd() {
+ if (this.gcdBusy) return;
+ this.gcdBusy = true;
+ try {
+ const response = await fetch('/api/v1/metadata/sources');
+ if (!response.ok) throw new Error('load');
+ const data = await response.json();
+ if (!this.gcdAlive) return;
+ this.gcdBase = data.find(item => item.source === 'gcd_local');
+ this.gcdPath = this.gcdBase?.settings?.database_path || '';
+ this.gcdError = ''; this.gcdMessage = '';
+ } catch (_) {
+ if (this.gcdAlive) this.gcdError = 'Could not load GCD settings. Your draft is kept; retry.';
+ } finally { this.gcdBusy = false; }
+ },
+ async saveGcd(enabled) {
+ if (this.gcdBusy) return;
+ const base = this.gcdBase;
+ this.gcdBusy = true; this.gcdError = ''; this.gcdMessage = '';
+ this.gcdController = new AbortController();
+ const timeout = setTimeout(() => this.gcdController?.abort(), 310000);
+ try {
+ const response = await fetch('/api/v1/metadata/sources/gcd_local', {
+ method: 'PUT', signal: this.gcdController.signal,
+ headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
+ body: JSON.stringify({revision: base.revision, enabled, priority: base.priority,
+ domain_priorities: base.domain_priorities,
+ settings: enabled ? {database_path: this.gcdPath.trim()} : base.settings}),
+ });
+ const data = await response.json();
+ if (!this.gcdAlive) return;
+ if (!response.ok) {
+ this.gcdError = response.status === 409
+ ? 'GCD settings changed in another session. Load saved GCD settings before retrying.'
+ : typeof data.detail === 'string' ? data.detail : 'GCD validation failed. Check the dump and retry.';
+ return;
+ }
+ this.gcdBase = data;
+ this.gcdPath = data.settings.database_path || '';
+ this.gcdMessage = enabled ? 'GCD is enabled. You can search, preview and add series.' : 'GCD is disabled. The database and its saved path are unchanged.';
+ window.dispatchEvent(new CustomEvent('metadata-credentials-updated', {
+ detail: {source: 'gcd_local', previousRevision: base.revision, policy: data},
+ }));
+ } catch (_) {
+ if (this.gcdAlive) this.gcdError = 'Validation stopped or the connection was lost. Load saved GCD settings to confirm the current source before retrying.';
+ } finally {
+ clearTimeout(timeout); this.gcdController = null; this.gcdBusy = false;
+ }
+ },
+ };
+}
+
+function metadataSourceSettings(seed, csrf) {
+ const clone = value => JSON.parse(JSON.stringify(value));
+ const labels = {
+ comicvine_local: 'ComicVine Local Catalog', comicvine_api: 'ComicVine API',
+ metron_api: 'Metron', gcd_local: 'GCD Local Database', gcd_api_v2: 'GCD API v2',
+ };
+ return {
+ sources: clone(seed), savedSources: clone(seed), order: [], domainOrders: {},
+ domains: {core: 'Core metadata', issues: 'Issue catalogs', artwork: 'Artwork', story_arcs: 'Story arcs'},
+ labels, saving: false, testing: null, message: '', error: '', conflict: false,
+ healthMessage: '', controllers: new Set(), alive: true, savedOrder: '', refreshing: false,
+ metronBase: null, metronEnabled: false, metronToken: '', metronClear: false,
+ metronSaving: false, metronError: '', metronMessage: '', metronConflict: false,
+ healthRefreshPending: false,
+ retryOpen: false, retrySource: null, retryPage: {items: [], total: 0, offset: 0, limit: 10, has_more: false},
+ retryLoading: false, retryError: '', retryTrigger: null,
+ init() { this.accept(seed); this.acceptMetron(seed.find(item => item.source === 'metron_api')); },
+ destroy() {
+ this.alive = false; this.metronToken = '';
+ this.controllers.forEach(controller => controller.abort());
+ },
+ get busy() { return this.saving || this.testing || this.refreshing || this.metronSaving; },
+ get metronDirty() {
+ return Boolean(this.metronBase && (this.metronEnabled !== this.metronBase.enabled ||
+ this.metronToken || this.metronClear));
+ },
+ acceptMetron(item) {
+ this.metronBase = item ? clone(item) : null;
+ this.metronEnabled = Boolean(item?.enabled);
+ this.metronToken = ''; this.metronClear = false;
+ this.metronError = ''; this.metronMessage = ''; this.metronConflict = false;
+ },
+ async saveMetron() {
+ if (this.busy || !this.metronDirty) return;
+ this.metronError = ''; this.metronMessage = '';
+ if (this.metronEnabled && this.metronClear) {
+ this.metronError = 'Disable Metron before removing its saved token.';
+ return;
+ }
+ if (this.metronEnabled && !this.metronToken && !this.metronBase.credential_configured) {
+ this.metronError = 'Enter a token before enabling Metron.';
+ return;
+ }
+ if (this.metronToken && (!/^[\x21-\x7e]+$/.test(this.metronToken) ||
+ this.metronToken.length > 4096 || this.metronToken.startsWith('enc:'))) {
+ this.metronError = 'Enter the API token from your Metron account, without spaces or line breaks.';
+ return;
+ }
+ this.metronSaving = true;
+ const base = clone(this.metronBase);
+ try {
+ const response = await this.request('/api/v1/metadata/sources/metron_api', {
+ method: 'PUT', body: JSON.stringify({
+ revision: base.revision, enabled: this.metronEnabled,
+ priority: base.priority, domain_priorities: base.domain_priorities, settings: base.settings,
+ ...(this.metronToken && !this.metronClear ? {credential: this.metronToken} : {}),
+ clear_credential: this.metronClear,
+ }),
+ });
+ if (!this.alive) return;
+ if (response.status === 409) {
+ this.metronConflict = true;
+ this.metronError = 'Metron settings changed in another session. Your draft is kept. Load saved Metron settings before trying again.';
+ return;
+ }
+ if (!response.ok) throw new Error('save');
+ const policy = response.data;
+ const current = this.sources.find(item => item.source === 'metron_api');
+ const descriptor = {...current, ...policy, availability: policy.configuration_status ||
+ (!policy.enabled ? 'disabled' : !policy.credential_configured ? 'unconfigured' : null)};
+ this.sources = this.sources.map(item => item.source === 'metron_api' ? descriptor : item);
+ // Only advance the priority revision when that draft saw the same policy.
+ this.savedSources = this.savedSources.map(item =>
+ item.source === 'metron_api' && item.revision === base.revision ? clone(descriptor) : item);
+ this.acceptMetron(policy);
+ this.healthRefreshPending = true;
+ this.healthMessage = '';
+ this.metronMessage = 'Metron settings saved. Connection checks use this saved configuration.';
+ } catch (_) {
+ if (this.alive) this.metronError = 'Could not save Metron settings. Your draft is kept; check the connection and retry.';
+ } finally { this.metronSaving = false; this.flushHealthRefresh(); }
+ },
+ async reloadMetron() {
+ if (this.busy) return;
+ this.metronSaving = true;
+ try {
+ const response = await this.request('/api/v1/metadata/sources');
+ if (!response.ok) throw new Error('load');
+ if (!this.alive) return;
+ this.sources = response.data;
+ this.acceptMetron(response.data.find(item => item.source === 'metron_api'));
+ } catch (_) {
+ if (this.alive) this.metronError = 'Could not load Metron settings. Your draft is kept; check the connection and retry.';
+ } finally { this.metronSaving = false; this.flushHealthRefresh(); }
+ },
+ flushHealthRefresh() {
+ if (this.alive && this.healthRefreshPending && !this.busy) {
+ this.healthRefreshPending = false;
+ this.refreshHealth();
+ }
+ },
+ sourceUpdated(detail) {
+ if (detail?.policy && detail.policy.source === detail.source) {
+ const current = this.sources.find(item => item.source === detail.source);
+ const descriptor = {...current, ...detail.policy};
+ // A same-page save may advance its own baseline, not an unseen external edit.
+ this.savedSources = this.savedSources.map(item =>
+ item.source === detail.source && item.revision === detail.previousRevision
+ ? clone(descriptor) : item);
+ }
+ this.refreshHealth();
+ },
+ eligible(domain) {
+ return this.order.filter(source => domain !== 'artwork' || !source.startsWith('gcd_'));
+ },
+ accept(data) {
+ this.sources = clone(data);
+ this.savedSources = clone(data);
+ this.order = data.map(item => item.source);
+ this.domainOrders = {};
+ for (const domain of Object.keys(this.domains)) {
+ if (data.some(item => Object.hasOwn(item.domain_priorities, domain))) {
+ this.domainOrders[domain] = this.eligible(domain).sort((a, b) => {
+ const left = data.find(item => item.source === a);
+ const right = data.find(item => item.source === b);
+ return (left.domain_priorities[domain] ?? left.priority) -
+ (right.domain_priorities[domain] ?? right.priority) || a.localeCompare(b);
+ });
+ }
+ }
+ this.savedOrder = this.signature();
+ },
+ signature() {
+ return JSON.stringify({order: this.order, domain_orders: Object.fromEntries(
+ Object.keys(this.domains).filter(domain => this.domainOrders[domain]).map(domain => [domain, this.domainOrders[domain]])
+ )});
+ },
+ get dirty() {
+ return this.signature() !== this.savedOrder;
+ },
+ move(domain, index, direction) {
+ if (this.busy) return;
+ const order = domain === 'global' ? this.order : this.domainOrders[domain];
+ const target = index + direction;
+ if (!order || target < 0 || target >= order.length) return;
+ const focused = document.activeElement;
+ const row = focused?.closest('[data-source-row]');
+ [order[index], order[target]] = [order[target], order[index]];
+ this.message = '';
+ if (row && this.$root.contains(row)) {
+ this.$nextTick(() => {
+ const button = focused.disabled ? row.querySelector('button:not(:disabled)') : focused;
+ button?.focus({preventScroll: true});
+ });
+ }
+ },
+ toggleDomain(domain, enabled) {
+ if (this.busy) return;
+ if (enabled) this.domainOrders[domain] = this.eligible(domain);
+ else delete this.domainOrders[domain];
+ this.message = '';
+ },
+ reset() {
+ if (this.busy) return;
+ this.accept(this.savedSources);
+ this.message = ''; this.error = ''; this.conflict = false;
+ },
+ async request(url, options = {}) {
+ const controller = new AbortController();
+ this.controllers.add(controller);
+ const timeout = setTimeout(() => controller.abort(), 20000);
+ try {
+ const response = await fetch(url, {
+ ...options, signal: controller.signal,
+ headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
+ });
+ const data = await response.json();
+ return {ok: response.ok, status: response.status, data};
+ } finally {
+ clearTimeout(timeout);
+ this.controllers.delete(controller);
+ }
+ },
+ async save() {
+ if (this.busy || !this.dirty) return;
+ this.saving = true; this.error = ''; this.message = ''; this.conflict = false;
+ const revisions = Object.fromEntries(this.savedSources.map(item => [item.source, item.revision]));
+ try {
+ const response = await this.request('/api/v1/metadata/priorities', {
+ method: 'PUT', body: JSON.stringify({
+ order: this.order, domain_orders: this.domainOrders,
+ revisions,
+ }),
+ });
+ if (response.status === 409) {
+ this.conflict = true;
+ this.error = 'Settings changed in another session. Your draft is kept. Load saved priority before trying again.';
+ return;
+ }
+ if (!response.ok) throw new Error('save');
+ const data = response.data;
+ if (!this.alive) return;
+ const previous = this.savedSources.find(item => item.source === 'metron_api');
+ if (this.metronBase?.revision === previous?.revision) {
+ this.metronBase = clone(data.find(item => item.source === 'metron_api'));
+ }
+ this.accept(data);
+ window.dispatchEvent(new CustomEvent('metadata-priorities-updated', {
+ detail: {previousRevisions: revisions, policies: data},
+ }));
+ this.message = 'Metadata priority saved.';
+ } catch (_) {
+ if (this.alive) this.error = 'Could not save metadata priority. Your draft is kept; check the connection and retry.';
+ } finally { this.saving = false; this.flushHealthRefresh(); }
+ },
+ async reload() {
+ if (this.busy) return;
+ this.saving = true;
+ try {
+ const response = await this.request('/api/v1/metadata/sources');
+ if (!response.ok) throw new Error('load');
+ const data = response.data;
+ if (!this.alive) return;
+ this.accept(data); this.error = ''; this.conflict = false; this.message = '';
+ } catch (_) {
+ if (this.alive) this.error = 'Could not load saved priority. Your draft is kept; check the connection and retry.';
+ } finally { this.saving = false; this.flushHealthRefresh(); }
+ },
+ status(item) {
+ if (!item.capabilities.length && item.availability !== 'feature_disabled') return 'Not available in this build';
+ const state = item.availability || item.account?.status || item.last_status || 'not_checked';
+ return {
+ ok: 'Ready', empty: 'Ready', disabled: 'Disabled', feature_disabled: 'Not released yet',
+ not_implemented: 'Not available in this build', unconfigured: 'Not configured',
+ invalid_configuration: 'Configuration needs attention', authentication_failed: 'Authentication required',
+ rate_limited: 'Rate limited', timeout: 'Timed out', unavailable: 'Unavailable',
+ incompatible_response: 'Unexpected provider response', unsupported: 'Not supported',
+ not_checked: 'Not checked yet',
+ }[state] || 'Needs attention';
+ },
+ dateLabel(value) { return value ? new Date(value).toLocaleString() : ''; },
+ holdMessage(item) {
+ if (item.availability) return '';
+ const account = item.account;
+ if (account?.probe_until && new Date(account.probe_until) > new Date()) return 'A connection check is in progress.';
+ if (account?.status === 'authentication_failed') return 'Automatic requests are paused. Check saved access, then test the connection to resume waiting work.';
+ if (account?.retry_at && new Date(account.retry_at) > new Date()) return 'Requests can resume after ' + this.dateLabel(account.retry_at) + '. Other sources can still run.';
+ return '';
+ },
+ retryState(row) {
+ return {
+ ready: 'Ready for the next scheduled run',
+ waiting: 'Waiting until ' + this.dateLabel(row.retry_at),
+ authentication_required: 'Check saved access, then test the connection',
+ source_disabled: 'Enable and configure this source to resume',
+ }[row.state] || 'Needs attention';
+ },
+ async showRetries(source, trigger) {
+ if (this.retryLoading) return;
+ this.retrySource = source; this.retryTrigger = trigger; this.retryOpen = true;
+ this.retryPage = {items: [], total: 0, offset: 0, limit: 10, has_more: false};
+ await this.loadRetries(0);
+ },
+ closeRetries() {
+ if (this.retryLoading) return;
+ this.retryOpen = false;
+ this.$nextTick(() => this.retryTrigger?.focus({preventScroll: true}));
+ },
+ async loadRetries(offset) {
+ if (this.retryLoading) return;
+ this.retryLoading = true; this.retryError = '';
+ const query = new URLSearchParams({limit: '10', offset: String(offset)});
+ if (this.retrySource) query.set('source', this.retrySource);
+ try {
+ const response = await this.request('/api/v1/metadata/retries?' + query);
+ if (!response.ok) throw new Error('load');
+ if (this.alive) this.retryPage = response.data;
+ } catch (_) {
+ if (this.alive) this.retryError = 'Could not load deferred work. Check the connection and retry.';
+ } finally { this.retryLoading = false; }
+ },
+ canTest(item) { return item.enabled && item.capabilities.length > 0 && !item.availability; },
+ async refreshHealth() {
+ if (this.busy) { this.healthRefreshPending = true; return; }
+ this.refreshing = true;
+ try {
+ const response = await this.request('/api/v1/metadata/sources');
+ if (!response.ok) throw new Error('load');
+ if (this.alive) this.sources = response.data;
+ } catch (_) {
+ if (this.alive) this.healthMessage = 'Could not refresh source status. Reload Metadata settings to check the saved configuration.';
+ } finally { this.refreshing = false; this.flushHealthRefresh(); }
+ },
+ async test(item) {
+ if (this.busy || !this.canTest(item)) return;
+ this.testing = item.source;
+ this.healthMessage = 'Checking ' + this.labels[item.source] + '...';
+ try {
+ const response = await this.request('/api/v1/metadata/sources/' + item.source + '/test', {method: 'POST'});
+ if (!response.ok) throw new Error('test');
+ const data = response.data;
+ if (!this.alive) return;
+ if (!data.recorded) {
+ this.healthMessage = 'Settings changed during this check. Test the saved configuration again.';
+ return;
+ }
+ item.last_status = data.outcome.status;
+ this.healthMessage = this.labels[item.source] + ': ' + this.status(item) + '.';
+ if (data.outcome.retry_after_seconds != null) {
+ this.healthMessage += ' Retry in ' + data.outcome.retry_after_seconds + ' seconds.';
+ }
+ } catch (_) {
+ if (this.alive) this.healthMessage = 'Could not check ' + this.labels[item.source] + '. Check the connection and retry.';
+ } finally {
+ this.testing = null;
+ await this.refreshHealth();
+ if (this.retryOpen && !this.retryLoading) await this.loadRetries(this.retryPage.offset);
+ this.flushHealthRefresh();
+ }
+ },
+ };
+}
diff --git a/src/pullbox/ui/static/js/pullbox.js b/src/pullbox/ui/static/js/pullbox.js
index a48c7453..dab9a363 100644
--- a/src/pullbox/ui/static/js/pullbox.js
+++ b/src/pullbox/ui/static/js/pullbox.js
@@ -19959,19 +19959,26 @@ function seriesDetailPage(config) {
var self = this;
if (self.refreshing) return;
self.refreshing = true;
+ var failureMessage = "Failed to refresh metadata";
fetch(cfg.refreshUrl, {
method: "POST",
headers: { "X-CSRF-Token": self.csrfToken() },
})
- .then(function (response) {
- if (!response.ok) throw new Error("Failed to refresh metadata");
+ .then(async function (response) {
+ if (!response.ok) {
+ var data = await _readJsonBody(response);
+ if (response.status === 409) {
+ failureMessage = _extractApiErrorMessage(response, data, failureMessage);
+ }
+ throw new Error(failureMessage);
+ }
self.dispatchToast("Metadata refreshed", "success");
setTimeout(function () {
window.location.assign(self.currentDetailUrl());
}, 500);
})
.catch(function () {
- self.dispatchToast("Failed to refresh metadata", "error");
+ self.dispatchToast(failureMessage, "error");
self.refreshing = false;
});
},
diff --git a/src/pullbox/ui/static/js/series-metadata-links.js b/src/pullbox/ui/static/js/series-metadata-links.js
new file mode 100644
index 00000000..d83a0156
--- /dev/null
+++ b/src/pullbox/ui/static/js/series-metadata-links.js
@@ -0,0 +1,215 @@
+/* Explicit provider linking; searches and previews never add a library series. */
+function seriesMetadataLinks(seriesId) {
+ return metadataEntityLinks("series", seriesId);
+}
+
+function issueMetadataLinks(issueId) {
+ return metadataEntityLinks("issues", issueId);
+}
+
+function metadataEntityLinks(kind, localId) {
+ var issueMode = kind === "issues";
+ var endpoint = "/api/v1/" + kind + "/" + localId + "/metadata-links";
+ return {
+ open: false, loading: true, busy: false, error: "", message: "", trigger: null,
+ current: {}, identities: [], sources: [], source: "", query: "",
+ results: [], candidate: null, searched: false, offset: 0, nextOffset: null,
+ generation: 0, disposed: false, linking: false, refreshing: false,
+ origins: [], canRefresh: false, syncing: false, providerRows: [], providerPage: 0, providerStart: 0, pageStarts: {}, nextProviderPage: null,
+ init: function () { this.load(); },
+ destroy: function () { this.disposed = true; this.generation += 1; },
+ request: async function (url, body) {
+ var response = await fetch(url, body === undefined ? { cache: "no-store" } : {
+ method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": readCsrfTokenFromBody() },
+ body: JSON.stringify(body),
+ });
+ var data = await response.json();
+ if (!response.ok) throw new Error(_extractApiErrorMessage(response, data, "Could not load provider metadata. Try again."));
+ return data;
+ },
+ load: async function () {
+ try {
+ var data = await this.request(endpoint);
+ if (this.disposed) return;
+ this.current = data.current;
+ this.identities = data.identities;
+ this.sources = data.sources;
+ this.origins = data.origins || [];
+ this.canRefresh = !!data.can_refresh;
+ this.syncing = !!data.syncing;
+ this.source = this.sources.length ? this.sources[0].source : "";
+ this.query = this.current.title;
+ this.error = "";
+ } catch (error) { this.error = error.message; }
+ finally { this.loading = false; }
+ },
+ label: function (namespace) { return { comicvine: "ComicVine", metron: "Metron", gcd: "GCD", locg: "LOCG" }[namespace] || namespace; },
+ sourceOptions: function () { return this.sources.map(item => ({ value: item.source, label: item.label })); },
+ describe: function (series) {
+ if (issueMode) return [series.cover_date || "Cover date unknown", series.page_count == null ? "Page count unknown" : series.page_count + " pages"].join(" · ");
+ return [series.year_start || "Year unknown", series.publisher || "Publisher unknown", series.issue_count === null ? "Issue count unknown" : series.issue_count + " issues"].join(" · ");
+ },
+ openLink: function () {
+ this.trigger = document.activeElement;
+ this.open = true;
+ this.error = "";
+ this.$nextTick(() => requestAnimationFrame(() => {
+ if (this.open && !this.disposed) (this.sources.length && !this.candidate && !issueMode ? this.$refs.linkQuery : this.$refs.linkDialog).focus({ preventScroll: true });
+ }));
+ },
+ closeLink: function () {
+ this.open = false;
+ this.generation += 1;
+ if (!this.linking) this.busy = false;
+ if (this.trigger && this.trigger.isConnected) this.trigger.focus({ preventScroll: true });
+ },
+ resetSearch: function () {
+ this.generation += 1;
+ this.busy = false;
+ this.results = [];
+ this.candidate = null;
+ this.searched = false;
+ this.nextOffset = null;
+ this.offset = 0;
+ this.providerRows = [];
+ this.providerPage = 0;
+ this.providerStart = 0;
+ this.pageStarts = {};
+ this.nextProviderPage = null;
+ this.error = "";
+ },
+ search: async function (offset) {
+ if (issueMode) return this.browseIssues(offset || 0);
+ if (this.busy || !this.source || !this.query.trim()) return;
+ var generation = ++this.generation;
+ this.busy = true;
+ this.error = "";
+ try {
+ var data = await this.request("/api/v1/metadata/search", {
+ query: this.query.trim(), sources: [this.source], limit_per_source: 10,
+ offsets: { [this.source]: offset || 0 }, search_mode: "full",
+ });
+ if (this.disposed || generation !== this.generation) return;
+ var outcome = data.sources.find(item => item.source === this.source);
+ if (!outcome || (outcome.status !== "ok" && outcome.status !== "empty")) throw new Error("This provider is unavailable. Check Metadata settings or try again later.");
+ this.results = data.results;
+ this.offset = offset || 0;
+ this.nextOffset = outcome.next_offset;
+ this.candidate = null;
+ this.searched = true;
+ } catch (error) { if (generation === this.generation) this.error = error.message; }
+ finally { if (generation === this.generation) this.busy = false; }
+ },
+ browseIssues: async function (offset) {
+ if (this.busy || !this.source) return;
+ var generation = ++this.generation;
+ this.busy = true;
+ this.error = "";
+ try {
+ var providerPage = this.providerPage || 1;
+ var providerStart = this.providerStart;
+ if (offset === 0) { providerPage = 1; providerStart = 0; }
+ else if (offset < this.providerStart) {
+ providerPage -= 1;
+ providerStart = this.pageStarts[providerPage];
+ } else if (offset >= this.providerStart + this.providerRows.length) {
+ providerPage = this.nextProviderPage;
+ providerStart = offset;
+ }
+ if (this.providerPage !== providerPage) {
+ var data = await this.request(endpoint + "/candidates", { source: this.source, page: providerPage });
+ if (this.disposed || generation !== this.generation) return;
+ if (data.status !== "ok" && data.status !== "empty") throw new Error("This provider could not load the issue list. Check Metadata settings or try again later.");
+ this.providerRows = data.data ? data.data.results : [];
+ this.nextProviderPage = data.data ? data.data.next_page : null;
+ this.providerPage = providerPage;
+ this.providerStart = providerStart;
+ this.pageStarts[providerPage] = providerStart;
+ }
+ var start = offset - this.providerStart;
+ this.results = this.providerRows.slice(start, start + 10);
+ this.offset = offset;
+ this.nextOffset = start + 10 < this.providerRows.length ? offset + 10 : (this.nextProviderPage ? this.providerStart + this.providerRows.length : null);
+ this.searched = true;
+ this.candidate = null;
+ } catch (error) { if (generation === this.generation) this.error = error.message; }
+ finally { if (generation === this.generation) this.busy = false; }
+ },
+ previousIssueOffset: function () {
+ if (this.offset > this.providerStart) return Math.max(this.providerStart, this.offset - 10);
+ var previousStart = this.pageStarts[this.providerPage - 1] || 0;
+ return previousStart + Math.floor((this.providerStart - previousStart - 1) / 10) * 10;
+ },
+ preview: async function (result) {
+ if (this.busy) return;
+ var generation = ++this.generation;
+ this.busy = true;
+ this.error = "";
+ try {
+ var data = await this.request(endpoint + "/preview", { source: result.source, external_id: result.external_id });
+ if (this.disposed || generation !== this.generation) return;
+ this.candidate = data;
+ this.$nextTick(() => requestAnimationFrame(() => {
+ if (this.open && this.candidate) this.$refs.reviewHeading.focus({ preventScroll: true });
+ }));
+ } catch (error) { if (generation === this.generation) this.error = error.message; }
+ finally { if (generation === this.generation) this.busy = false; }
+ },
+ conflict: function () {
+ if (!this.candidate) return "";
+ var review = this.candidate.review;
+ if (review.owner_local_id && review.owner_local_id !== localId) return "This provider record is already linked to another library " + (issueMode ? "issue" : "series") + ". No records will be merged.";
+ if (review.current_external_id && review.current_external_id !== review.external_id) return "This " + (issueMode ? "issue" : "series") + " already has a different match for this provider. No match will be replaced.";
+ return "";
+ },
+ confirm: async function () {
+ if (this.busy || !this.candidate || this.conflict()) return;
+ this.busy = true;
+ this.error = "";
+ var candidate = this.candidate;
+ this.linking = true;
+ try {
+ await this.request(endpoint + "/confirm", {
+ event_id: candidate.review.event_id, fingerprint: candidate.review.fingerprint,
+ review_revision: candidate.review.review_revision, source_revision: candidate.source_revision,
+ });
+ if (this.disposed) return;
+ await this.load();
+ this.candidate = null;
+ this.results = [];
+ this.searched = false;
+ this.message = "Provider linked. Use Refresh metadata to fill metadata gaps. Files and issue " + (issueMode ? "numbers" : "matches") + " are unchanged.";
+ this.closeLink();
+ } catch (error) { if (!this.disposed) this.error = error.message; }
+ finally { this.busy = false; this.linking = false; }
+ },
+ refreshMetadata: async function () {
+ if (this.refreshing || !this.canRefresh) return;
+ this.refreshing = true;
+ this.message = "";
+ this.error = "";
+ try {
+ var result = await this.request("/api/v1/issues/" + localId + "/refresh-metadata", {});
+ if (this.disposed) return;
+ var target = document.getElementById("issue-metadata-content");
+ if (target) await htmx.ajax("GET", "/htmx/issues/" + localId + "/metadata", { target: target, swap: "morph:outerHTML" });
+ if (this.disposed) return;
+ await this.load();
+ this.message = result.outcomes.length ? "Metadata refreshed with available providers. Some linked providers could not respond; retry later to fill remaining gaps." : "Issue metadata refreshed. Your edits, reading progress and files were preserved.";
+ } catch (error) { if (!this.disposed) this.error = error.message; }
+ finally { if (!this.disposed) this.refreshing = false; }
+ },
+ originLabel: function (origin) {
+ return origin.user_override ? "Your edit" : ({ comicvine_local: "ComicVine Local", comicvine_api: "ComicVine API", metron_api: "Metron", gcd_local: "GCD Local", gcd_api_v2: "GCD API v2" }[origin.source] || "Existing library metadata");
+ },
+ trapFocus: function (event) {
+ if (!this.open) return;
+ var elements = Array.from(this.$refs.linkDialog.querySelectorAll('button:not([disabled]), input:not([disabled]), a[href], [tabindex="0"]')).filter(el => el.getClientRects().length > 0);
+ var first = elements[0], last = elements[elements.length - 1];
+ if (!first) { event.preventDefault(); return; }
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === this.$refs.reviewHeading || document.activeElement === this.$refs.linkDialog)) {
+ event.preventDefault(); last.focus();
+ } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === this.$refs.linkDialog)) { event.preventDefault(); first.focus(); }
+ },
+ };
+}
diff --git a/src/pullbox/ui/static/js/series-sidecar.js b/src/pullbox/ui/static/js/series-sidecar.js
new file mode 100644
index 00000000..8de14897
--- /dev/null
+++ b/src/pullbox/ui/static/js/series-sidecar.js
@@ -0,0 +1,69 @@
+/* Explicit series.json output. All values and target locations come from the server. */
+function seriesSidecar(seriesId) {
+ const endpoint = "/api/v1/series/" + seriesId + "/sidecar";
+ return {
+ open: false, busy: false, preview: null, result: null, error: "", trigger: null,
+ generation: 0, disposed: false,
+ destroy() { this.disposed = true; this.generation += 1; },
+ async request(action, body) {
+ const response = await fetch(endpoint + "/" + action, {
+ method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": readCsrfTokenFromBody() },
+ body: JSON.stringify(body),
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(_extractApiErrorMessage(response, data, "Could not prepare series metadata. Try again."));
+ return data;
+ },
+ async show() {
+ this.trigger = document.activeElement;
+ this.open = true;
+ this.$nextTick(() => this.$refs.dialog.focus({ preventScroll: true }));
+ if (!this.busy) await this.loadPreview();
+ },
+ close() {
+ this.open = false;
+ if (this.trigger && this.trigger.isConnected) this.trigger.focus({ preventScroll: true });
+ },
+ async loadPreview() {
+ if (this.busy) return;
+ const generation = ++this.generation;
+ this.busy = true; this.error = "";
+ try {
+ const data = await this.request("preview", {});
+ if (this.disposed || generation !== this.generation) return;
+ this.preview = data; this.result = null;
+ } catch (error) {
+ if (!this.disposed && generation === this.generation) this.error = error.message;
+ } finally { if (!this.disposed) this.busy = false; }
+ },
+ async write() {
+ if (this.busy || !this.preview || !this.preview.ready || this.result) return;
+ this.busy = true; this.error = "";
+ try {
+ const data = await this.request("write", { review_key: this.preview.review_key });
+ if (!this.disposed) this.result = data;
+ } catch (error) {
+ if (!this.disposed) { this.error = error.message; this.preview.ready = false; }
+ } finally { if (!this.disposed) this.busy = false; }
+ },
+ targets() { return this.result ? this.result.targets : this.preview ? this.preview.targets : []; },
+ actionLabel(action) {
+ return { create: "Create series.json", update: "Update series.json", unchanged: "Up to date", blocked: "Left unchanged" }[action] || action;
+ },
+ summary() {
+ if (!this.result) return "";
+ const written = this.result.written;
+ const blocked = this.result.targets.filter(target => target.action === "blocked").length;
+ return "Wrote " + written + " series.json file" + (written === 1 ? "." : "s.") +
+ (blocked ? " " + blocked + " folder" + (blocked === 1 ? " was" : "s were") + " left unchanged; review the reasons below." : "");
+ },
+ trapFocus(event) {
+ if (!this.open) return;
+ const elements = Array.from(this.$refs.dialog.querySelectorAll('button:not([disabled]), a[href], summary, [tabindex="0"]')).filter(el => el.getClientRects().length > 0);
+ const first = elements[0], last = elements[elements.length - 1];
+ if (!first) { event.preventDefault(); return; }
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === this.$refs.dialog)) { event.preventDefault(); last.focus(); }
+ else if (!event.shiftKey && (document.activeElement === last || document.activeElement === this.$refs.dialog)) { event.preventDefault(); first.focus(); }
+ },
+ };
+}
diff --git a/src/pullbox/ui/static/js/story-arc-preview.js b/src/pullbox/ui/static/js/story-arc-preview.js
index 9ebab321..689874b4 100644
--- a/src/pullbox/ui/static/js/story-arc-preview.js
+++ b/src/pullbox/ui/static/js/story-arc-preview.js
@@ -1,4 +1,4 @@
-/* The complete bounded preview stays local: paging never refetches Comic Vine
+/* The complete bounded preview stays local: paging never refetches a source
* or drops order/skip choices for members on another page. */
function storyArcPreview() {
return {
@@ -6,6 +6,7 @@ function storyArcPreview() {
libraryRootId: '', monitored: false, rootOptions: [],
title: '', description: '', coverUrl: '', coverFailed: false,
fingerprint: '', fileDefaultsFingerprint: '', fileSummary: '', fileExample: '',
+ sourceRevision: null, sourceLabel: 'Metadata source',
error: '', submitError: '', notice: '', busy: '', controller: null, endpoint: '', reorderAnnouncement: '',
init() {
@@ -86,6 +87,8 @@ function storyArcPreview() {
if (this.coverUrl !== data.coverUrl) this.coverFailed = false;
this.coverUrl = data.coverUrl;
this.fingerprint = data.fingerprint;
+ this.sourceRevision = data.sourceRevision ?? null;
+ this.sourceLabel = data.sourceLabel || 'Metadata source';
const defaultsChanged = preserve && this.fileDefaultsFingerprint !== data.fileDefaultsFingerprint;
this.fileDefaultsFingerprint = data.fileDefaultsFingerprint;
this.fileSummary = data.fileSummary;
@@ -160,7 +163,7 @@ function storyArcPreview() {
if (!this.$el.isConnected) return;
this.ready = false;
this.error = error.name === 'AbortError'
- ? 'Comic Vine took too long to respond. Your edits are still here. Retry preview.'
+ ? `${this.sourceLabel} took too long to respond. Your edits are still here. Retry preview.`
: error instanceof TypeError || error instanceof SyntaxError
? 'The connection failed. Your edits are still here. Retry preview.' : error.message;
} finally {
diff --git a/src/pullbox/ui/story_arc_catalog_forms.py b/src/pullbox/ui/story_arc_catalog_forms.py
index a95bd8f1..3c3762cb 100644
--- a/src/pullbox/ui/story_arc_catalog_forms.py
+++ b/src/pullbox/ui/story_arc_catalog_forms.py
@@ -9,6 +9,7 @@ class StoryArcCatalogAddForm(BaseModel):
"""Validate the submitted order, canonical root and current file defaults."""
fingerprint: str = Field(max_length=128)
+ source_revision: int | None = Field(default=None, ge=1, lt=2**63)
file_defaults_fingerprint: str = Field(default="", max_length=128)
issue_provider_ids: list[str] = Field(default_factory=list, max_length=2000)
reading_orders: list[int] = Field(default_factory=list, max_length=2000)
diff --git a/src/pullbox/ui/story_arc_catalog_routes.py b/src/pullbox/ui/story_arc_catalog_routes.py
index a3399c53..e81aaabc 100644
--- a/src/pullbox/ui/story_arc_catalog_routes.py
+++ b/src/pullbox/ui/story_arc_catalog_routes.py
@@ -1,82 +1,35 @@
-"""Preview-first Comic Vine arc discovery and explicit membership refresh."""
+"""Compatibility URLs and shared presentation for source-bound Story Arc commands."""
from __future__ import annotations
-from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
-from urllib.parse import urlencode
-import structlog
from fastapi import APIRouter, BackgroundTasks, Form, HTTPException, Path, Query, Request
from fastapi.responses import RedirectResponse
-from sqlalchemy import select
-from sqlalchemy.exc import IntegrityError
+from pydantic import ValidationError
from starlette.responses import Response
-from pullbox.api.deps import (
- AuthenticatedUser,
- DbSession,
- get_request_session_factory,
-)
+from pullbox.api.deps import AuthenticatedUser, DbSession # noqa: TC001
from pullbox.core.issue_numbers import format_issue_number
-from pullbox.core.library_policy import load_search_on_add_default
+from pullbox.core.metadata_identity import MetadataSource
from pullbox.models.story_arc import StoryArc, StoryArcLifecycle
-from pullbox.providers.metadata.comicvine import ComicVineError
-from pullbox.services.cover_url_service import build_story_arc_cover_url
-from pullbox.services.story_arc_file_defaults import load_story_arc_file_defaults
-from pullbox.services.story_arc_placement_integration import StoryArcPlacementIntegrationError
-from pullbox.services.story_arc_service import StoryArcServiceError, StoryArcValidationError
-from pullbox.ui.comicvine_provider import (
- ComicVineNotConfiguredError,
- open_comicvine_ui_provider,
-)
+from pullbox.schemas.metadata_sources import StoryArcPreviewQuery
+from pullbox.services.metadata_arc_commands import saved_arc_source
+from pullbox.ui.metadata_arc_search import arc_search_context
from pullbox.ui.story_arc_catalog_forms import StoryArcCatalogAddForm # noqa: TC001
-from pullbox.ui.story_arc_presenters import load_story_arc_placement_roots
if TYPE_CHECKING:
- from collections.abc import AsyncIterator, Callable
+ from collections.abc import Callable
from fastapi.templating import Jinja2Templates
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
- from pullbox.services.story_arc_catalog import (
- StoryArcCatalogPreview,
- StoryArcCatalogService,
- )
+ from pullbox.services.story_arc_catalog import StoryArcCatalogPreview
-logger = structlog.get_logger(__name__)
router = APIRouter()
_get_templates: Callable[[], Jinja2Templates] | None = None
_build_context: Callable[..., dict[str, object]] | None = None
_ProviderId = Annotated[str, Path(pattern=r"^[1-9][0-9]{0,18}$")]
-_ERRORS = {
- "provider": "Comic Vine couldn't load this arc. Check the provider settings and try again.",
- "validation": (
- "The arc wasn't added. Review the reading order and storage choices, then confirm again."
- ),
- "stale": (
- "The provider membership changed after preview. Review the latest list before confirming."
- ),
- "conflict": "This arc changed in another tab. Review the latest changes before confirming.",
- "file-defaults": (
- "Story Arc file defaults changed or are unavailable. "
- "Review Settings → Media Management, then preview this arc again."
- ),
- "catalog_limit_exceeded": (
- "This arc exceeds the supported review limit. Nothing was added or changed."
- ),
- "incomplete_hydration": (
- "Some member details couldn't be loaded. Nothing was added or changed; retry preview."
- ),
- "canonical_root_required": (
- "Choose a library root for new series before saving provider changes."
- ),
- "canonical_root_unavailable": (
- "Restore or enable the saved library root in Settings, then retry provider changes. "
- "Existing series paths and arc storage haven't changed."
- ),
-}
@dataclass(frozen=True)
@@ -101,96 +54,11 @@ def _render(request: Request, username: str, template: str, **values: object) ->
def _redirect(request: Request, url: str) -> Response:
if request.headers.get("HX-Request"):
return Response(status_code=204, headers={"HX-Redirect": url})
- # All callers build /story-arcs routes from integer IDs or the numeric
- # _ProviderId validator; no request value supplies an origin or URL prefix.
+ # Callers build fixed /story-arcs routes from validated IDs/source enums.
# codeql[py/url-redirection]
return RedirectResponse(url, status_code=303)
-@asynccontextmanager
-async def _catalog_service(
- session: DbSession,
- *,
- session_factory: async_sessionmaker[AsyncSession] | None = None,
-) -> AsyncIterator[StoryArcCatalogService]:
- from pullbox.services.story_arc_catalog import StoryArcCatalogService
-
- try:
- async with open_comicvine_ui_provider(
- session,
- session_factory=session_factory,
- ) as provider:
- yield StoryArcCatalogService(provider)
- except ComicVineNotConfiguredError as exc:
- raise StoryArcValidationError("Comic Vine is not configured") from exc
-
-
-def _failure_code(exc: Exception) -> str:
- code = getattr(exc, "code", "")
- if code in ("catalog_limit_exceeded", "incomplete_hydration"):
- return str(code)
- return "provider"
-
-
-async def _existing(session: DbSession, provider_id: str) -> int | None:
- if int(provider_id) > 2**63 - 1:
- raise HTTPException(status_code=404, detail="Story Arc provider identity not found")
- existing_id = await session.scalar(
- select(StoryArc.id).where(StoryArc.comicvine_id == int(provider_id))
- )
- return int(existing_id) if existing_id is not None else None
-
-
-async def load_story_arc_catalog_search_context(
- session: DbSession,
- *,
- q: str,
- page: int,
- base_url: str,
- session_factory: async_sessionmaker[AsyncSession] | None = None,
-) -> dict[str, object]:
- """Load one bounded Comic Vine arc result page for full or HTMX shells."""
- query = q.strip()
- results: list[dict[str, object]] = []
- total = 0
- error = ""
- if len(query) >= 2:
- try:
- async with _catalog_service(session, session_factory=session_factory) as service:
- found, total = await service.search(query, limit=20, offset=(page - 1) * 20)
- existing = await service.find_existing(
- session, [item.provider_id for item in found]
- )
- results = [
- {"metadata": item, "existing_id": existing.get(item.provider_id)}
- for item in found
- ]
- except (ComicVineError, StoryArcServiceError):
- await session.rollback()
- error = "Comic Vine search failed. Check the provider settings and try again."
- logger.warning("story_arc_catalog_search_failed")
-
- total_pages = max(1, (total + 19) // 20)
- return {
- "query": query,
- "catalog_query": query,
- "results": results,
- "total": total,
- "page": page,
- "total_pages": total_pages,
- "shown_count": len(results),
- "in_library_count": sum(1 for result in results if result["existing_id"]),
- "error_message": error,
- "pagination_base_url": (f"{base_url}?{urlencode({'q': query})}" if query else base_url),
- "next_url": (
- base_url + "?" + urlencode({"q": query, "page": page + 1}) if page * 20 < total else ""
- ),
- "previous_url": (
- base_url + "?" + urlencode({"q": query, "page": page - 1}) if page > 1 else ""
- ),
- }
-
-
def _members(preview: StoryArcCatalogPreview) -> list[dict[str, str]]:
titles = {series.provider_id: series.title for series in preview.series}
return [
@@ -211,16 +79,17 @@ async def story_arc_catalog_search(
request: Request,
user: AuthenticatedUser,
session: DbSession,
- q: Annotated[str, Query(max_length=500)] = "",
+ q: Annotated[str, Query(max_length=300)] = "",
page: Annotated[int, Query(ge=1, le=100)] = 1,
) -> Response:
username = user.username
- context = await load_story_arc_catalog_search_context(
+ context = await arc_search_context(
+ request,
session,
q=q,
page=page,
+ source=MetadataSource.COMICVINE_API,
base_url="/story-arcs/catalog",
- session_factory=get_request_session_factory(request),
)
return _render(
request,
@@ -240,36 +109,12 @@ async def story_arc_catalog_preview(
session: DbSession,
error: str = Query(""),
) -> Response:
- username = user.username
- existing_id = await _existing(session, provider_id)
- if existing_id is not None:
- return _redirect(request, f"/story-arcs/{existing_id}")
- preview = None
- message = _ERRORS.get(error, "")
- try:
- async with _catalog_service(
- session,
- session_factory=get_request_session_factory(request),
- ) as service:
- preview = await service.preview(provider_id)
- except (ComicVineError, StoryArcServiceError) as exc:
- await session.rollback()
- message = _ERRORS[_failure_code(exc)]
- logger.warning("story_arc_catalog_preview_failed", category=_failure_code(exc))
- roots, truncated = await load_story_arc_placement_roots(session, selected_root_id=None)
- managed_roots = tuple(root for root in roots if root.can_manage)
- return _render(
- request,
- username,
- "pages/story_arc_catalog_preview.html",
- preview=preview,
- members=_members(preview) if preview else [],
- provider_id=provider_id,
- error_message=message,
- placement_roots=roots,
- managed_roots=managed_roots,
- placement_roots_truncated=truncated,
- arc_file_defaults=await load_story_arc_file_defaults(session),
+ from pullbox.ui.story_arc_source_routes import source_arc_preview
+
+ if int(provider_id) > 2**63 - 1:
+ raise HTTPException(status_code=404, detail="Story Arc provider identity not found")
+ return await source_arc_preview(
+ MetadataSource.COMICVINE_API, provider_id, request, user, session, error
)
@@ -282,70 +127,42 @@ async def story_arc_catalog_add(
background_tasks: BackgroundTasks,
form: Annotated[StoryArcCatalogAddForm, Form()],
) -> Response:
- existing_id = await _existing(session, provider_id)
- if existing_id is not None:
- return _redirect(request, f"/story-arcs/{existing_id}")
- code = "validation"
+ from pullbox.ui.story_arc_source_routes import SourceArcAddForm, source_arc_add
+
+ if int(provider_id) > 2**63 - 1:
+ raise HTTPException(status_code=404, detail="Story Arc provider identity not found")
try:
- order = form.reviewed_order()
- defaults = await load_story_arc_file_defaults(session)
- if (
- form.file_defaults_fingerprint
- and form.file_defaults_fingerprint != defaults.fingerprint
- ):
- code = "file-defaults"
- raise StoryArcValidationError("File defaults changed")
- policy = defaults.proposal()
- async with _catalog_service(
- session,
- session_factory=get_request_session_factory(request),
- ) as service:
- preview = await service.preview(provider_id)
- if preview.fingerprint != form.fingerprint:
- code = "stale"
- raise StoryArcValidationError("Preview changed")
- arc = await service.add(
- session,
- preview,
- ordered_issue_provider_ids=order,
- skipped_issue_provider_ids=form.skipped_issue_provider_ids,
- library_root_id=form.library_root_id,
- monitored=form.monitored,
- search_missing=form.monitored,
- include_upcoming=form.monitored,
- placement_policy=policy,
- )
- arc_id = arc.id
- initial_pending = _has_initial_work(arc)
- search_on_add = arc.monitored and await load_search_on_add_default(session)
- await session.commit()
- except StoryArcPlacementIntegrationError:
- await session.rollback()
- return _redirect(request, f"/story-arcs/catalog/{provider_id}?error=file-defaults")
- except (StoryArcServiceError, IntegrityError):
- await session.rollback()
- return _redirect(request, f"/story-arcs/catalog/{provider_id}?error={code}")
- except ComicVineError:
- await session.rollback()
- return _redirect(request, f"/story-arcs/catalog/{provider_id}?error=provider")
- if search_on_add:
- from pullbox.tasks.story_arc_search_task import schedule_story_arc_search
-
- schedule_story_arc_search(arc_id)
- if initial_pending:
- from pullbox.services.story_arc_catalog_placement import run_catalog_initial_placements
-
- background_tasks.add_task(
- run_catalog_initial_placements,
- arc_id,
- session_factory=request.app.state.db_session_factory,
+ decision = SourceArcAddForm.model_validate(form.model_dump())
+ except ValidationError:
+ # A pre-upgrade form has no source revision and must not bypass review.
+ return _redirect(request, f"/story-arcs/catalog/{provider_id}?error=review")
+ return await source_arc_add(
+ MetadataSource.COMICVINE_API,
+ provider_id,
+ request,
+ _user,
+ session,
+ background_tasks,
+ decision,
+ )
+
+
+def _refresh_selection(arc: StoryArc) -> StoryArcPreviewQuery | None:
+ selected = saved_arc_source(arc)
+ if selected is None and arc.comicvine_id is not None:
+ selected = StoryArcPreviewQuery(
+ source=MetadataSource.COMICVINE_API, external_id=str(arc.comicvine_id)
)
- return _redirect(request, f"/story-arcs/{arc_id}?notice=catalog-added")
+ return selected
async def _provider_arc(session: DbSession, arc_id: int) -> StoryArc:
arc = await session.get(StoryArc, arc_id)
- if arc is None or arc.comicvine_id is None or arc.lifecycle is not StoryArcLifecycle.ACTIVE:
+ if (
+ arc is None
+ or _refresh_selection(arc) is None
+ or arc.lifecycle is not StoryArcLifecycle.ACTIVE
+ ):
raise HTTPException(status_code=404, detail="Active provider Story Arc not found")
return arc
@@ -389,48 +206,13 @@ async def story_arc_catalog_refresh_preview(
session: DbSession,
error: str = Query(""),
) -> Response:
+ from pullbox.ui.story_arc_source_routes import source_refresh_preview
+
username = user.username
arc = await _provider_arc(session, story_arc_id)
- name, provider_id = arc.name, str(arc.comicvine_id)
- cover_src = build_story_arc_cover_url(arc)
- comicvine_url = arc.comicvine_url or (
- f"https://comicvine.gamespot.com/story-arc/4045-{provider_id}/"
- )
- catalog = (arc.diagnostics or {}).get("provider_catalog")
- saved_root = catalog.get("canonical_library_root_id") if isinstance(catalog, dict) else None
- needs_library_root = not (type(saved_root) is int and saved_root > 0)
- preview = None
- changes = None
- message = _ERRORS.get(error, "")
- try:
- async with _catalog_service(
- session,
- session_factory=get_request_session_factory(request),
- ) as service:
- preview = await service.preview(provider_id)
- if preview.membership_complete:
- changes = await service.preview_refresh(session, story_arc_id, preview)
- except (ComicVineError, StoryArcServiceError) as exc:
- await session.rollback()
- message = _ERRORS[_failure_code(exc)]
- roots, truncated = await load_story_arc_placement_roots(session, selected_root_id=None)
- managed_roots = tuple(root for root in roots if root.can_manage)
- return _render(
- request,
- username,
- "pages/story_arc_catalog_refresh.html",
- story_arc_id=story_arc_id,
- arc_name=name,
- arc_cover_src=cover_src,
- arc_comicvine_url=comicvine_url,
- preview=preview,
- changes=changes,
- error_message=message,
- members=_members(preview) if preview else [],
- needs_library_root=needs_library_root,
- placement_roots=managed_roots,
- placement_roots_truncated=truncated,
- )
+ selection = _refresh_selection(arc)
+ assert selection is not None
+ return await source_refresh_preview(arc, selection, request, username, session, error)
@router.post("/story-arcs/{story_arc_id}/catalog-refresh", include_in_schema=False)
@@ -443,41 +225,21 @@ async def story_arc_catalog_refresh(
fingerprint: Annotated[str, Form(max_length=128)],
confirm_refresh: bool = Form(False),
library_root_id: Annotated[int | None, Form(ge=1)] = None,
+ source_revision: Annotated[int | None, Form(ge=1)] = None,
) -> Response:
+ from pullbox.ui.story_arc_source_routes import source_refresh
+
arc = await _provider_arc(session, story_arc_id)
- provider_id = str(arc.comicvine_id)
- code = "conflict"
- try:
- if not confirm_refresh:
- raise StoryArcValidationError("Confirm the refresh preview")
- async with _catalog_service(
- session,
- session_factory=get_request_session_factory(request),
- ) as service:
- preview = await service.preview(provider_id)
- if preview.fingerprint != fingerprint:
- code = "stale"
- raise StoryArcValidationError("Preview changed")
- result = await service.refresh(
- session,
- story_arc_id,
- preview,
- expected_revision=expected_revision,
- library_root_id=library_root_id,
- )
- search_on_add = result.story_arc.monitored and await load_search_on_add_default(session)
- await session.commit()
- except (StoryArcServiceError, IntegrityError) as exc:
- await session.rollback()
- root_error = getattr(exc, "code", "")
- if root_error in {"canonical_root_required", "canonical_root_unavailable"}:
- code = root_error
- return _redirect(request, f"/story-arcs/{story_arc_id}/catalog-refresh?error={code}")
- except ComicVineError:
- await session.rollback()
- return _redirect(request, f"/story-arcs/{story_arc_id}/catalog-refresh?error=provider")
- if search_on_add:
- from pullbox.tasks.story_arc_search_task import schedule_story_arc_search
-
- schedule_story_arc_search(story_arc_id)
- return _redirect(request, f"/story-arcs/{story_arc_id}?notice=catalog-refreshed")
+ selection = _refresh_selection(arc)
+ assert selection is not None
+ return await source_refresh(
+ story_arc_id,
+ selection,
+ request,
+ session,
+ source_revision=source_revision,
+ expected_revision=expected_revision,
+ fingerprint=fingerprint,
+ confirm_refresh=confirm_refresh,
+ library_root_id=library_root_id,
+ )
diff --git a/src/pullbox/ui/story_arc_presenters.py b/src/pullbox/ui/story_arc_presenters.py
index 749c278a..6035ee44 100644
--- a/src/pullbox/ui/story_arc_presenters.py
+++ b/src/pullbox/ui/story_arc_presenters.py
@@ -189,6 +189,7 @@ class StoryArcDetailView:
catalog_refresh_error: str | None = None
initial_placements: StoryArcInitialPlacementView | None = None
manual_editable: bool = True
+ catalog_source_label: str | None = None
@property
def active(self) -> bool:
@@ -997,6 +998,17 @@ async def load_story_arc_detail(
)
if fallback_issue_id is not None:
cover_src = f"/api/v1/issues/{int(fallback_issue_id)}/cover"
+ from pullbox.services.metadata_arc_commands import saved_arc_source
+ from pullbox.ui.metadata_series_search import SOURCE_LABELS
+
+ source_selection = saved_arc_source(arc)
+ source_label = (
+ SOURCE_LABELS[source_selection.source]
+ if source_selection
+ else "Comic Vine"
+ if arc.comicvine_id
+ else None
+ )
return StoryArcDetailView(
id=arc.id,
name=arc.name,
@@ -1046,10 +1058,11 @@ async def load_story_arc_detail(
publisher_name=publisher_name,
cover_src=cover_src,
catalog_removed_count=_catalog_diagnostic_count(arc, "removed_issue_provider_ids"),
- catalog_added_review_count=pending_count if arc.comicvine_id else 0,
+ catalog_added_review_count=pending_count if source_label else 0,
catalog_refresh_error=_catalog_refresh_error(arc),
initial_placements=_initial_placement_view(arc),
manual_editable=can_manually_edit_arc(arc),
+ catalog_source_label=source_label,
)
diff --git a/src/pullbox/ui/story_arc_routes.py b/src/pullbox/ui/story_arc_routes.py
index 5994f8be..edfdb8f3 100644
--- a/src/pullbox/ui/story_arc_routes.py
+++ b/src/pullbox/ui/story_arc_routes.py
@@ -15,12 +15,12 @@
from sqlalchemy.exc import IntegrityError
from starlette.responses import Response
-from pullbox.api.deps import (
+from pullbox.api.deps import ( # noqa: TC001 - FastAPI resolves route annotations at runtime.
AuthenticatedUser,
DbSession,
- get_request_session_factory,
)
from pullbox.config import get_settings
+from pullbox.core.metadata_identity import MetadataSource # noqa: TC001
from pullbox.core.story_arc_naming import (
DEFAULT_STORY_ARC_FILE_TEMPLATE,
DEFAULT_STORY_ARC_FOLDER_TEMPLATE,
@@ -57,7 +57,8 @@
StoryArcServiceError,
StoryArcValidationError,
)
-from pullbox.ui import story_arc_catalog_routes
+from pullbox.ui import story_arc_catalog_routes, story_arc_source_routes
+from pullbox.ui.metadata_arc_search import arc_search_context
from pullbox.ui.story_arc_local_issue_search import search_story_arc_local_issues
from pullbox.ui.story_arc_presenters import (
load_story_arc_detail,
@@ -68,6 +69,7 @@
router = APIRouter()
router.include_router(story_arc_catalog_routes.router)
+router.include_router(story_arc_source_routes.router)
_GetTemplates = Callable[[], Jinja2Templates]
_BuildContext = Callable[..., dict[str, object]]
@@ -466,19 +468,21 @@ async def story_arc_add(
request: Request,
user: AuthenticatedUser,
session: DbSession,
- q: Annotated[str, Query(max_length=500)] = "",
+ q: Annotated[str, Query(max_length=200)] = "",
page: Annotated[int, Query(ge=1, le=100)] = 1,
error: str | None = Query(None),
+ source: Annotated[MetadataSource | Literal[""], Query()] = "",
) -> Response:
"""Render provider-first Story Arc discovery on its own add page."""
template_user = _StoryArcTemplateUser(username=user.username)
manual_create_enabled = get_settings().story_arc_manual_create_enabled
- search_context = await story_arc_catalog_routes.load_story_arc_catalog_search_context(
+ search_context = await arc_search_context(
+ request,
session,
q=q,
page=page,
+ source=source or None,
base_url="/story-arcs/add",
- session_factory=get_request_session_factory(request),
)
search_context["error_message"] = _ERROR_MESSAGES.get(error or "", "") or str(
search_context["error_message"]
diff --git a/src/pullbox/ui/story_arc_source_routes.py b/src/pullbox/ui/story_arc_source_routes.py
new file mode 100644
index 00000000..0ccf1b70
--- /dev/null
+++ b/src/pullbox/ui/story_arc_source_routes.py
@@ -0,0 +1,285 @@
+"""Browser adapters for the shared source-bound Story Arc catalog commands."""
+
+from typing import Annotated
+from urllib.parse import urlencode
+
+from fastapi import APIRouter, BackgroundTasks, Form, Path, Request
+from pydantic import Field, ValidationError
+from sqlalchemy.exc import IntegrityError
+from starlette.responses import Response
+
+from pullbox.api.deps import AuthenticatedUser, DbSession, get_request_session_factory
+from pullbox.config import get_settings
+from pullbox.core.metadata_identity import MetadataEntityKind, MetadataSource
+from pullbox.models.story_arc import StoryArc
+from pullbox.schemas.metadata_arc_catalog import (
+ ArcCatalogAdd,
+ ArcCatalogRefresh,
+ ArcCatalogSelection,
+)
+from pullbox.schemas.metadata_sources import StoryArcPreviewQuery
+from pullbox.services.cover_url_service import build_story_arc_cover_url
+from pullbox.services.metadata_arc_commands import (
+ ArcCommandResult,
+ catalog_writer,
+ describe_arc_catalog,
+ fetch_current_arc_catalog,
+ source_arc_add_transaction,
+ source_arc_refresh_transaction,
+)
+from pullbox.services.metadata_sources import read_source_policies
+from pullbox.services.provider_artwork import allowed_artwork_url
+from pullbox.services.story_arc_catalog_identity import catalog_owners
+from pullbox.services.story_arc_catalog_types import StoryArcCatalogError
+from pullbox.services.story_arc_file_defaults import load_story_arc_file_defaults
+from pullbox.services.story_arc_placement_integration import StoryArcPlacementIntegrationError
+from pullbox.services.story_arc_service import StoryArcServiceError
+from pullbox.ui.metadata_series_search import SOURCE_LABELS
+from pullbox.ui.story_arc_catalog_forms import StoryArcCatalogAddForm
+from pullbox.ui.story_arc_catalog_routes import _members, _redirect, _render
+from pullbox.ui.story_arc_presenters import load_story_arc_placement_roots
+
+router = APIRouter()
+_NativeId = Annotated[str, Path(pattern=r"^[1-9][0-9]{0,254}$")]
+
+
+class SourceArcAddForm(StoryArcCatalogAddForm):
+ source_revision: int = Field(ge=1, lt=2**63)
+
+
+def command_message(exc: Exception | None = None, *, code: str = "") -> str:
+ if isinstance(exc, StoryArcCatalogError):
+ if exc.code == "incomplete_membership":
+ return "Incomplete member list. Nothing was changed; retry the preview."
+ return str(exc)
+ messages = {
+ "file_defaults_changed": (
+ "Story Arc file defaults changed. Review Settings > Media Management, "
+ "then preview this arc again."
+ ),
+ "canonical_root_required": (
+ "Choose a library root for new series before saving provider changes."
+ ),
+ "canonical_root_unavailable": (
+ "Restore or enable the saved library root in Settings, then retry provider changes. "
+ "Existing series paths and arc storage haven't changed."
+ ),
+ }
+ if code in messages:
+ return messages[code]
+ return (
+ "The arc wasn't changed. Review the current source and file settings, then retry preview."
+ )
+
+
+async def current_selection(
+ session: DbSession, source: MetadataSource, identifier: str
+) -> ArcCatalogSelection:
+ policies = await read_source_policies(session)
+ policy = next(row for row in policies if row.source is source)
+ if not policy.enabled or policy.revision < 1:
+ raise StoryArcCatalogError(
+ "source_changed",
+ "Enable and save this source in Metadata settings, then retry preview.",
+ )
+ return ArcCatalogSelection(
+ source=source, external_id=identifier, source_revision=policy.revision
+ )
+
+
+def schedule_work(result: ArcCommandResult, request: Request, tasks: BackgroundTasks) -> None:
+ if result.search_on_add:
+ from pullbox.tasks.story_arc_search_task import schedule_story_arc_search
+
+ schedule_story_arc_search(result.arc.id)
+ if result.initial_placements:
+ from pullbox.services.story_arc_catalog_placement import run_catalog_initial_placements
+
+ tasks.add_task(
+ run_catalog_initial_placements,
+ result.arc.id,
+ session_factory=get_request_session_factory(request),
+ )
+
+
+@router.get("/story-arcs/catalog/{source}/{provider_id}", include_in_schema=False)
+async def source_arc_preview(
+ source: MetadataSource,
+ provider_id: _NativeId,
+ request: Request,
+ user: AuthenticatedUser,
+ session: DbSession,
+ error: str = "",
+) -> Response:
+ username = user.username
+ preview = None
+ message = command_message(code=error) if error else ""
+ revision = 0
+ try:
+ selection = await current_selection(session, source, provider_id)
+ revision = selection.source_revision
+ owners = await catalog_owners(session, MetadataEntityKind.STORY_ARC, [provider_id], source)
+ if provider_id in owners:
+ return _redirect(request, f"/story-arcs/{owners[provider_id]}")
+ preview = await fetch_current_arc_catalog(
+ session, selection, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled
+ )
+ await describe_arc_catalog(session, preview)
+ except (StoryArcServiceError, StoryArcPlacementIntegrationError, ValidationError) as exc:
+ await session.rollback()
+ preview, message = None, command_message(exc)
+ roots, truncated = await load_story_arc_placement_roots(session, selected_root_id=None)
+ cover = preview.metadata.cover_url if preview else None
+ return _render(
+ request,
+ username,
+ "pages/story_arc_catalog_preview.html",
+ preview=preview,
+ members=_members(preview) if preview else [],
+ provider_id=provider_id,
+ preview_url=f"/story-arcs/catalog/{source.value}/{provider_id}",
+ source_revision=revision,
+ source_label=SOURCE_LABELS[source],
+ preview_cover_url=cover if cover and allowed_artwork_url(cover) else "",
+ error_message=message,
+ placement_roots=roots,
+ managed_roots=tuple(r for r in roots if r.can_manage),
+ placement_roots_truncated=truncated,
+ arc_file_defaults=await load_story_arc_file_defaults(session),
+ )
+
+
+@router.post("/story-arcs/catalog/{source}/{provider_id}", include_in_schema=False)
+async def source_arc_add(
+ source: MetadataSource,
+ provider_id: _NativeId,
+ request: Request,
+ _user: AuthenticatedUser,
+ session: DbSession,
+ background_tasks: BackgroundTasks,
+ form: Annotated[SourceArcAddForm, Form()],
+) -> Response:
+ try:
+ decision = ArcCatalogAdd(
+ source=source,
+ external_id=provider_id,
+ source_revision=form.source_revision,
+ fingerprint=form.fingerprint,
+ file_defaults_fingerprint=form.file_defaults_fingerprint,
+ ordered_issue_ids=form.reviewed_order(),
+ skipped_issue_ids=form.skipped_issue_provider_ids,
+ library_root_id=form.library_root_id,
+ monitored=form.monitored,
+ )
+ preview = await fetch_current_arc_catalog(
+ session, decision, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled
+ )
+ async with source_arc_add_transaction(session, preview, decision) as result:
+ destination = f"/story-arcs/{result.arc.id}?notice=catalog-added"
+ except (
+ StoryArcServiceError,
+ StoryArcPlacementIntegrationError,
+ IntegrityError,
+ ValidationError,
+ ) as exc:
+ await session.rollback()
+ code = exc.code if isinstance(exc, StoryArcCatalogError) else "review"
+ return _redirect(
+ request,
+ f"/story-arcs/catalog/{source.value}/{provider_id}?{urlencode({'error': code})}",
+ )
+ schedule_work(result, request, background_tasks)
+ return _redirect(request, destination)
+
+
+async def source_refresh_preview(
+ arc: StoryArc,
+ selection: StoryArcPreviewQuery,
+ request: Request,
+ username: str,
+ session: DbSession,
+ error: str,
+) -> Response:
+ arc_id, name = arc.id, arc.name
+ cover_src = build_story_arc_cover_url(arc)
+ resource_url = (
+ f"https://comicvine.gamespot.com/story-arc/4045-{selection.external_id}/"
+ if selection.source is MetadataSource.COMICVINE_API
+ else None
+ )
+ catalog = arc.diagnostics.get("provider_catalog", {})
+ root = catalog.get("canonical_library_root_id") if isinstance(catalog, dict) else None
+ preview = changes = None
+ message = command_message(code=error) if error else ""
+ revision = 0
+ try:
+ current = await current_selection(session, selection.source, selection.external_id)
+ revision = current.source_revision
+ preview = await fetch_current_arc_catalog(
+ session, current, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled
+ )
+ changes = await catalog_writer(preview).preview_refresh(session, arc_id, preview)
+ except (StoryArcServiceError, ValidationError) as exc:
+ await session.rollback()
+ preview, changes, message = None, None, command_message(exc)
+ roots, truncated = await load_story_arc_placement_roots(session, selected_root_id=None)
+ return _render(
+ request,
+ username,
+ "pages/story_arc_catalog_refresh.html",
+ story_arc_id=arc_id,
+ arc_name=name,
+ arc_cover_src=cover_src,
+ arc_comicvine_url=resource_url,
+ source_label=SOURCE_LABELS[selection.source],
+ source_revision=revision,
+ preview=preview,
+ changes=changes,
+ error_message=message,
+ members=_members(preview) if preview else [],
+ needs_library_root=not (type(root) is int and root > 0),
+ placement_roots=tuple(r for r in roots if r.can_manage),
+ placement_roots_truncated=truncated,
+ )
+
+
+async def source_refresh(
+ arc_id: int,
+ selection: StoryArcPreviewQuery,
+ request: Request,
+ session: DbSession,
+ *,
+ source_revision: int | None,
+ expected_revision: int,
+ fingerprint: str,
+ confirm_refresh: bool,
+ library_root_id: int | None,
+) -> Response:
+ try:
+ if not confirm_refresh or source_revision is None:
+ raise StoryArcCatalogError("review_required", "Review the source changes first")
+ decision = ArcCatalogRefresh(
+ **selection.model_dump(),
+ source_revision=source_revision,
+ expected_revision=expected_revision,
+ fingerprint=fingerprint,
+ library_root_id=library_root_id,
+ )
+ preview = await fetch_current_arc_catalog(
+ session, decision, gcd_api_enabled=get_settings().metadata_gcd_api_v2_enabled
+ )
+ async with source_arc_refresh_transaction(session, arc_id, preview, decision) as result:
+ destination = f"/story-arcs/{arc_id}?notice=catalog-refreshed"
+ except (
+ StoryArcServiceError,
+ StoryArcPlacementIntegrationError,
+ IntegrityError,
+ ValidationError,
+ ) as exc:
+ await session.rollback()
+ code = exc.code if isinstance(exc, StoryArcCatalogError) else "review"
+ return _redirect(
+ request, f"/story-arcs/{arc_id}/catalog-refresh?{urlencode({'error': code})}"
+ )
+ schedule_work(result, request, BackgroundTasks())
+ return _redirect(request, destination)
diff --git a/src/pullbox/ui/templates/base.html b/src/pullbox/ui/templates/base.html
index 3ff34742..77cf6207 100644
--- a/src/pullbox/ui/templates/base.html
+++ b/src/pullbox/ui/templates/base.html
@@ -984,6 +984,10 @@
+
+
+
+