Scanta
Working private toolA self-hosted photo-scanning pipeline that resumes interrupted imports from a ledger without re-uploading completed files.
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.Contractsfixtures 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.