Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions alembic/versions/o6i7j8k9l012_add_series_rescan_job_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Allow durable per-series rescan jobs in the existing utility queue."""

from alembic import op
from sqlalchemy import text

revision = "o6i7j8k9l012"
down_revision = "n5h6i7j8k901"
branch_labels = None
depends_on = None

_OLD_TYPES = (
"file_convert",
"mass_convert_pipeline",
"mass_rename",
"db_check_cleanup",
"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:
_replace((*_OLD_TYPES, "series_rescan"))


def downgrade() -> None:
if op.get_bind().scalar(
text("SELECT count(*) FROM utility_jobs WHERE job_type = 'series_rescan'")
):
raise RuntimeError("Remove saved series rescan jobs before downgrading this migration.")
_replace(_OLD_TYPES)
28 changes: 28 additions & 0 deletions docs/development/IMPORT_REVIEW_RECOVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,34 @@ recovery is available in Import Follow-up and does not require an
offline command. It works for Mylar and folder imports. This is not a full
rescan, a database restore, or an import.

## Mylar Inventory And Missing References

Copy and keep-in-place scans retain the same Mylar file inventory, including
recorded filenames that no longer exist. Missing-only annual groups retain their
own series identity; a missing annual must not disappear in copy mode or become
an ordinary numbered issue. Keep-in-place reference-root checks still apply
independently of discovery.

Steps 2 and 3 report available file records separately from missing references.
Missing references remain visible in review (in Info when no other issue needs
attention), but are not counted as available comics, archive safety decisions,
or unsettled file-review work. Durable
`scan_total_files` and `files_total` remain record totals for compatibility.
`files_present` and `files_missing_references` expose the split. Mylar batch
diagnostics also record the handling mode and reconciled-reference count.

A renamed source can replace a stale Mylar path only when one same-folder file
has an independent, agreeing ComicInfo issue ID. Reconciliation checks the
issue publication year, not ComicInfo.Volume's series start year. A Mylar
ordinary-issue row may represent a collected volume only when its saved
filename explicitly identifies a volume. Conflicting IDs, numbers, publication
years, annual types, and competing copies remain unresolved. Source signature,
root boundary, archive safety, and manual-review protections still apply.

The shared checks cover fresh Mylar discovery and saved-review reconciliation.
They do not modify Mylar, rename source files, or change completed-import
recovery and ownership rules. No metadata-provider requests are introduced.

## Import Follow-up

The Follow-up tab groups actionable work by import job rather than rendering
Expand Down
322 changes: 322 additions & 0 deletions docs/development/IMPORT_REVIEW_REDESIGN.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/development/INFRASTRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ tracked in the active CI/CD path.
AMD64/ARM64 platform builds can run concurrently. Production releases use
architecture-specific GitHub Actions cache scopes and merge the platform
digests only after validation succeeds.
- The Docker runners are organization-level runners in `pullbox-docker-builders`,
not repository-level registrations. Check the organization's runner inventory
and the group's selected-repository access before attempting restoration; an
empty repository-only listing does not establish that these runners are missing.
- Untrusted Docker PRs run a reduced public sanity check (`Dockerfile.dev`
build) instead of the full DHI-backed production build.
- `Docker Validate Required` is the stable aggregate check for Docker
Expand Down
49 changes: 49 additions & 0 deletions docs/development/SERIES_RESCAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Series Folder Rescan

The series detail **Rescan folder** action reconciles local files with the existing
issue catalog. It is separate from metadata refresh and library organization.

- Scans only the configured series folder and its subfolders, plus individually
registered paths for that series. It never scans those other paths' parent
folders or an entire library root.
- Inspects archives off the event loop using the shared safety, content, local
metadata, conflict, and semantic matching helpers. No metadata-provider calls
are needed. Exact issue-number text preserves lettered and fractional issues.
- Plans the complete candidate set before registration. Ambiguous copies, foreign
series, weak matches, unsafe archives, one-page archives, and recently modified
files require review instead of being silently chosen.
- Rechecks source signatures, root policy, catalog identity, current ownership,
active imports, and downloads before writing. New files are referenced, not
managed artifacts. Read-only mounts are supported.
- Existing valid copies win. Proven replacements for missing links preserve the
LibraryFile ID and become references. Missing/unreadable files never downgrade
ownership or trigger downloads. Rescans never move, rename, convert, overwrite,
delete, or write ComicInfo into source files.

## Progress and Review

`series_rescan` uses the durable utility queue and shared background activity
projection. Initial inspection is indeterminate until the complete candidate set
is known; registration has measured progress. Each result is saved as a job item.
The series dialog shows added, repaired, unchanged, and review counts, with paged
exceptions. **Review in Import** opens an explicit single-file, keep-in-place
import for the existing matching and safety-review controls. This action does not
approve or bypass safety findings. Files still being copied or on an unavailable
mount should instead be rescanned after the source is stable.

The issue panel updates in place on completion, without replacing the page. Jobs
continue when the dialog is closed or the user navigates elsewhere. Saved reports
are also retained in utility history. A repeat click reuses the active series job.

The additive migration `o6i7j8k9l012` extends the utility job-type constraint. No
catalog or ownership rows are backfilled. Downgrade requires removal of rescan job
history first rather than silently reclassifying or deleting its records.

## Regression Coverage

`test_series_rescan.py` covers source preservation, referenced registration,
idempotence, stale ownership/link repair, duplicates, exact letter suffixes,
one-page/unsafe archives, unavailable roots, linked files in mixed folders, active
downloads, durable queue execution, report counts, and duplicate start requests.
Migration coverage preserves dependent history. Browser coverage exercises
progress, completion, saved results, keyboard focus, and stable page identity.
108 changes: 107 additions & 1 deletion src/pullbox/api/v1/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
from fastapi import APIRouter, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse

from pullbox.api.deps import AuthenticatedStreamUser, get_request_session_factory
from pullbox.api.deps import (
AuthenticatedStreamUser,
InteractiveOperatorUser,
get_request_session_factory,
)
from pullbox.config import get_settings
from pullbox.core.events import (
ReaderCompletionChanged,
Expand Down Expand Up @@ -125,6 +129,108 @@ def _raise_http_error(exc: PageSourceError) -> Never:
) from exc


async def _import_source(request: Request, job_id: int, file_id: int) -> ResolvedReaderSource:
from pullbox.core.exceptions import ConfigurationError, ValidationError
from pullbox.services.import_reader_service import (
load_import_reader_record,
resolve_import_reader_source,
)

_require_reader_enabled()
try:
async with get_request_session_factory(request)() as session:
record = await load_import_reader_record(session, job_id, file_id)
return await anyio.to_thread.run_sync(resolve_import_reader_source, record)
except (ValidationError, ConfigurationError, ValueError, OSError, RuntimeError) as exc:
raise HTTPException(
status_code=409,
detail="This file changed, is unavailable, or cannot be previewed safely. "
"Close the reader and recheck it, or choose Skip.",
) from exc
except PageSourceError as exc:
_raise_import_reader_error(exc)


def _raise_import_reader_error(exc: PageSourceError) -> Never:
message = str(exc)
if exc.code in {PageSourceErrorCode.CORRUPT_SOURCE, PageSourceErrorCode.EMPTY_SOURCE}:
message = "This archive or its image is damaged or contains no readable pages."
raise HTTPException(
status_code=_ERROR_STATUS[exc.code],
detail={
"code": exc.code.value,
"message": message
+ " Close the reader and choose Skip if you do not want to import it.",
},
) from exc


@router.get("/imports/{job_id}/files/{file_id}/manifest")
async def import_reader_manifest(
request: Request, job_id: int, file_id: int, _user: InteractiveOperatorUser
) -> JSONResponse:
"""Preview a staged file without approval, registration, or reading-state writes."""
source = await _import_source(request, job_id, file_id)
try:
service = _content_service(request)
manifest = await service.get_manifest(source)
if manifest.page_count != 1:
raise HTTPException(
status_code=409,
detail="This file is no longer a one-page archive. "
"Close the reader and recheck it, or choose Skip.",
)
# Validate the image too, so a broken page gets an actionable reader error.
await service.get_page(source, page_index=0, revision=manifest.revision)
except ReaderWorkerBusyError as exc:
_raise_reader_busy(exc)
except PageSourceError as exc:
_raise_import_reader_error(exc)
return JSONResponse(
content={
"title": manifest.title,
"issue_label": "Import preview",
"page_count": manifest.page_count,
"revision": manifest.revision,
"initial_page_index": 0,
"page_url_template": (
f"/api/v1/reader/imports/{job_id}/files/{file_id}/pages/{{page_index}}"
f"?revision={manifest.revision}"
),
},
headers={"Cache-Control": "private, no-store"},
)


@router.get("/imports/{job_id}/files/{file_id}/pages/{page_index}")
async def import_reader_page(
request: Request,
job_id: int,
file_id: int,
page_index: int,
_user: InteractiveOperatorUser,
revision: Annotated[str, Query(min_length=1, max_length=64)],
) -> Response:
source = await _import_source(request, job_id, file_id)
if page_index != 0:
raise HTTPException(status_code=404, detail="Preview page not found.")
try:
page = await _content_service(request).get_page(source, page_index=0, revision=revision)
except ReaderWorkerBusyError as exc:
_raise_reader_busy(exc)
except StaleReaderRevisionError as exc:
raise HTTPException(
status_code=409, detail="The file changed. Open View File again."
) from exc
except PageSourceError as exc:
_raise_import_reader_error(exc)
return FileResponse(
page.path,
media_type=page.media_type,
headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"},
)


@router.get("/issues/{issue_id}/manifest", response_model=ReaderManifestResponse)
async def reader_manifest(
request: Request,
Expand Down
2 changes: 2 additions & 0 deletions src/pullbox/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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_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
from pullbox.api.v1.suggestions import router as suggestions_router
Expand All @@ -42,6 +43,7 @@
v1_router.include_router(blocklist_router)
v1_router.include_router(auth_router)
v1_router.include_router(series_router)
v1_router.include_router(series_rescan_router)
v1_router.include_router(story_arcs_router)
v1_router.include_router(story_arc_placements_router)
v1_router.include_router(issues_router)
Expand Down
Loading
Loading