Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
8633c26
chore: start v2.0 development cycle
Sep 27, 2026
ae077b6
feat: establish metadata provider identity foundations
Sep 27, 2026
40ebc47
feat: inventory legacy metadata identities and rehearse ownership
Sep 27, 2026
1b65fa1
feat: define metadata identity lifecycle and history contracts
Sep 27, 2026
515ff11
feat: audit legacy metadata identity ownership across pages
Sep 28, 2026
17be908
feat: define metadata evidence provenance and retry contracts
Sep 28, 2026
e618837
feat: add bounded MetronInfo identity evidence reader
Sep 28, 2026
6b4be93
feat: read embedded metadata in one bounded archive pass
Sep 28, 2026
c332d6c
feat: persist metadata identities with guarded legacy backfill
Sep 28, 2026
1963fc2
feat: attach metadata identities atomically during arc adoption
Sep 28, 2026
462994f
feat: attach identities during metadata hydration and imports
Sep 28, 2026
9de2dfa
fix: guard metadata refresh and recovery identity ownership
Sep 28, 2026
870aae2
feat: preserve story arc identity lifecycle and migration history
Sep 28, 2026
c4a437c
feat: route story arc catalog identities through shared history
Sep 28, 2026
4894d2a
fix: normalize legacy story arc identities without losing evidence
Sep 28, 2026
3151b7e
feat: preserve story arc import identity history through rollback
Sep 28, 2026
0c43d6c
feat: add revision-checked metadata identity review
Sep 28, 2026
0989ab4
feat: persist metadata source configuration and priorities
Sep 28, 2026
0cd78bc
feat: add bounded metadata source discovery and health APIs
Sep 28, 2026
105daf1
feat(metadata): add source priority and health settings
Sep 28, 2026
13ba6c8
feat(metadata): add bounded Metron source adapter
Sep 28, 2026
1ce7293
feat(metadata): add Metron credential settings
Sep 28, 2026
7f2f4ac
feat(metadata): add source-bound series previews
Sep 28, 2026
76f8d1e
feat(metadata): add identity-safe series adoption
Sep 28, 2026
2116c1e
feat(metadata): add source-aware series creation workflow
Sep 28, 2026
3fe1b4a
feat(metadata): add provider-neutral artwork downloads
Sep 28, 2026
eca6db0
perf(metadata): limit story arc migration reflection
Sep 28, 2026
50caa38
fix(metadata): reject encoded artwork before decoding
Sep 28, 2026
eb41c77
feat(metadata): checkpoint source-bound Add Series preview
Sep 28, 2026
026d9a9
fix(metadata): honor root naming in source previews
Sep 28, 2026
60563db
feat(metadata): collect bounded multi-source series results
Sep 28, 2026
b2709d4
feat(metadata): add bounded search snapshot cache
Sep 28, 2026
76a9654
feat(metadata): connect Add Series to multi-source discovery
Sep 28, 2026
9cee550
feat(metadata): add source-bound story arc discovery
Sep 28, 2026
d7103b8
feat(metadata): make story arc adoption source-aware
Sep 28, 2026
bba495d
feat(metadata): preserve complete source story arc snapshots
Sep 28, 2026
517a43e
feat(metadata): add source-bound story arc catalog commands
Sep 28, 2026
5523e06
feat(metadata): connect story arc UI to configured sources
Sep 28, 2026
de93197
feat(metadata): refresh monitored arcs through configured sources
Sep 28, 2026
3ac0411
feat(metadata): cache and revalidate source responses
Sep 28, 2026
053add9
test(metadata): register source-aware story arc routes
Sep 28, 2026
39ed94f
feat(metadata): assemble canonical source refresh snapshots
Sep 28, 2026
e696598
feat(metadata): persist revision-checked canonical baselines
Sep 28, 2026
11f013f
feat(metadata): refresh series through configured sources
Sep 28, 2026
5233f82
fix(metadata): complete manual refresh failure handling
Sep 28, 2026
d06e512
feat(metadata): refresh scheduled series through configured sources
Sep 28, 2026
a6325b4
feat(metadata): add bounded recent issue source reads
Sep 28, 2026
af4f6fc
feat(metadata): persist source-bound catalog checkpoints
Sep 28, 2026
e7dd4ec
refactor(metadata): share canonical issue catalog writes
Sep 28, 2026
334054a
feat(metadata): sync daily issues through configured sources
Sep 28, 2026
5805006
feat(metadata): isolate durable retries by series and source
Sep 28, 2026
8d678b6
feat(metadata): persist shared provider account admission
Sep 28, 2026
5400b20
feat(metadata): expose deferred work and recover account access
Sep 29, 2026
6b8b056
feat(metadata): persist canonical story arc metadata
Sep 29, 2026
34a83cb
feat(metadata): cascade story arc descriptive refresh
Sep 29, 2026
0a213b3
fix(metadata): unify legacy story arc source controls
Sep 29, 2026
57b15f0
feat(metadata): preserve canonical creator credits
Sep 29, 2026
2960210
feat(metadata): retain rich MetronInfo archive values
Sep 29, 2026
923190b
feat(metadata): reconcile embedded archive evidence
Sep 29, 2026
3d8d25e
feat(metadata): retain archive provenance in canonical snapshots
Sep 29, 2026
f7ed67e
feat(metadata): validate MetronInfo output against offline schema
Sep 29, 2026
cf9de68
feat(metadata): render coordinated archive metadata safely
Sep 29, 2026
2a87dd3
feat(metadata): write paired CBZ metadata atomically
Sep 29, 2026
0039bf2
feat(metadata): stage paired archive writes in interruptible workers
Sep 29, 2026
cea6ed6
feat(metadata): bind archive writes to current library ownership
Sep 29, 2026
e8723f8
feat(metadata): journal paired archive publication and recovery
Sep 29, 2026
cd5aa9d
feat(metadata): finalize canonical archive publication state
Sep 29, 2026
3eb25e4
fix(metadata): reconcile ComicInfo counts on archive round-trip
Sep 29, 2026
0840ffd
feat(metadata): bind archive publication to import ownership
Sep 29, 2026
b796b72
fix(metadata): recover stopped import archive publications
Sep 29, 2026
d1347c2
fix(metadata): coordinate archive publication with library renames
Sep 29, 2026
06efc10
feat(metadata): stage paired metadata from non-ZIP archives
Sep 29, 2026
a93bf1a
fix(library): make conversion publication recoverable
Sep 29, 2026
6f82f64
feat(metadata): support paired PDF conversion
Sep 29, 2026
54530fa
fix(library): keep deletion scoped to literal folder paths
Sep 29, 2026
500f0f5
feat(library): add durable removal staging and recovery
Sep 29, 2026
5a0572c
feat(library): finish committed removals with recoverable cleanup
Sep 29, 2026
e351038
fix(library): protect recovery artifacts during trash cleanup
Sep 29, 2026
fb600d7
fix(library): finish terminal removal workspace cleanup
Sep 29, 2026
0f76dba
feat(import): connect paired archive metadata writing and retries
Sep 30, 2026
80da37b
fix(metadata): guard provider rollout and search loading state
Sep 30, 2026
4ff39c2
fix(metadata): show Metron covers in series search and preview
Sep 30, 2026
13d117e
feat(metadata): enable read-only GCD local search and add
Sep 30, 2026
4dfcb24
feat: link series providers and safely enrich metadata
Sep 30, 2026
cff944d
fix(metadata): distinguish empty provider searches from outages
Sep 30, 2026
5246316
feat(metadata): review unsupported GCD catalog entries before add
Oct 1, 2026
af736fb
feat(metadata): link and enrich existing issue providers
Oct 1, 2026
9fcfe8d
feat(metadata): preview and write reconciled CBZ metadata
Oct 1, 2026
266263e
feat(metadata): review conflicting file metadata before paired writes
Oct 1, 2026
de0b34c
feat(metadata): preview and write reconciled series sidecars
Oct 1, 2026
6310f9b
chore(security): record five approved runtime risk exceptions
Oct 1, 2026
13ae676
fix(security): match approved runtime package names literally
Oct 1, 2026
f3a95f2
fix(ci): qualify ORM and exact security exception contracts
Oct 1, 2026
dbbcefd
fix(metadata): synchronize GCD and priority settings revisions
Oct 1, 2026
c125961
test: isolate metadata job sessions and cached UI state
Oct 2, 2026
95dd183
test(import): own the active matching job in history navigation
Oct 2, 2026
c161df9
test: restore runtime names and synchronize concurrency assertions
Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 4 additions & 3 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 35 additions & 6 deletions .grype.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
47 changes: 47 additions & 0 deletions alembic/versions/b9v0w1x2y345_add_library_conversion_journal.py
Original file line number Diff line number Diff line change
@@ -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")
44 changes: 44 additions & 0 deletions alembic/versions/c0w1x2y3z456_add_library_removal_journal.py
Original file line number Diff line number Diff line change
@@ -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")
36 changes: 36 additions & 0 deletions alembic/versions/d1x2y3z4a567_add_removal_cleanup_evidence.py
Original file line number Diff line number Diff line change
@@ -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")
57 changes: 57 additions & 0 deletions alembic/versions/e2y3z4a5b678_index_removal_trash_receipts.py
Original file line number Diff line number Diff line change
@@ -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")
27 changes: 27 additions & 0 deletions alembic/versions/f3z4a5b6c789_mark_removal_workspace_cleanup.py
Original file line number Diff line number Diff line change
@@ -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")
34 changes: 34 additions & 0 deletions alembic/versions/g4a5b6c7d890_add_series_catalog_exclusions.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading