A local-first Windows photo & video library manager — an Apple-Photos-style catalog that never leaves your PC. It indexes your existing folders without touching the originals, backs up your iPhone over USB, understands where and which way every photo was taken, and exposes the whole library to scripts and AI tools (Claude, GPT/Codex, Antigravity) through a safe local API.
Version 1.5.0 · released 2026-10-04 · Changelog · Release history · Feature index · machine-readable project-manifest.json
Download — the installer (
PhotoAlbum-Setup-<version>.msi, ~110 MB, SHA-256 alongside) is attached to every GitHub Release. It is deliberately not stored in the git tree.
- Your photos stay where they are. PhotoAlbum builds a searchable SQLite catalog next to your folders; originals are never moved, renamed or rewritten.
- Reads the metadata straight from your original photos and videos — capture date, camera, lens, exposure, GPS position, altitude, speed, GPS accuracy and the direction the camera was facing.
- Knows where — and which way. GPS → country / region / city / town, offline and free, plus a live compass for the camera direction.
- Albums, three ways: create them by hand, add a multi-selection, or turn any filter result into one album — or one album per year / month / day / country / region / city — with a live preview.
- Powerful filters: text, photo/video, rating, favourites, tags (any or all), people, places (down to city), years, date ranges, events, albums — and, through the API, GPS boxes and camera-direction ranges.
- Takes your metadata with you. One click exports the metadata of all photos, any filter result or any album to
photoalbum-metadata.json— every file name linked to everything above, incl. country / city and camera direction (JSON-Schema'd) — and can copy the photos to another folder or drive by date, place or album. - iPhone backup over USB — journaled, resumable, BLAKE3-verified, duplicate-skipping, with true capture dates (no iCloud, no iTunes sync).
- Ready for AI tools. A loopback REST API (80+ documented endpoints) and a zero-dependency MCP server let Claude, GPT/Codex or Antigravity find, see, tag and organise photos — write tools default to preview.
- Private by design. No accounts, no cloud, no telemetry. The API listens on
127.0.0.1only and needs a per-install token.
| What PhotoAlbum does | Walk-through | |
|---|---|---|
| 1 · Import | Add any folder or drive (JPG, HEIC, PNG, TIFF, WebP, GIF, DNG, MOV, MP4, AVI, MKV…), optionally tagging the whole import; or back up an iPhone over USB. Duplicates are skipped by content hash. | Getting photos in |
| 2 · Read the originals' metadata | Capture date, size, video duration, camera make / model / lens, ISO, aperture, shutter, focal length, GPS position, altitude, speed, accuracy and camera direction — read from the files, never written back. | Metadata |
| 3 · Place & direction | Country, region, city and town from the GPS position (offline); camera heading with true/magnetic reference and a compass; maps. | Geography |
| 4 · Label | Tags, people, named places, events, ratings, favourites and notes — one photo or a whole selection at a time. | Organising |
| 5 · Find | Live search and filters you can combine, sort, and page through; every result can become an album or an export. | Filters |
| 6 · Albums | Create albums, add photos by selection or from a filter, or auto-create albums split by year / month / day / country / region / city. | Albums |
| 7 · Export | Metadata JSON for all photos, a filter result or an album (with country / city / camera direction), plus copy-out of the photos by date, place or album. | Export |
| 8 · Automate | Everything above from Claude, GPT/Codex, Antigravity or your own scripts. | Agent guide |
Worked examples that combine these steps (e.g. "one album per city for everything shot in Estonia in July, then export the metadata"): Recipes. There is no "import an album" action — albums are created inside PhotoAlbum; to turn folders you already have into albums, import them with a tag and batch-album by that tag (details).
Quick start · What you can do · Overview · Architecture · Feature index · Features · Agent & automation · Release history · Database · Tech stack · Building · Project structure · Data, privacy & security · HEIC · Troubleshooting · Roadmap · Contributing · License
| OS | Windows 10 or 11, 64-bit. The installer is self-contained — no .NET, Rust or Visual C++ install needed. |
| Disk | ≈ 0.5 GB for the app (it bundles .NET, WPF and the libvlc video engine) plus space for the catalog and thumbnails. |
| iPhone photos (HEIC) | Microsoft's free HEIF Image Extensions (+ HEVC Video Extensions for videos) from the Microsoft Store. The app shows a banner with the Store link if they are missing — see HEIC / HEIF support. |
| iPhone backup | Apple's USB driver (comes with Apple Devices / iTunes). The Phone view detects a missing driver and offers a one-click install. |
| AI tools (optional) | The REST API is built into the app. The MCP server is a single Node ≥ 18 script, tools/mcp-server/photoalbum-mcp.mjs, that lives in this repository (it is not part of the MSI) — clone the repo or download that one file. |
- Download
PhotoAlbum-Setup-<version>.msifrom the latest release and double-click it (approve the administrator prompt). The installer is not code-signed, so Windows may say Unknown publisher; you can confirm the download first withGet-FileHash .\PhotoAlbum-Setup-<version>.msi -Algorithm SHA256and compare it with the.sha256file on the release page. - It installs per-machine to
C:\Program Files\Photo Albumand adds Start-menu and desktop shortcuts. Run the MSI again at any time for Change / Repair / Remove; installing a newer version upgrades in place. Silent install:msiexec /i PhotoAlbum-Setup-<version>.msi /qn. - Uninstalling removes the program but keeps your library (
%LOCALAPPDATA%\PhotoAlbum, see where your data lives).
Prefer to build it yourself? See Building and BUILDING.md.
- Import — Library → Import Folder (optionally tag everything as it comes in), or plug in an iPhone and open the Phone view.
- Let it work — previews, camera direction and place names fill in on their own; a status strip at the bottom of the Library shows progress.
- Organise — filter, then Add results to album… (one album, or split by date/place) or Export… (metadata JSON and/or copy the photos).
- Automate — point your AI tool at the MCP server: docs/agent-api.md.
Everything is documented under docs/ (generated index).
PhotoAlbum is a local-first, privacy-preserving photo and video library manager. All data stays on your machine — no cloud accounts, no telemetry, no subscriptions. It indexes your existing folder structures non-destructively, leaving originals in place while building a rich searchable catalog backed by SQLite.
How it works. You point it at folders (or plug in an iPhone). A Rust core hashes and scans the files, the app reads each file's metadata (date, camera, GPS, heading) and stores it in the catalog, and background jobs add thumbnails and place names. Everything you do afterwards — filter, tag, build albums, export — works on the catalog; your original files are only ever read, or copied when you ask for a copy.
What it is not. Not a cloud service or sync tool, not a photo editor, not cross-platform (Windows 10/11 only), and not (yet) a face-recognition app — see the Roadmap.
PhotoAlbum is a layered solution with a Rust native core. Full diagrams (components, import pipeline, agent flow, delivery pipeline): docs/architecture.md.
flowchart LR
UI["WPF app<br/>views · view-models · services · background jobs"] --> CORE["Core<br/>domain · contracts"]
UI --> DATA["Data<br/>SQLite · migrations 001-008"]
UI -- P/Invoke --> RUST["Rust core<br/>BLAKE3 · scan · thumbs · copy · dHash"]
API["Local REST API<br/>127.0.0.1:5150 · token · OpenAPI"] --> CORE
UI -. registers services .-> API
MCP["MCP server (Node)"] -- HTTP --> API
AI["Claude · GPT/Codex · Antigravity"] -- MCP / HTTP --> MCP
| Layer | Purpose | Key dependencies |
|---|---|---|
PhotoAlbum.Core |
Domain types, repository interfaces, service contracts shared with the API (batch albums, export, copy, bulk edit, file access, geocoding) | None |
PhotoAlbum.Data |
SQLite persistence, versioned migrations, Dapper repositories | Microsoft.Data.Sqlite, Dapper |
PhotoAlbum.App |
WPF UI, ViewModels (CommunityToolkit.Mvvm), App Services | ModernWpfUI, LibVLCSharp.WPF |
PhotoAlbum.Api |
Local REST API on 127.0.0.1:5150 — token auth, vault guard, OpenAPI | ASP.NET Core minimal API |
photoalbum_core.dll |
BLAKE3 hashing, directory scanning, thumbnail generation, copy verification, perceptual hashing | Rust crates: blake3, walkdir, image |
All Rust functions are exported with #[no_mangle] pub extern "C" fn pa_* and consumed from C# via [LibraryImport("photoalbum_core")] source-generated P/Invoke. The FFI contract is defined in rust/photoalbum_core/crates/ffi/src/lib.rs and wrapped by C# interfaces in PhotoAlbum.Core/Interfaces/.
C# (LibraryImport, Infrastructure/RustInterop.cs) ──► photoalbum_core.dll (cdylib)
IRustHasher ──► pa_hash_file · pa_verify_file (BLAKE3)
IRustScanner ──► pa_scan_directory
IRustThumbnailer ──► pa_generate_thumbnail
IRustCopyEngine ──► pa_copy_file_verified
IRustPHasher ──► pa_phash_file (perceptual dHash)
pa_health_check (start-up self-test)
The capabilities of the app, grouped by what you want to do (newest additions carry their release number). The narrative version, with where-to-click and API equivalents, is the Feature guide; changes per release are in the Release history.
| Feature | What it does | Since | Docs / API |
|---|---|---|---|
| Import folders without touching the originals | Import any folder or drive recursively (JPG, HEIC, PNG, TIFF, WebP, GIF, BMP, DNG; MOV, MP4, M4V, AVI, MKV, WMV, 3GP). Optionally tag the whole import as it arrives. Originals are only read; each file is identified by its BLAKE3 hash so duplicates are skipped, and re-importing a folder adds only the new files. | 1.0.0 | docs |
| Video as first-class library items | In-app playback (no codec packs needed); capture date, duration and Apple GPS location are read from the video file, so videos take part in every filter, album and export. | 1.0.0 | docs |
| iPhone backup over USB | Back up selected photos or the whole camera roll to a folder: journaled and resumable, every file BLAKE3-verified, duplicates skipped, true capture dates kept, with a report, ledger and CSV manifest. | 1.0.0 | docs |
| Backup ledger, report and CSV manifest | Every backup writes an append-only ledger, a human-readable BACKUP-REPORT.txt with session history, and a per-item CSV manifest into the destination. | 1.1.0 | docs |
| Live backup tracking, pause / resume and retry | Watch a backup progress tile by tile with ETA, pause and resume it, overwrite or re-run it, and retry only the failures from a results bar. | 1.1.0 | docs |
| Feature | What it does | Since | Docs / API |
|---|---|---|---|
| Metadata read from the original photos and videos | At import the capture date, pixel size, video duration, camera, lens, exposure, GPS position and (since 1.5.0) altitude, speed, accuracy and camera direction are read from the files themselves — never written back — and stored in the catalog for display, filtering, sorting and export. | 1.0.0 | docs |
| True capture dates on phone backups | Backed-up files carry the date the photo was taken (not the transfer date), and a re-stamp job fixes files backed up earlier. | 1.1.0 (validated) | docs · POST /phone/restamp |
| Camera and exposure details from the originals | Camera make, model, lens, ISO, aperture, shutter speed and focal length are read from each photo's EXIF, stored, shown in the Detail view, sortable and included in the export. | 1.2.0 (validated) | docs |
| Country, region, city and town from GPS (offline) | Every photo with GPS is matched against a bundled GeoNames dataset — free, offline, on by default — and gets a country, region, city and town you can see, filter on, split albums by, and export. | 1.3.0 (validated) | docs · POST /geo/resolve |
| Camera direction, altitude, speed and GPS accuracy | The compass heading the camera faced (true or magnetic north), altitude, speed and GPS accuracy are read from the photos, stored, shown with a live compass, filterable by heading range and exported. | 1.5.0 | docs · GET /media?facingFrom=&facingTo= |
| Feature | What it does | Since | Docs / API |
|---|---|---|---|
| Albums | Create, rename, hide and delete albums; fill them from a multi-selection, the Detail view, or every photo a filter matches. Albums can also be created by scripts and AI tools. | 1.0.0 | docs · POST /albums, POST /albums/batch |
| Duplicate detection | Finds byte-identical copies (BLAKE3) and look-alikes (perceptual dHash) and lets you keep left, right or both. | 1.0.0 | |
| Search and filters | Live text search plus filters for photo/video, rating, favourites, tags (any or all), people, places incl. country/region/city, years, date ranges, events and albums; the API adds GPS-box and camera-direction filters. Sort by date, rating, name, camera, ISO or import time. Every result can become an album or an export. | 1.0.0 | docs · GET /media |
| Tags, people, places, events, ratings and notes | Label photos one at a time in the Detail view or in bulk from the multi-select bar (tags, people, named places and events, rating, favourite, hide); labels are searchable, filterable, albumable and exported. | 1.0.0 | docs · POST /media/bulk |
| Batch albums: one album, or one per year / month / day / country / region / city | Add everything the current filters match to a new or existing album, or split it into one album per year, month, day, country, region or city, with a live preview of what will be created. | 1.4.0 (validated) | docs · POST /albums/batch |
| Thumbnails for HEIC and video, with visible progress | iPhone HEIC photos and videos get thumbnails in the background through Windows' own codecs, with a progress strip, per-tile status and Retry for failures. | 1.4.0 (validated) | docs |
| Paged library | A status-bar pager (page box, first/prev/next/last, per-page size) and a Next-page button keep very large libraries fast. | 1.5.0 | docs |
| Feature | What it does | Since | Docs / API |
|---|---|---|---|
| Export metadata for all photos, a filter result or an album — and copy photos out | Writes photoalbum-metadata.json — every file name linked to its capture date, camera, GPS (latitude, longitude, altitude, camera direction), country / region / city / town, tags, people, albums, rating and notes — into the photos' root folder, with a published JSON Schema for other projects. Can also copy the photos to another folder or drive: flat, or arranged by date, place or album. | 1.5.0 | docs · POST /export/metadata, POST /export/copy |
| Feature | What it does | Since | Docs / API |
|---|---|---|---|
| Local REST API with OpenAPI | While the app runs it serves a loopback-only REST API (127.0.0.1:5150) with Swagger/OpenAPI documentation. | 1.0.0 | docs · http://127.0.0.1:5150/api/v1 |
| Per-install token, diagnostics and the first MCP server | API calls need a random per-install token; backup diagnostics return a root-cause verdict; a zero-dependency MCP server exposes them to AI tools. | 1.1.0 | docs |
| Import a folder through the API | An AI tool or script can import a folder into the catalog and follow the job's progress. | 1.2.0 | POST /library/import |
| AI tools can search, see, tag, organise and export | Find photos by tag/person/album name, country/region/city, dates, GPS box and camera direction; fetch originals and JPEG previews (HEIC converted); bulk tag/people/place/album/rating edits; batch albums; copy-out; jobs; /capabilities discovery. Write tools default to preview. | 1.5.0 | docs · GET /capabilities, POST /media/bulk |
| Feature | What it does | Since | Docs / API |
|---|---|---|---|
| Folder clone with BLAKE3 verification | Copy a folder to another drive with every file verified after copying; cancellable and designed to resume. | 1.0.0 | |
| Safe delete with restore | Trash is restorable, the hidden vault keeps photos and albums out of sight (and out of the API) while locked, permanent delete is only possible from Trash with confirmation. | 1.0.0 | |
| API self-check, vault guard and error logging | The app probes its own API at start-up and logs the verdict; hidden-vault photos are invisible to every API call while locked; unhandled API exceptions are logged and returned as JSON instead of an empty 500. | 1.5.0 | docs |
| HEIC codec prompt | When HEIC previews fail for every file, a banner explains that Microsoft's HEIF Image Extension is missing and links to the Store; Retry re-runs after installing. | 1.5.0 | docs |
| Feature | What it does | Since | Docs / API |
|---|---|---|---|
| Propagation-check harness | Proves a code change reached the test app, installer, portable build, repo staging and documentation. | 1.1.0 | docs |
| Machine-readable project manifest | Version, features, API, compatibility, docs. | 1.2.0 | docs |
| Release and documentation harness | tools/release.py bumps the version everywhere, records features and releases, regenerates the README feature index / release history / API table and docs/api-reference.md, verifies consistency (also in CI) and stages the repo for GitHub. | 1.5.0 | docs |
Detailed notes per area. For the end-to-end tour (import → metadata → place & direction → filters → albums → export, with recipes) read the Feature guide.
- Non-destructive indexing — scans existing folders, never moves or modifies originals
- BLAKE3 content hashing — each file is uniquely identified by its content hash, not its path; duplicate imports are detected and skipped automatically
- Multi-folder library — import from multiple source folders; each is tracked as an indexed location with file count and total size
- Batch import tagging — when importing a folder, optionally apply one or more tags to all imported photos at once (existing tags or new custom tags)
- Incremental re-index — re-scanning a folder only processes new files
All filters are live — results update immediately with no submit button, and results are paged (see Paging).
| Filter | Description |
|---|---|
| Full-text search | Searches filename and notes fields |
| Media type / rating / favorites | All · Photos · Videos; minimum stars; favorites only |
| Filter menu | People · Places (incl. reverse-geocoded countries/regions/cities) · Years · Events · Albums · Tags — pick several; each becomes a removable chip |
| Sort | Date (newest/oldest) · Rating · Name · Camera · ISO · Recently added |
| Clear | One button resets everything |
Every filter result can be turned into an album or exported — see Batch albums and Export.
- Full-resolution image viewer with smooth scaling; video playback via LibVLCSharp with WMF/system-player fallbacks
- Keyboard navigation: ← → between photos, Backspace to return, Ctrl+F favorite, Delete to trash
- Star rating, inline notes, per-photo tags and people
- Metadata panel with readable, theme-aware text: date · type · dimensions · camera (make/model/lens) · exposure (ISO · ƒ · shutter · focal length) · location (place name, coordinates, camera direction with a live compass, altitude, speed, GPS accuracy, Show on Map)
- Create named albums, add photos by double-click or drag
- Album detail view with photo grid
- Smart albums (query-based, schema ready)
- Manual person tagging with region coordinates stored in
MediaPerson - Person cards with avatar support
- Agents can create people and tag photos through the API (
POST /people,POST /media/bulk) - Automatic face detection / clustering is not built yet — it is on the roadmap and will run fully on-device
- Named places with latitude/longitude/radius
- Assign photos to places manually
- Interactive map panel (split-pane layout): selecting a place renders an OpenStreetMap tile map in an embedded WebView2; no API key required
- GPS from EXIF: latitude, longitude, altitude, speed, accuracy and camera heading are extracted from image EXIF / QuickTime atoms at import time with MetadataExtractor (numeric GPS IFD tags, so HEIC works too); stored on
MediaItem - Offline reverse geocoding: GPS coordinates are automatically resolved to country / region / city / town and linked as named
Placerows — free, fully offline, private, on by default (bundled GeoNames dataset, CC-BY; no API key, no network). The place name shows in the Detail view LOCATION section; backfill existing items withPOST /api/v1/geo/resolve. Optional cloud providers (Google/Nominatim) can be added for street-level precision (opt-in). See docs/reverse-geocoding.md - Detail view location bar: if a photo has GPS coordinates, a "Show on Map" button appears in the metadata panel and opens Google Maps in the system browser
- "Open in Browser" button launches the selected place in Google Maps
Phones record which way the camera faced. PhotoAlbum reads GPSImgDirection (+ reference: true/magnetic north), altitude, speed and the device-reported
accuracy; stores them; shows them next to the other geography fields (a small compass arrow, "Facing 82° E · true north", "Altitude 56.5 m · Speed 2.3 km/h · GPS accuracy ±78 m");
lets you filter by direction (facingFrom/facingTo) via the API; and includes them in the JSON export. Photos imported before this existed are filled in by a
one-time background pass with a visible progress strip. Honest limits (compass accuracy, true vs magnetic north, photos without a heading): docs/camera-direction.md.
Large results are paged so memory stays flat and navigation stays quick. A compact pager sits in the status bar — ⏮ ‹ Page [2] of 9 › ⏭, the range ("151–300 of 1,241") and a per-page choice (100/150/300/500, remembered) — and a quiet Next page button ends the grid. It disappears when everything fits on one page; Ctrl+←/→ and typing a page number work too. Design notes: docs/ui-pagination.md.
Library → Add results to album… takes whatever the filters currently match and either adds it all to one new or existing album, or splits it into one album per
year / month / day / country / region / city (optionally with a name prefix). Narrow further inside the dialog: must have ALL tags, a date range or whole year, a country/region/city. A
live preview shows exactly which albums will be created and how many photos each gets before anything is written; same-name albums are reused and nothing is ever duplicated.
Also available to agents as POST /api/v1/albums/batch. See docs/batch-albums-and-previews.md.
Library → Export… (or an album's Export…): write photoalbum-metadata.json — every file name linked to all of its metadata (camera, exposure, GPS incl. direction,
place, tags, people, albums, rating, notes) — into the photos' common root folder so it travels with them, and/or copy the photos to another folder arranged flat, by date, by place or by album.
The JSON has a published schema (docs/schemas/photoalbum-metadata.schema.json) so other projects can use the library without the SQLite database.
Originals are never touched; hidden-vault photos are never exported while locked. Details: docs/metadata-export.md.
Thumbnails for iPhone HEIC and video are generated in the background through Windows' own codecs (the HEIC's embedded preview, ~17/second; the Shell thumbnail provider for video; the paired still for Live Photo clips). The Library's status strip shows live progress ("Generating previews… 312 of 1,241"), empty tiles say why they're empty, and Retry re-runs failures. HEIC needs Microsoft's HEIF Image Extensions — see HEIC / HEIF support.
- Named events with start/end date range
- Assign photos to events
- Toggle multi-select mode from the Library toolbar
- Ctrl+A selects all visible items; Escape exits multi-select
- Bulk edit: apply tags, rating, or notes to all selected items simultaneously
- Bulk delete: moves selected items to Trash
- Selection count shown in the accent-colored action bar
- Trash mode — soft delete; items are hidden from library but recoverable via the Trash view
- Vault mode — soft delete with a distinct mode flag (future encryption hook)
- Permanent delete — hard delete from Trash view only, with confirmation dialog
- Restore — restores trashed items to the library in one click
- Empty Trash — permanently deletes all trashed items with a single confirmation
- Source and destination folder selection via native folder picker
- Progress bar with per-file status and file count
- All files verified with BLAKE3 after copy; failed files reported
- Cancellable mid-operation via
CancellationTokenSource - Interrupted clones are designed to resume (idempotent copy engine)
- Dynamic Phone nav item appears when an iPhone is connected over USB (MTP/PTP), disappears on disconnect
- Enumerates the camera roll — both the classic
DCIMlayout and the iOS 17+ date-folder layout — with a live scanning overlay (item/GB counters, progress bar vs last-scan estimate) and a device storage gauge - Back up selected photos or everything to a user-chosen folder: journaled, resumable, per-file BLAKE3-verified, duplicate-skipping (per-device hash index), retry with backoff, and reconnect-wait on connection jitter — see docs/phone-backup-resilience.md
- Automatic connect-time diagnostics: Apple USB driver detection with one-click winget bootstrap from Apple's servers (license-compliant — never bundled), Apple service health with elevated start, guided unlock/Trust states — see docs/apple-driver-install.md and docs/phone-connectivity-remediation.md
- Honest iCloud semantics: totals labeled as library size; when "Optimize iPhone Storage" makes the library exceed device capacity the UI explains why, matching iMazing/CopyTrans documented behaviour — validation and DevOps grade in docs/phone-feature-validation.md
- iPhone albums cannot be mirrored (iOS does not expose them over any supported Windows interface) — documented limitation with mitigation in docs/iphone-backup-feasibility.md
- Battle-tested against a real 60k-item / 262 GB iPhone library — see docs/LESSONS-LEARNED.md for the full retrospective (5 root causes, what worked, reusable principles)
- Fully API-accessible:
GET /api/v1/phone/status,GET /api/v1/phone/media,POST /api/v1/phone/backup(job-based with live counters)
Connecting uses the cached inventory for an instant, backup-ready view and does not re-walk the device automatically. This is fast and safe, but it means the cached file paths can become stale after the phone is restarted or re-enumerated — press Refresh to re-index. Common backup errors and their fixes:
| Symptom (in the log / report) | Cause | Fix |
|---|---|---|
File \Internal Storage\…\IMG_xxxx.MOV not found on many items after a phone restart/reconnect |
iOS re-enumerates its PTP date-folders on restart, so their internal identifiers change. The cached inventory still holds the old paths, which no longer resolve. | Press Refresh to re-scan at the current paths, then Back Up All again. The 28k+ already-copied are skipped instantly; only the rest are re-attempted. |
0x8007001E — "cannot read from the specified device" on specific videos/photos |
Those originals are iCloud-optimized (not stored locally); MTP/PTP cannot trigger an iCloud download, so the read returns 0 bytes. | On the iPhone: Settings → Photos → Download and Keep Originals, wait for the download, then re-run. Failed items are never marked copied, so a re-run re-attempts only them. |
0x8007001E on every read, 0 copied, session reopening each attempt |
The phone's PTP responder has wedged after a long sustained transfer (lock/sleep, throttling, low battery, or iCloud sync). | Unplug, unlock, replug (Auto-Lock: Never, keep charging) to respawn the responder, then resume. |
| Grid loads but no thumbnails during a backup | By design — while a backup is running, thumbnails are served only from the on-disk cache, never from the device (fetching over MTP would contend for the transfer session and stall the backup). | None needed; thumbnails fill in from cache and after the run. |
Every backup writes a durable, append-only backup-ledger.jsonl plus a regenerated BACKUP-REPORT.txt (with a SESSION HISTORY of every pause/resume/retry) and backup-manifest.csv (per-item status, BLAKE3, attempts, duplicate-of, session) to the destination — see docs/phone-backup-resilience.md.
- Videos are first-class library items — every filter (type, tags, albums, people, places, events, years, rating) applies
- In-app playback with play/pause/seek/mute via the bundled libvlc — H.264/HEVC/ProRes decode with no Windows codec packs required; system-player fallback retained
- Phone view: iPhone-style
All / Photos / Videosfilter with counts, and per-video ▶ preview (temp download → in-app player, auto-cleaned) - Video telemetry captured on import: QuickTime creation time, duration, and Apple
ISO6709GPS (region/place) via MetadataExtractor — feeds Places, year filters, and the detail overlay like photos; backups preserve capture time on the copied file — see docs/video-support.md
- Tags have a name, optional color, and optional parent (hierarchical tag tree, schema ready)
- Tags are created globally and reused across photos
- Import-time batch tagging: assign tags at import, applied to every new file in the batch
- Per-photo tag management in the detail view
- Tag filtering in the library as a primary search tool
- Theme — Dark, Light, or System (follows Windows dark/light mode automatically); applies immediately without restart
- Metadata write-back — global toggle to enable/disable ExifTool write-back of ratings, tags, and notes to original files; off by default
- Library Locations — lists all indexed source folders with file count, total size (formatted B/KB/MB/GB), and last indexed date
- Local API — live status indicator (checks connectivity to 127.0.0.1:5150), base URL copy button, one-click Open Docs button (launches Swagger UI in the browser)
- About — version, build stack info
- All interactive controls carry
AutomationProperties.Name - Keyboard navigation throughout: Tab order, Enter/Space on list items, arrow key photo browsing
- Screen reader labels on all action buttons and form fields
- Color contrast meets WCAG AA for both Light and Dark themes
PhotoAlbum uses a two-tier duplicate detection strategy:
Tier 1 — Exact deduplication (BLAKE3) Every imported file is hashed byte-for-byte with BLAKE3. A file that is a literal byte-identical copy (e.g., copy-pasted from a backup) is detected and skipped at import time. No separate scan needed.
Tier 2 — Perceptual deduplication (dHash) Visual near-duplicates — re-exported JPEGs, photos resized or recompressed by another app, or the same scene shot twice — have different byte content but look identical to the eye. The perceptual hash pipeline catches these.
Algorithm (dHash):
- Resize image to 9×8 grayscale (Lanczos3 filter, executed in the Rust core via the
imagecrate) - Compare each pixel to its right neighbour across each row: 8 comparisons × 8 rows = 64 bits
- Store the 64-bit hash (
PHashcolumn onMediaItem) - At scan time, load all stored hashes and compute pairwise Hamming distance:
popcount(a XOR b) - Pairs with Hamming distance ≤ 10 (≥ ~84% similar) are flagged as possible duplicates
In C#, Hamming distance is computed with System.Numerics.BitOperations.PopCount(a ^ b).
The Possible Duplicates smart album (pinned in the Albums view) triggers the scan on demand. Each pair is shown side-by-side with a similarity percentage and three resolution options: Keep Left, Keep Right, or Keep Both (dismiss the pair without deleting either).
The PHashService fills missing hashes in background batches of 50 items before each duplicate scan, so freshly imported photos are automatically included.
BinaryManifestServicehashes 5 critical assemblies at startup (app exe, Rust DLL, Core, Data, Api)- Hashes stored in
BinaryManifestSQLite table - On mismatch (binary substitution), a warning is logged; non-fatal
- Run silently in background after DB init to avoid delaying startup
While the app is open it hosts a local REST API (http://127.0.0.1:5150/api/v1, loopback only) and ships a zero-dependency MCP server. An AI tool can read and see photos, find them
by tag / person / place / date / camera direction, tag people and places, build albums, and copy photos into other folders — with write tools defaulting to preview, the hidden vault enforced,
and a per-install token (never a static key). On start the app self-checks the API and logs the result. Setup for each tool, the filter format, safety rules and worked examples:
docs/agent-api.md.
{ "mcpServers": { "photoalbum": { "command": "node", "args": ["C:/path/to/PhotoAlbum/tools/mcp-server/photoalbum-mcp.mjs"] } } }The MCP server is one dependency-free script in this repository (tools/mcp-server/, not installed by the MSI) — point args at wherever you cloned or saved it. PhotoAlbum must be running; the script
reads the token from %LOCALAPPDATA%\PhotoAlbum\api-token.txt. Tools that speak plain HTTP can skip MCP and call the REST API directly with the X-PhotoAlbum-Token header.
| Area | Endpoints | Examples |
|---|---|---|
/albums |
9 | POST /albums/batch, GET /albums, GET /albums/{id:long} |
/capabilities |
1 | GET /capabilities |
/diag |
4 | GET /diag/backup, GET /diag/failures, GET /diag/analyze |
/events |
6 | GET /events, GET /events/{id:long}, POST /events |
/export |
2 | POST /export/metadata, POST /export/copy |
/geo |
1 | POST /geo/resolve |
/health |
1 | GET /health |
/hidden |
7 | GET /hidden/status, POST /hidden/unlock, POST /hidden/lock |
/jobs |
2 | GET /jobs/{jobId}, POST /jobs/{jobId}/cancel |
/library |
1 | POST /library/import |
/media |
24 | GET /media, GET /media/{id:long}, GET /media/{id:long}/file |
/people |
6 | GET /people, GET /people/{id:long}, POST /people |
/phone |
6 | GET /phone/status, GET /phone/media, POST /phone/backup |
/places |
6 | GET /places, GET /places/{id:long}, POST /places |
/tags |
6 | GET /tags, GET /tags/{id:long}, POST /tags |
82 endpoints · full reference with every summary: docs/api-reference.md · live OpenAPI at http://127.0.0.1:5150/swagger/v1/swagger.json while the app is running.
Credits / attribution — offline place names © GeoNames, CC-BY 4.0 (bundled at Data/geonames/).
- Camera direction & GPS detail — Camera heading (true/magnetic north), altitude, speed and GPS accuracy are read from the photos, stored, shown with a live compass in the LOCATION section, filterable, and exported; existing photos are back-filled in the background with visible progress. (docs)
- Library paging — A compact status-bar pager (page box, first/prev/next/last, per-page size, range text) plus a Next-page button at the end of the grid; hidden when everything fits on one page; Ctrl+arrow shortcuts. (docs)
- Metadata JSON export and copy-out — Export (Library or any album) writes photoalbum-metadata.json - every file name linked to all its metadata - into the photos' root folder, and can copy the photos to another folder flat / by date / by place / by album. (docs)
- Agent API v2 and MCP server v2 — Find photos by tag/person/album name, country/region/city, dates, GPS box and camera direction; fetch originals and JPEG previews (HEIC converted); bulk tag/people/place/album/rating edits; batch albums; copy-out; jobs; /capabilities discovery. Write tools default to preview. (docs)
- API self-check, vault guard and error logging — The app probes its own API at start-up and logs the verdict; hidden-vault photos are invisible to every API call while locked; unhandled API exceptions are logged and returned as JSON instead of an empty 500. (docs)
- HEIC codec prompt — When HEIC previews fail for every file, a banner explains that Microsoft's HEIF Image Extension is missing and links to the Store; Retry re-runs after installing. (docs)
- Release and documentation harness — tools/release.py bumps the version everywhere, records features and releases, regenerates the README feature index / release history / API table and docs/api-reference.md, verifies consistency (also in CI) and stages the repo for GitHub. (docs)
- Fixed: The Library had no way to reach the next page of photos (the load-more trigger was never wired up).
- Fixed: The HEIC codec banner existed but nothing ever triggered it (the documented start-up codec check was never implemented).
- Fixed: The MCP server crashed on exit with a libuv assertion (exit code 127) when the client disconnected.
- Fixed: The album page issued one database query per photo and decoded every thumbnail on the UI thread; it now batches and loads previews in the background.
- Fixed: API list/detail endpoints did not honour the hidden vault while it was locked.
- Standalone video clips Windows cannot decode (two in the reference library) show 'No preview available'; Live Photo clips fall back to their paired still.
- Compass heading accuracy is typically +/-10-20 degrees; see docs/camera-direction.md.
- Batch add-to-album — Add everything the current filters match to one album, or split into one album per year / month / day / country / region / city, with a live preview. (docs)
- Visible background previews — A progress bar and per-tile status show preview generation; Retry for failures. (docs)
- Fixed: Thumbnails never generated for iPhone HEIC photos and videos (Rust decoder can't read HEIC) — now generated through Windows codecs at ~17/second.
- Fixed: The local API returned HTTP 500 for every request (an unregistered service on /geo/resolve corrupted routing).
- Fixed: Detail-view metadata text was near-invisible on the Dark theme (no Foreground on the value style).
- Two 'Photo Album' entries during an upgrade are the normal MajorUpgrade sequence, not a defect.
- Fixed: An app installed to Program Files hung at start-up: the log folder wasn't writable. Logs now fall back to %LOCALAPPDATA%.
- Fixed: The installer is now the MSI itself (no Burn bootstrapper) so the Change/Repair/Remove menu appears.
Older releases
- Offline reverse geocoding — GPS coordinates resolve to country / region / city / town using a bundled GeoNames dataset — free, offline, on by default. (docs)
- Reverse-geocode backfill API — POST /geo/resolve resolves the existing library. (docs)
- Camera EXIF in the catalog — Make, model, lens, ISO, aperture, shutter and focal length are extracted, stored and sortable. (docs)
- Agent-triggerable import — POST /library/import (job-based).
- Machine-readable project manifest — project-manifest.json describes version, features, API and docs.
- Fixed: GPS was dropped on import.
- Fixed: A duplicate metadata panel mis-parsed rationals.
- Agent-operable local API — Per-install token, diagnostics endpoints, rules-based root-cause verdict, MCP server. (docs)
- Backup UX — Live tracking, pause/resume, ETA, overwrite mode, results bar. (docs)
- Multi-session audit — Append-only ledger, session history report, CSV manifest. (docs)
- Metadata re-stamp — POST /phone/restamp fixes timestamps on already-backed-up files. (docs)
- Fixed: Installer upgrades never fired (hard-coded product version).
- Fixed: Pulled files carried the transfer date instead of the capture date.
- Fixed: Connectivity and backup durability hardening.
- Library manager — Indexing, search, albums, people, places, events, tags, safe delete, clone/backup, duplicate detection, video, iPhone backup, local REST API on a Rust performance core.
Full details: CHANGELOG.md · machine-readable: release-history.json, project-features.json, project-manifest.json.
All data is stored in a single SQLite file at %LOCALAPPDATA%\PhotoAlbum\library.db.
| Table | Purpose |
|---|---|
MediaItem |
Core photo/video record: camera EXIF, GPS lat/lon, camera direction / altitude / speed / accuracy, thumbnail path, rating, notes |
MediaFileLocation |
Physical file paths, sizes, volume names; linked to IndexedFolder |
IndexedFolder |
Source folder registry with first/last indexed timestamps |
Album / AlbumMedia |
Albums and their photo membership |
Tag / MediaTag |
Tags and per-photo tag assignments |
Person / MediaPerson |
People and region-tagged assignments to photos |
Place / MediaPlace |
Named places and reverse-geocoded country/region/city/town |
Event / MediaEvent |
Named time-range events |
Metadata |
Key/value EXIF metadata store per photo |
ThumbnailCache |
Thumbnail file paths indexed by size |
OperationLog |
Audit trail of all create/edit/delete/tag operations |
UserSettings |
Key/value app preferences (theme, toggle states) |
BinaryManifest |
Content hashes of app binaries for integrity verification |
SchemaVersion |
Migration version tracking |
Migrations (001 initial · 002 indexed folders · 003 pHash · 004 GPS · 005 rotation · 006 EXIF · 007 place geography · 008 GPS detail) are versioned and run automatically on startup. WAL mode and foreign key enforcement are enabled on every connection.
| Component | Technology |
|---|---|
| UI Framework | WPF (.NET 10, net10.0-windows) |
| UI Controls | ModernWpfUI 0.9.6 (Fluent Design) |
| MVVM | CommunityToolkit.Mvvm 8.4.2 |
| Database | SQLite via Microsoft.Data.Sqlite 10.0.x + Dapper |
| Local API | ASP.NET Core minimal API + Swagger/OpenAPI |
| Video | LibVLCSharp.WPF 3.10.0 + VideoLAN.LibVLC.Windows |
| Logging | Serilog (file sink, rolling daily, 14-day retention) |
| Hosting | Microsoft.Extensions.Hosting (generic host, DI container) |
| Rust toolchain | Rust stable — MSVC (what CI uses) or GNU/MinGW (what build-release.cmd prefers when MSYS2 is installed) |
| Content hashing | BLAKE3 (Rust crate) |
| File scanning | walkdir (Rust crate) |
| Image processing | image crate (Rust) for thumbnail generation and perceptual hashing |
| Metadata | MetadataExtractor 2.9 (EXIF, QuickTime, GPS IFD, Apple maker notes) |
| HEIC / video previews | Windows Imaging Component + Shell thumbnail provider (Microsoft HEIF/HEVC extensions) |
| Reverse geocoding | Bundled GeoNames dataset (CC-BY 4.0), fully offline |
| AI integration | Local REST API + zero-dependency MCP server (Node ≥ 18) |
| Perceptual hashing | dHash (difference hash) implemented in dedup crate; Hamming via BitOperations.PopCount in C# |
Full guide: BUILDING.md. The short version:
| Tool | Needed for | Notes |
|---|---|---|
| Windows 10/11 x64 | everything | the app is WPF (net10.0-windows) |
| .NET 10 SDK | app + tests | |
| Rust stable | native core | MSVC toolchain works out of the box (CI builds with it). If MSYS2 is installed at C:\msys64, build-release.cmd puts its MinGW on PATH first, so the GNU toolchain works too. |
| Python 3.9+ | release harness (tools/release.py, tools/propagation-check.py) |
standard library only |
| WiX Toolset 5 | building the MSI only | dotnet tool install --global wix |
| Node.js ≥ 18 | running the MCP server only | no npm install needed |
# Full build (Rust + C#)
dotnet build src/PhotoAlbum.App/PhotoAlbum.App.csproj
# Skip Rust rebuild (faster, when Rust code unchanged)
dotnet build src/PhotoAlbum.App/PhotoAlbum.App.csproj -p:SkipRustBuild=true
# Run the tests
dotnet test PhotoAlbum.slnx -c Release
# Full self-contained publish to COMPILED/
dotnet publish src/PhotoAlbum.App/PhotoAlbum.App.csprojEvery successful dotnet build automatically syncs the compiled output to the COMPILED/ folder (self-contained win-x64 harness). The first dotnet publish bootstraps COMPILED/ with the full .NET runtime (~460 MB); subsequent builds only sync the changed assemblies.
.\COMPILED\PhotoAlbum.App.exeNo installation required — the folder is fully self-contained.
installer\build.ps1 builds the app and the MSI and copies it to dist\release\PhotoAlbum-Setup-<version>.msi (+ .sha256) — the file you attach to the GitHub Release.
Version, changelog, feature index, API docs and the repo staging folder are kept in step by tools/release.py — see Contributing and BUILDING.md.
src/PhotoAlbum.Core/
Domain/ MediaItem, Album, Tag, Person, Place, Event, IndexedFolder
Interfaces/ repositories, IRust*, IBatchAlbumService, IMetadataExporter, IPhotoCopier,
IBulkMediaService, IMediaFileProvider, IReverseGeocoder
DestinationGuard.cs output-folder safety rules
src/PhotoAlbum.Data/
Migrations/ 001_InitialSchema … 008_GpsDetail
Repositories/ MediaItemRepository (filters, batch queries, relations), Album/Tag/Person/Place repositories …
src/PhotoAlbum.App/
Views/ Library (pager, status strip) · Detail · Albums · People · Places · Events · Phone · Settings
BatchAlbumDialog · ExportResultsDialog · AppShell (HEIC banner)
ViewModels/ LibraryViewModel (paging, banner), DetailViewModel (geography incl. heading) …
Services/ IndexOrchestrator · ThumbnailManager + ThumbnailBackfillService + WindowsThumbnailer
GpsDetailBackfillService · ReverseGeocodeService + OfflineReverseGeocoder
BatchAlbumService · MetadataExporter · PhotoCopier · BulkMediaService · MediaFileProvider · ItemSelector
PhoneBackupService · MediaMetadataService · HiddenContentService …
src/PhotoAlbum.Api/
LocalApiHost.cs endpoints + token + vault-guard middleware AgentFilter.cs names→ids filter, ApiExtraServices
.github/workflows/build.yml CI: Rust + .NET build, tests, `release.py check`
docs/ user & engineering documentation (docs/README.md is a generated index)
installer/ build.ps1 + source/ (WiX v4: Package.wxs is the MSI)
Design/ UI design notes / assets
BUILDING.md · CONTRIBUTING.md · SECURITY.md · CHANGELOG.md · project-manifest.json
tools/
release.py release & documentation harness (bump | docs | check | stage | notes)
propagation-check.py proves a change reached app, installer, portable, repo staging and docs
mcp-server/ photoalbum-mcp.mjs (MCP v2) fetch-geonames.py rebuild the bundled place dataset
rust/photoalbum_core/crates/ ffi · hasher · scanner · thumbnailer · copy_engine · dedup
Nothing is written to your photo folders except what you explicitly ask for (an export's photoalbum-metadata.json, or copies of photos you export).
| What | Location |
|---|---|
| Catalog (SQLite, WAL mode) | %LOCALAPPDATA%\PhotoAlbum\library.db |
| Thumbnails | %LOCALAPPDATA%\PhotoAlbum\thumbnails\ |
| JPEG previews served to agents (converted from HEIC) | %LOCALAPPDATA%\PhotoAlbum\previews\ |
| iPhone inventory cache (instant reconnect) | %LOCALAPPDATA%\PhotoAlbum\phone-cache\<device>\ |
| API token (per install, random) | %LOCALAPPDATA%\PhotoAlbum\api-token.txt — delete the file to rotate it |
Logs (albumlog.md, crash-*.txt) |
<install folder>\log\, or %LOCALAPPDATA%\PhotoAlbum\log\ when the install folder is read-only (Program Files) |
| Backup report / ledger / manifest | the backup destination you chose (BACKUP-REPORT.txt, backup-ledger.jsonl, backup-manifest.csv) |
| Metadata export | photoalbum-metadata.json in the photos' common root folder (or the folder you pick) |
- The local REST API binds exclusively to
127.0.0.1— it is never accessible from the network - The API requires a per-install random token (
X-PhotoAlbum-Token, compared in constant time);/healthis the only open endpoint - The hidden vault is excluded from every list, file, preview, bulk, export and album call while locked
- Agent write tools default to preview; bulk edits refuse an empty filter; copy/export destinations are validated (no drive roots, Windows, Program Files, ProgramData)
- No cloud sync, no remote telemetry, no external connections — photos are never uploaded; any future face data would stay local
- SQLite database is unencrypted in v1 (stored in user's
%LOCALAPPDATA%) - ExifTool metadata write-back is opt-in and off by default
- HEIC codec installation is guided through the Microsoft Store; no third-party codec is bundled
Found a vulnerability? Please follow SECURITY.md.
HEIC files need Microsoft's HEIF Image Extensions (and, for iPhone photos, the HEVC Video Extensions) — free from the Microsoft Store. PhotoAlbum does not bundle a third-party codec.
When the background preview job finds HEIC files and none can be decoded, the app shows a banner across the top — "Your HEIC photos can't be previewed — Windows needs Microsoft's free HEIF Image Extension…" — with an Install HEIC Codec → button
that opens the Store page (ms-windows-store://pdp/?ProductId=9PMMSR1CGPWG). After installing, press Retry on the Library status strip. JPEG, PNG, TIFF, WebP and video work without any codec.
Earlier documentation described a start-up codec check; the banner existed but nothing triggered it. In 1.5.0 the trigger is the preview job itself (reactive, so it also catches a codec that is installed but broken).
| Symptom | What to do |
|---|---|
| Windows says Unknown publisher / SmartScreen warns when starting the MSI | The installer is not code-signed. Compare its SHA-256 with the .sha256 on the release page, then choose More info → Run anyway. |
| The app does not start, or starts and closes | Read the newest albumlog.md in <install folder>\log\ or %LOCALAPPDATA%\PhotoAlbum\log\ — a FATAL entry names the cause. Run the MSI again and choose Repair. |
| Thumbnails of iPhone photos are blank | Install Microsoft's HEIF Image Extensions (the banner links to the Store), then press Retry on the Library status strip. See HEIC / HEIF support. |
| A few video tiles say No preview available | Standalone HEVC clips that Windows cannot extract a frame from. Playback still works; Live Photo clips use their paired still. |
| Place names are empty | Reverse geocoding is on by default and runs at import; for photos imported earlier call POST /api/v1/geo/resolve. Photos without GPS (screenshots, edited copies) have no place. |
| Camera direction is empty | Only photos that carry a GPS heading have one — typically original iPhone/Android camera shots, not screenshots, downloads or edited exports. See docs/camera-direction.md. |
The MCP server or a script gets 401 / cannot connect |
Start PhotoAlbum first (the API lives inside the app). The MCP server reads the token from %LOCALAPPDATA%\PhotoAlbum\api-token.txt; Settings → Local API shows whether 127.0.0.1:5150 is up. |
| iPhone is not detected or backup stalls | See Troubleshooting phone backups and docs/phone-connectivity-remediation.md. |
| Something looks stale after an update | Run python tools/propagation-check.py (developers) — it proves source, test app, installer, portable build, repo staging and docs all carry the same change. |
Still stuck? Open an issue with the version (Settings → About) and the relevant albumlog.md lines. Please remove personal paths first.
Directions, not promises — shipped work is in the release history; the reasoning behind these lives in docs/roadmap-metadata-and-ai.md.
- People — on-device face detection + clustering (local inference only) feeding the existing People tags; today people are created and tagged by hand or through the agent API.
- Map view with a view cone — draw camera position and heading on the Places map.
- Street-level place names — an opt-in cloud geocoder behind the existing
IReverseGeocoderabstraction (the offline GeoNames default stays). - Agent repair actions — gated
repair / rescan / retry-failedendpoints and matching MCP tools for phone backups. - Video thumbnails for clips Windows cannot decode (LibVLC snapshot).
- Distribution — code-signed installer and a tagged-release GitHub Actions workflow.
This project is open source under the MIT License. Contributions are welcome.
- Fork the repository
- Create a feature branch:
git checkout -b feature/your-feature - Build and test locally using
COMPILED/PhotoAlbum.App.exe - Submit a pull request with a clear description of the change
Please keep pull requests focused — one feature or fix per PR. Accessibility labels (AutomationProperties.Name) are required on all new interactive controls. New Rust FFI functions must have a corresponding C# interface and P/Invoke wrapper.
python tools/release.py bump minor --title "What this release is about" `
--feature "Feature name::What it does::docs/its-doc.md" --fix "What was broken"
installer\build.ps1 # app + MSI -> dist\release\PhotoAlbum-Setup-<version>.msi (+ .sha256)
python tools/release.py check # version, changelog, features, README, API docs & docs index all agree
python tools/propagation-check.py # source -> test app -> installer -> portable -> repo staging -> docs
python tools/release.py stage # mirror into "GitHub Export/" (never copies .git, stale files or installers)
python tools/release.py notes # release notes for the GitHub Release (dist\release\RELEASE-NOTES-<version>.md)project-features.json, release-history.json and the API source are the sources of truth; the README Feature index, Release history, API table,
docs/api-reference.md and docs/README.md are generated — never edit the generated blocks by hand. CI runs release.py check, so a feature or endpoint that isn't documented fails the build.
git pushthe staged tree tomain(CI must be green).- Tag it —
git tag v<version> && git push origin v<version>. - Create the GitHub Release for that tag: paste
RELEASE-NOTES-<version>.mdand attachPhotoAlbum-Setup-<version>.msiand its.sha256(gh release create v<version> dist\release\PhotoAlbum-Setup-<version>.msi dist\release\PhotoAlbum-Setup-<version>.msi.sha256 --title "PhotoAlbum <version>" --notes-file dist\release\RELEASE-NOTES-<version>.md).
More detail: CONTRIBUTING.md.
MIT License — see LICENSE for details.