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 @@

+ + + + diff --git a/src/pullbox/ui/templates/components/comicvine_result_cover.html b/src/pullbox/ui/templates/components/comicvine_result_cover.html index b5ff6471..8b8eaff5 100644 --- a/src/pullbox/ui/templates/components/comicvine_result_cover.html +++ b/src/pullbox/ui/templates/components/comicvine_result_cover.html @@ -1,5 +1,12 @@ -{% macro comicvine_result_cover(cover_url, title, href=none, link_testid=none) -%} -
+{% macro comicvine_result_cover(cover_url, title, href=none, link_testid=none, metron_id=none, source_revision=none) -%} + diff --git a/src/pullbox/ui/templates/components/provider_update_link.html b/src/pullbox/ui/templates/components/provider_update_link.html index 7b49adc0..93a1eb66 100644 --- a/src/pullbox/ui/templates/components/provider_update_link.html +++ b/src/pullbox/ui/templates/components/provider_update_link.html @@ -1,5 +1,5 @@ {# Shared navigation action for a read-only provider update check. #} -{% macro provider_update_link(url, label="Check for updates", testid="") %} +{% macro provider_update_link(url, label="Check for updates", testid="", source_label="Comic Vine") %} {{ label }} Checking… - + {% endmacro %} diff --git a/src/pullbox/ui/templates/pages/add_series.html b/src/pullbox/ui/templates/pages/add_series.html index 6dfce713..cce4c70a 100644 --- a/src/pullbox/ui/templates/pages/add_series.html +++ b/src/pullbox/ui/templates/pages/add_series.html @@ -73,6 +73,25 @@ class_name="series-registry-search add-series-search-field" ) }} +
+ Source + {{ dropdown_select( + name="source", + value=search_source, + size="sm", + options=search_source_options, + root_testid="add-series-source-select", + hx_get="/series/add", + hx_target="#add-series-results", + hx_swap="morph:outerHTML", + hx_push_url="true", + hx_sync="#add-series-search-form:replace", + hx_include="#add-series-search-form", + extra_input_attrs='hx-indicator="#add-series-results-loading"', + class_name="series-registry-dropdown", + fit_width=true + ) }} +
Sort {{ dropdown_select( @@ -104,11 +123,11 @@ x-show="showModal" x-cloak class="modal-shell !mt-0" - @keydown.escape.window="showModal = false" + @keydown.escape.window="closeModal()" >