Scanta
Working private toolGets a family photo archive off the scanner and into a self-hosted library, and resumes a half-finished import from a ledger instead of re-uploading what already landed.
At a glance
- Outcome: A nontechnical family member scans old photos from a Windows PC into the family archive, without that PC, or the public server it uploads to, ever holding a credential for the archive.
- Status: Deployed and running privately. The intake service is live on a VPS behind an OIDC provider, the importer runs on the homelab node next to Immich, and real batches import today.
- Role: Solo: architecture, auth model, ledger design, the desktop app, both server processes, and the shared contract assembly they all compile against.
- Stack & libraries: C#/.NET throughout: Avalonia + NAPS2 desktop client, ASP.NET Core intake, .NET Worker + EF Core/SQLite importer, shared contracts assembly.
- Source: Private; this page is the case study.
- Validation: xUnit unit tests plus WebApplicationFactory HTTP and contract tests that POST the canonical fixtures verbatim. Two opt-in tests drive a real Immich, one of them the whole pipeline.
- Limitations: No real scanner has run through the pipeline yet; the NAPS2 integration is written and reviewed, but the hardware acceptance is unchecked. The dates-and-albums acceptance hasn’t been run end to end, so nobody has checked that approximate dates sort correctly or that a contributor’s album comes out owned and shared right. The target is still the development Immich instance rather than the final archive. Automatic deskew and crop decline when the detector isn’t confident and fall back to a manual crop.
The actual problem
Make old-photo scanning easy for one specific nontechnical person, while the archive credential never leaves the homelab.
That second half is what shapes everything. The homelab is deliberately off the public internet, so the scanning PC can’t reach it and the public server that can be reached must never hold a key to it. The VPS stages the batch; the homelab is the only thing allowed to write to the archive.
[Windows desktop] --upload--> [public intake (ScanDrop)] --forward--> [homelab ImportWorker] --> [Immich]
Approximate dates are a type
This is the decision that turns the project from a service-boundary exercise into a product.
A scanned photo carries the date it was scanned, which is almost always wrong, and
the person scanning usually knows only “summer, sometime in the seventies.” So the
shared contract has a DateHint: a kind (exact date, month, season, year,
decade, or unknown), a confidence, a display label, and a resolved sort date.
Approximate dates resolve to a midpoint instead of January 1. A year becomes the middle of that year, a season becomes the middle of its season, so a few hundred year-only photos spread across the timeline instead of stacking on a single day. Hints apply per photo, per date-group, or per batch, most specific winning.
And “I don’t know” is a real answer, not a validation failure. The photo keeps its scan date, goes to its album anyway, and also lands in a Needs Dating album to be revisited later.
One status vocabulary, two audiences
BatchStatus lives in the shared contract and covers both the wire protocol and
the importer’s own ledger states, so the desktop’s status poll and the importer’s
SQLite row are the same fourteen-value enum.
The desktop never shows them. The intake service reduces them to six phrases, from uploading through importing to imported or needs help, and a retryable failure keeps reading as importing through the first two attempts. It only becomes Delayed, retrying at the third, where the backoff stops being seconds.
A transient failure isn’t the user’s problem until it stops being transient.
How an interrupted import resumes
The ledger is forward-only. It records completed external effects: the Immich asset ID for each file, the created album ID for each batch, and each album share and Needs Dating placement that succeeded. A retry moves forward from those facts. There is no rollback and no auto-delete. A photo that already landed stays landed, and the retry just finishes what’s missing. That’s the shape that fits an external API which can’t join a database transaction.
Identity is derived from content. Every upload carries a device asset ID derived
from the file’s SHA-256, so the same file always presents the same identity, and the
client treats a duplicate response as success rather than an error. That covers the
one window the ledger can’t: a crash after the archive has accepted the upload but
before SQLite records the returned asset ID.
Concurrency is capped at one batch, on purpose. That caps throughput, and it’s what makes restart recovery unambiguous: nothing is legitimately in flight at startup, so any batch still marked Claimed or ImportingToImmich is a crash remnant. The worker requeues it and re-drives it from the ledger.
Verified so far: a forced mid-import crash against the development Immich, plus unit coverage over the ledger’s skip rules. Replaying a device asset ID against a real server is not something I’ve tested.
The rest of how it’s built
- Three runtime processes and one shared assembly. A desktop scanner (Avalonia
- NAPS2) for capture, date hints, album selection, and crop/deskew; a
public-facing ScanDrop intake (ASP.NET Core) that authenticates clients,
validates, and stages uploads; an ImportWorker (.NET Worker + EF Core) running
next to Immich; and
Scanta.Contracts, one shared assembly of DTOs and enums so “compiles” means “will integrate.”
- NAPS2) for capture, date hints, album selection, and crop/deskew; a
public-facing ScanDrop intake (ASP.NET Core) that authenticates clients,
validates, and stages uploads; an ImportWorker (.NET Worker + EF Core) running
next to Immich; and
- Auth at the seams. Desktop clients hold opaque, scoped, peppered-hash tokens. The admin UI is gated by OIDC against a self-hosted identity provider. The ScanDrop → ImportWorker hop requires a shared service-token bearer on every endpoint. The homelab is reachable only over a private overlay network, so the network controls who can reach the port and the token controls who gets in.
- Tested against the contract. xUnit plus
WebApplicationFactoryHTTP and contract tests that POST the canonical fixtures embedded inScanta.Contractsverbatim. The ImportWorker ships the real Immich client by default and fails closed on a missing key; the in-memory fake is opt-in for dev and CI.