← All projects

Scanta

Working private tool

A self-hosted photo-scanning pipeline that resumes interrupted imports from a ledger without re-uploading completed files.

C#/.NETMicroservicesAuthenticationASP.NET CoreSelf-hosted

At a glance

  • Outcome: Lets a specific nontechnical family member scan old photos from Windows without giving archive credentials to the PC or public server.
  • Status: Runs real batches privately. Hosts OIDC-protected intake on a VPS and runs the importer beside Immich on a homelab node.
  • Role: Built every component solo.
  • Stack & libraries: Uses C#/.NET, Avalonia + NAPS2, ASP.NET Core, .NET Worker, EF Core/SQLite, and shared contracts.
  • Source: MIT-licenses five private repositories: an umbrella and four submodules. Uses this page as the case study.
  • Validation: Uses xUnit and WebApplicationFactory HTTP and contract tests that POST canonical Scanta.Contracts fixtures verbatim. Includes two opt-in real-Immich tests; one covers the pipeline.
  • Limitations: Has not run a real scanner; NAPS2 is written and reviewed, but hardware acceptance remains unchecked. Has not tested date and album handling end to end, including approximate sorting and contributor ownership and sharing. Targets development Immich, not the final archive; uses manual crop when automatic deskew and crop lack confidence.

Architecture

Keeps the homelab off the public internet and the archive credential inside it. The VPS stages batches; only the homelab writes the archive.

[Windows desktop] --upload--> [public intake (ScanDrop)] --forward--> [homelab ImportWorker] --> [Immich]

Uses Avalonia + NAPS2 for capture, date hints, album selection, and crop/deskew. ASP.NET Core ScanDrop authenticates, validates, and stages; .NET Worker + EF Core ImportWorker runs beside Immich. Compiling Scanta.Contracts DTOs and enums verifies integration.

Dates and status

Scans assign usually wrong dates; contributors may know only “summer, sometime in the seventies.” DateHint stores exact date, month, season, year, decade, or unknown, confidence, display label, and resolved sort date. Maps years and seasons to midpoints, not January 1, spreading a few hundred year-only photos across the timeline.

Applies hints per photo, date-group, or batch; the most specific wins. Accepts unknown without validation failure, keeps its scan date, imports it, and adds it to its album and Needs Dating.

Shares one fourteen-value BatchStatus across wire protocol, desktop poll, and SQLite. Reduces it to six phrases, from uploading through importing to imported or needs help. Shows retryable failures as importing twice, then Delayed, retrying when third-attempt backoff exceeds seconds.

Import recovery

Records completed effects in a forward-only ledger: file asset IDs, batch album IDs, album shares, and Needs Dating placements. Retries without rollback or auto-delete; Immich cannot join a transaction, and landed photos remain.

Derives device asset IDs from SHA-256, giving the same file the same identity. Treats duplicate as success, covering a crash after Immich accepts an upload but before SQLite stores its ID. Limits concurrency to one batch, capping throughput; nothing is legitimately in flight at startup. Requeues remnants marked Claimed or ImportingToImmich.

Verified a forced mid-import crash against development Immich and unit-tested ledger skip rules. Has not replayed a device asset ID against a real server.

Authentication and contracts

Uses opaque, scoped, peppered-hash desktop tokens and self-hosted admin OIDC. Requires a shared service-token bearer on every ScanDrop → ImportWorker endpoint. A private overlay limits port access. Ships the real Immich client by default, fails closed without a key, and uses the in-memory fake only for dev and CI.