---
title: PhotoStructure v2026.8
url: https://photostructure.com/about/v2026.8/
description: PhotoStructure v2026.8 release notes
date: 2026-08-26
keywords: release-notes, release-notes-2026, release-notes-2026-q3, previews, tags, settings
---

## What’s in this release

Building on [v2026.4](/about/v2026.4/), this release is mostly about stability and quality, starting with the pictures themselves. PhotoStructure builds previews up to 4K now rather than capping them at 1920 pixels, lets you decide how much disk they’re allowed to use, and addresses each one by size so galleries stop paying a wasted round trip on every thumbnail. The same theme runs through the rest: good files are no longer rejected as corrupt, photos on an unplugged drive stay visible, sync uses less memory and pauses far less often, Linux and Docker can install the extra import tools for you, and licenses activate in your browser and renew themselves.

One thing to know before you upgrade: every library re-renders its preview images once, in the background, over as many syncs as it takes. Your originals are not touched and there is nothing to do, but on a large library it is a while of steady processor and disk work.

## ✨ Highlights

- **Better-looking photos on 4K and Retina screens.** However big your display, PhotoStructure sent your browser a preview no wider than 1920 pixels and let the browser scale it up. It now builds previews up to 4K by default and asks for the size your screen actually needs. RAW and HEIC photos you imported earlier get the better treatment as they’re re-synced.
- **You decide how much disk space previews use.** Settings has two new sliders — the largest size to keep for photos (4K by default) and for videos (1080p), anywhere from 480p up to 8K — plus a **Browsing previews** choice with five stops from **Minimum** to **Maximum** (**Balanced** by default) that trades library disk space against how sharp thumbnails look and how much your browser downloads while you’re looking around. Your originals are never touched; PhotoStructure rebuilds only the affected previews during the next sync.
- **Galleries load faster, especially for older photos and videos.** Thumbnails came in a fixed set of sizes, and anything smaller than the largest of them — standard-definition video, older phone photos — never had one at every size, so your browser kept asking for a size that wasn’t there and paying a wasted round trip on every single thumbnail. Each photo now carries the two numbers your browser needs to ask for a size that does exist, so the answer is one quick look at the disk. Galleries also stopped asking for bigger images than they display.
- **Every library rebuilds its previews once after you upgrade.** Those per-photo numbers are new, so PhotoStructure re-renders the preview images for everything it shows — all of it. This happens in the background across as many syncs as it takes and picks up where it left off if you restart. Your existing previews keep working until their replacements are ready, so browsing stays usable throughout, and a video keeps its playback file when that file is still the right size. On a large library this is several days of steady processor and disk work. Your originals are not touched, and there is nothing you need to do.
- **Long tag lists are easy to skim again.** With hundreds of keywords, the breadcrumb dropdown was one endless list. Children are now grouped — by letter for keywords, by decade for years — with headings that stick as you scroll and a jump rail down the side, like the address book on your phone. The list also opens at wherever you already are instead of at the top. Thanks to ukanuk for reporting this issue.
- **The settings page has a section index.** On wide screens a table of contents follows along and highlights the section you’re reading, and the save bar no longer covers the last setting. Phones keep the card grid. See the [forum thread](https://forum.photostructure.com/t/752).
- **Linux and Docker can install the extra import tools for you.** Videos and iPhone photos need a couple of free, open-source tools that PhotoStructure doesn’t ship. macOS and Windows have been able to fetch them for you since v2026.4; Linux was left to do it by hand, and Docker’s installer quietly did nothing on locked-down hosts — including every TrueNAS SCALE catalog app, which meant no video previews and no iPhone photo import at all. Both now use the same one-click flow, installing into your own files rather than the system ones, so no admin password is involved and the steps are identical on Ubuntu, Fedora, and other mainstream distributions. PhotoStructure still asks first and still shows the legal notice. Because the versions it asks for have moved up, everyone visits the tools page once after upgrading — your earlier answer is remembered, and declining is fine.
- **AVIF photos import.** PhotoStructure accepted AVIF files, failed to render them, and then recorded a perfectly good photo as corrupt. AVIF now goes through the same decoders as HEIC — the free codec tools on Linux, Docker, and Windows, and the built-in ones on macOS Ventura and later. Files rejected by earlier versions are reconsidered automatically after you upgrade.
- **Your license activates in a browser, and renews itself from then on.** Activation runs through your PhotoStructure account in a browser window, and the installation renews on its own schedule afterward instead of waiting for something to notice it had gone stale. When a renewal can’t complete, the license card in Settings says why and what to do: a billing problem links to your billing page and names when the next attempt runs, an installation that no longer matches its license offers to reactivate, and an install configured not to use the network is told that’s the reason. A license already issued keeps working offline until it expires.
- **Docker: a container started with no storage now says so.** Run without volumes, PhotoStructure fell back to the container’s own disposable layer, reported itself healthy, and threw away everything it imported on the next container recreate. Those containers are now held on a setup page pointing at the compose wizard.

## 📦 Other improvements

- **Slow searches no longer look like outages.** A large library can honestly take more than five seconds to answer a search. PhotoStructure used to give up, retry the same expensive query, and flash “Waiting on the server…” while it did. Search now shows its own progress and only reports trouble when there is some.
- **Folder pickers reopen where you left off.** On desktop, “choose a folder” used to drop you back in Downloads every time.
- **Long menus use the space that’s actually on screen** instead of scrolling inside a short box. Thanks to ukanuk for reporting this issue.
- **The Theme toggle stays put**, at the top of the menu’s footer. It used to sit between the Admin tools and the About group, so expanding Admin made it look like it had moved. Thanks to ukanuk for reporting this issue. [Discord discussion](https://discord.com/channels/818905168107012097/1523327140270182552/1523327140270182552)
- **The Sync page shows what’s holding sync back.** “Idle” covered a lot of ground: paused by you, paused on battery, paused because the machine slept, waiting on a busy CPU, or blocked by a failing health check. Each is now its own row. These used to share a single flag, which meant plugging in the charger could resume a sync you had deliberately stopped.
- **The sync report replaced the Live Feed.** Both showed the same rows from the same file, but the Live Feed started when you opened it and dropped rows on a fast import, so it had no history and no honest count. Its filter buttons now drive the report viewer — and the same filter applies to the file you download. One enormous folder no longer produces one enormous report, either: reports roll to a new file every 100,000 rows, which keeps the viewer quick and stays under the size ceiling on FAT and other older filesystems. Download links still cover the whole run.
- **Unhealthy drives are explained in English.** The Health page used to print raw data at you — `{"mountPoint":"/root/.gvfs","status":"inaccessible"}` — and leave you to work out what it meant.
- **Changing counts roll instead of jumping.** Library metrics and tag asset counts swapped one number for another with nothing to show which value had moved. Each digit now rolls to its new value.
- **The sync page’s thumbnail strip holds still.** Every arriving photo pushed the others sideways, so a thumbnail moved between aiming and clicking and the wrong photo opened. Thumbnails now dissolve in place, and the strip pauses while you’re pointing at it.
- **Installing the extra import tools on Windows is far smaller and faster.** It used to pull down a complete MSYS2 environment — nearly 2 GB and several minutes. It’s now about 420 MB and takes around twenty seconds. If you already have the old setup, PhotoStructure leaves it alone and keeps using it when it’s new enough.

## 🐛 Bug fixes

- **No more sync↔web port-collision crash-loops.** The sync and web processes now talk over a local socket — a Unix domain socket on macOS and Linux, a named pipe on Windows — instead of TCP loopback ports, removing the `EADDRINUSE` crash-loop that could strike, most visibly on macOS, when sync restarted before its old port was released.
- **Deleted photos now disappear from your library.** If you removed a file from a scanned folder, its photo could linger forever: the bookkeeping that marks a file as “seen this time around” reset on every run, so nothing ever looked stale. Thanks to tkohhh for reporting this issue.
- **New months and tags show up without a restart.** A tag created during sync — a new month, for instance — could stay invisible until you restarted PhotoStructure. Thanks to tkohhh for reporting this issue.
- **Thumbnails keep up with your edits.** Four separate faults left you looking at a stale picture. Adjusting a photo and saving over the original changed nothing on screen, because PhotoStructure compared the file’s size and shape — which an edit doesn’t change — and ignored the modification time it was already recording. Deleting an asset’s previews to force a fresh render did nothing, because nothing noticed they were gone. Any repair, re-tag, or rebuild pass changed every touched photo’s thumbnail address whether or not the picture had changed, throwing away a library’s worth of browser cache. And rotating a photo left the share link, clipboard copy, and sync-page thumbnails serving the pre-rotation image for up to a day, because those three built their own addresses without the version marker. Thanks to tkohhh for reporting these issues.
- **No more gibberish place names and titles.** Some Panasonic and Nikon cameras stash unrelated binary data in fields named `City`, `Country`, and `Title`, and PhotoStructure was reading it — producing Where tags like `?K` and `?GLG{H`. Those camera-specific fields are now skipped by default. Thanks to ukanuk for reporting this issue.
- **Accented characters are readable again.** Titles and descriptions written by pre-Unicode cameras — `Göteborg` and friends — used to arrive mangled; PhotoStructure now works out the original character set from evidence in the file. Separately, text arriving from files, logs, and helper programs was decoded one chunk at a time, so a character split across a chunk boundary became two replacement marks — `café` could arrive as `caf��`.
- **Video previews stopped failing on newer FFmpeg builds.** FFmpeg 7 rejects the color range PhotoStructure was handing it, which could fail preview generation for an entire library of videos. Thanks to jsmith for reporting this issue.
- **Good files stopped being called corrupt.** Whether a file was damaged was decided by pattern-matching what the decoder printed, so anything unrecognized counted as success: a JPEG that jpegtran couldn’t parse imported anyway, while a RAW that failed for reasons of PhotoStructure’s own — running out of memory, or a cancelled task — was blamed on your file. The decoder’s exit status and how much it actually wrote now decide, and only failures naming damaged input mark a file corrupt.
- **Sync no longer pauses on a healthy, hard-working computer.** Any sensor over 80 °C used to look like an emergency, but modern CPUs are designed to run at 90–95 °C under load — so importing photos could pause itself, repeatedly, on a machine that was perfectly fine. PhotoStructure now reads each sensor’s own manufacturer-declared limits, and only sensors it recognizes — processor, graphics, drives, and memory — can pause sync; a hot reading from an unlabeled mainboard pin is reported, not acted on. Drives are the exception in the other direction: firmware states when the drive gives up, not when a background sync should ease off, so PhotoStructure caps them at 52 °C for spinning disks and 70 °C for SSDs.
- **Sync stopped pausing over a disk your library isn’t on.** The 16 GB free-space floor meant for the library was applied to every volume PhotoStructure touched, including the one holding its disposable image cache — so a Mac with a roomy external library and a tight system drive paused sync repeatedly and filled the page with pause notices. Each volume is now judged by what it actually holds, and cache pressure prompts a cleanup instead of stopping work. Thanks to Mike and Gavin for reporting this issue.
- **Phantom “low disk space” warnings are gone.** A folder on a drive PhotoStructure deliberately doesn’t scan couldn’t be traced back to that drive at all, so its free space and health were reported as unknown — which the Health page showed as a problem.
- **Sync stopped getting stuck.** Four separate stalls: two byte-identical files could be adopted, evicted, and re-adopted forever — one pair accounted for 98.8% of a user’s sync activity for over twelve hours and blocked every other import; a dropped automounted share left PhotoStructure waiting on the operating system once per imported file, on files that had nothing to do with that drive; reading a spun-down or failing disk’s temperature froze the whole process until the drive answered, with Docker always on that path; and a worker steadily grinding through a 60-megapixel raw looked exactly like a wedged one, because its progress never reached the process watching it.
- **Photos on an unplugged drive stay visible.** If every copy of a photo lived on an external or network drive that wasn’t connected, sync hid the photo — even though a complete, current set of its previews was sitting in your library. A drive that leaves an empty mount directory behind could also make every file on it look deleted. A deletion now has to be provable: PhotoStructure asks whether the volume that would hold the file is actually present before concluding anything is gone.
- **Sync uses less memory while importing.** Computing a photo’s visual fingerprints pinned the image decoder’s memory inside the long-running sync process, where nothing could reclaim it, and reading a photo’s dimensions decoded the whole image to get them. Fingerprinting moved to the worker processes, which release that memory when they recycle, and dimensions are read from the header.
- **Files you stop excluding are imported again.** Once a file was rejected by an exclusion rule, removing that rule didn’t bring it back — the rejection was cached with nothing to clear it.
- **Some HEIC photos imported the wrong picture.** A HEIC file can hold more than one image, and PhotoStructure took whichever came first rather than the one the file names as its main photo — so a Live Photo or a burst could come in showing the wrong frame, and very large photos could import as a single corner tile.
- **Album descriptions survive import.** A description stored in one place was kept and the identical description stored in another was thrown away, because metadata was being judged before anything knew what it was for.
- **Photos with unusual edit history import again.** A file whose XMP history recorded a plain date where PhotoStructure writes structured data caused the entire file to be skipped. Older PhotoStructure sidecars and hand-edited ones are affected.
- **Similar-photo detection got its accuracy back.** Two separate faults pushed genuine matches below the threshold: a cryptographic checksum was being scored as if it were a visual fingerprint, and a photo whose fingerprint was only partly computed scored *higher* than one computed in full.
- **People with only a first name get tagged again.** Setting `tagNamesDefaultFamily` to blank has promised since 2020 that a one-name person would file under `Who/Madonna`, but the tag was quietly dropped instead — so everyone without a family name vanished from Who.
- **Idle drives can spin down again.** PhotoStructure polled your drives in the background to keep its picture of them current, which kept external and network disks awake. It now refreshes at sync boundaries instead.
- **System Activity shows every problem file, not just the first hundred.** The table fetched a fixed first page, so a library with thousands of skipped files could neither see nor retry the rest.
- **A failed startup shows you what went wrong.** PhotoStructure used to exit, leaving nothing to look at. It now stays up in recovery mode and serves the Health page.
- **Sync matches your computer workload instead of flapping.** Import concurrency was computed once from `cpuBusyPercent` and then held fixed, but tasks don’t cost the same: one video transcode can keep 2 or 3 CPU cores busy. A video-heavy queue oversubscribed the machine, tripped the safety limit, and paused and resumed the whole queue over and over. PhotoStructure now measures what it’s actually using and adjusts how many imports run at once — down quickly when it overshoots your target, back up one at a time when there’s room — so the safety limit is a backstop rather than the normal control. When something does stop sync, the log now names the check and the reason once, and records recovery when it clears. Worker processes also recycle more often, which trades a little startup cost for lower overall memory use.
- **One bad file no longer blocks its neighbors.** PhotoStructure reads a file’s siblings to work out what belongs together, and a truncated video next door threw out of ffprobe and failed the *good* file’s own metadata read. An unlistable parent directory did the same. Unreadable neighbors are skipped now.
- **Docker: `docker stop` and command-line arguments work when you set `PUID`.** Dropping to your user ran the app through a wrapper that swallowed everything after the command, so `docker run photostructure/server info` started with no arguments at all, `--version` printed the wrapper’s version, and `--debug` stopped the container from starting. The same wrapper never passed the stop signal along, so containers were killed rather than shut down. With `PS_LIBRARY_DIR` set, `docker exec` commands also looked for settings where the container had never written them, and disagreed with the running app.
- **Docker: your license identity survives a container recreate.** All four hardware identifiers Linux exposes are readable only by root, and the compose wizard always sets `PUID` — so every stock Docker install lost them the moment PhotoStructure dropped privileges, leaving the license anchored to generic strings that don’t tell one machine from another. Those identifiers are now handed across the privilege drop, the placeholder serials cloud hosts report are rejected, and a library folder owned by the wrong user is corrected instead of dead-ending setup. The compose wizard can also share your host’s `machine-id` with the container, which is the sturdiest identity a container can have.
- **macOS: photo access is requested before it’s needed**, rather than tripping over a permission prompt partway through a sync. Gallery scroll position is also restored in fullscreen again. See the [forum thread](https://forum.photostructure.com/t/apple-photos-database-import-sync/121/13).

## ⚠️ Breaking changes

- **Older Linux releases are no longer supported.** PhotoStructure now expects Debian 12+, Ubuntu 22.04/24.04/26.04 LTS, or Fedora 40+. Linux Mint 22+ is newly recognized. Alpine is no longer detected — the official Docker images have been Debian-based for some time.
- **Three advanced preview settings were replaced.** `previewResolutions`, `exactFitResolutions`, and `previewScaleThreshold` are gone. Set the maximum preview size with `photoPreviewFitInside` and `videoPreviewFitInside`, and how much disk to spend on browsing previews with `previewCacheProfile`. If you set any of the three retired settings, remove them; PhotoStructure won’t honor them. `transcodeMaxDim` and `PS_TRANSCODE_MAX_DIM` keep working as aliases for `videoPreviewFitInside`. `previewCacheProfile` picks the defaults for `maxThumbnailLongEdge` and `previewPixelWastePct`, and an explicit value for either one wins over the profile. The debug-only `placeholderThumbs` (`PS_PLACEHOLDER_THUMBS`) was removed; a stale value gets a targeted warning and is ignored.
- **`syncPort` and `syncEventsPort` no longer exist.** Sync and web talk over a local socket now, so there are no ports to configure. If either is still in your `settings.toml` or environment, PhotoStructure logs a targeted warning and ignores it.
- **`singleThreaded` (`PS_SINGLE_THREADED`) was removed.** It disabled the worker processes, which are the only way PhotoStructure can recover from image work that hangs or crashes — so the setting took that recovery away from exactly the constrained machines that need it most. To reduce load, lower `cpuBusyPercent` or set `maxConcurrentImports` to `1`. A stale value gets a targeted warning and is ignored.
- **Reaching a configured temperature max can now pause sync.** It used to warn only, and only sensors in `stopSyncSensorNames` can pause. `maxTempCelsius` is a *fallback* for sensors that declare no limit of their own, and its default moved from 80 °C to 95 °C. Drives get *ceilings* instead — `maxHddTempCelsius` (52 °C) and `maxSsdTempCelsius` (70 °C) — and stop at that temperature or their own declared max, whichever is lower. Sync resumes after cooling 7 °C below whichever limit applied. `warningCelsiusDelta`, how far below the stop boundary the warning band starts, defaults to 5 °C, down from 10 °C. If you raised the old setting only to silence warnings, re-check it. The `criticalTempCelsius` and `warnTempCelsius` aliases are gone.
- **`writeVolumeUuidMountpointBlocklist` was split in two.** It conflated “this kind of filesystem can’t hold a durable ID” with “don’t write under this path”. Filesystem types now go in `writeVolumeUuidFilesystemBlocklist` and paths in `writeVolumeUuidPathBlocklist`. Any existing value is sorted into the two new settings on first start and the old key is removed from your settings file — no action needed unless you want to adjust the result.
- **A blank setting now means “not set” rather than “set to nothing”.** Whether a blank counted as a real value used to depend on which parser the setting happened to use, which meant `previewsDir=""` resolved to an empty path instead of its default, and `excludedMountpointPaths=""` threw away the list that keeps `/proc`, `/sys`, and `/dev` out of your scans. Blanks now fall through to the default everywhere, except where emptying the value is the documented way to switch something off — the keyword delimiters, `rejectRating`, `tagNamesDefaultFamily`, and `powerShellCulture`. Setting `libraryDir` to an empty string is no longer honored: use `PS_RESET_LIBRARY_DIR` to forget a library. Config-directory aliases other than `PS_CONFIG_DIR` are now warned about rather than silently ignored.

* * *

## Detailed changelog

### ✨ New features

- **Preview resolution is now a literal fit box, and it’s yours to set.** `photoPreviewFitInside` (default `4096x2160`) and `videoPreviewFitInside` (default `1920x1080,1080x1920`) bound generated JPEG previews and MP4 playback derivatives; the original is fit inside every box without enlargement and the highest-resolution result wins. `videoPreviewFitInside` governs poster JPEGs and transcoded MP4s together, so they can’t silently diverge. Both run 480p through 8K in `/settings`, custom file values and `PS_*` overrides are preserved, and changing either flags affected assets for capacity-aware derivative refresh during the next sync. Originals are never modified, and browser-compatible originals are still streamed directly at native resolution.
- **`previewCacheProfile`** is the one control for how much disk PhotoStructure spends on browsing previews. Five stops — `xs` through `xl`, shown as **Minimum**, **Small**, **Balanced**, **Large**, **Maximum** — each setting the default for `maxThumbnailLongEdge` (480, 600, 720, 840, 960 px) and `previewPixelWastePct` (0, 90, 80, 70, 60). The default is `m` / **Balanced**, and an explicit value for either advanced setting overrides that part of the profile. Changing the profile rebuilds the preview cache in the background.
- **`previewPixelWastePct`** decides how many in-between sizes to prepare, by answering a question you can actually answer: how much of a preview may be larger than what the browser asked for? Enabled values are `40`–`95`; lower values prepare more sizes, using more disk to send less over the network, and `0` disables in-between sizes entirely. Each ladder’s rung count is computed once rather than retried until a ratio is satisfied — integer geometry can make that unsatisfiable, and the retry loop could hang preview generation outright on very wide sources.
- **Every preview is addressed by its long edge.** Preview files are now `fit-640.jpg` and `sq-640.jpg` — one number per size — each sized against an L×L box: `fit` fits inside it, `sq` crops to fill it, so the long edge lands exactly on L at any aspect ratio and `fit` stops cropping. Two integers per asset — the oriented dimensions of the source the previews actually render from, which for a RAW is its embedded JPEG rather than the sensor — ship with every gallery query, so the browser derives exactly the sizes the generator built instead of guessing from a library-wide list. Gallery thumbnails come from a dedicated `/img/:assetId/thumb/:reducer/:longEdge` route that resolves with a single filesystem check: no redirect, no metadata read, no basename lookup. The per-asset `info.json` sidecar that used to be read on that path is gone, replaced by columns on `Asset` and the filesystem itself. Released `fit-wN`/`sq-wN` sets keep working through one fallback until conversion reaches them. The tag gallery’s `fit` query parameter went away with the redirect, and Tag and Search galleries share one grid component instead of two copies that had drifted apart. That grid also advertises the tile width the layout actually uses rather than the widest a track can ever get: over-reporting made a DPR-2 display fetch 3.5× the pixels it needed, 560 times per page, and inverted the profiles — **Balanced** cost 2.7× more network than **Maximum**. The square ladder is capped at the source’s short edge, so a source smaller than the smallest rung still gets a thumbnail rather than none.
- **Upgrading libraries convert in the background.** A one-time operation flags every shown asset with no preview provenance recorded — on any library that has been in use, all of them — and a capacity-aware producer re-renders them a batch at a time alongside normal sync work. No queue of per-asset tasks is materialized up front, so stopping mid-conversion costs nothing: the flag is the work queue, and a restart resumes rather than starting over. Unconverted assets keep serving their released width-named previews through the fallback path for the whole window. The provenance columns (`previewFingerprint`, `renderWidth`, `renderHeight`, `previewSourceMtime`, `previewSourceSize`) land in a single structural migration, deliberately: the conversion is version-gated, so a column added after the gate had moved would never reach a library that already finished.
- **The viewer’s 1920 px request cap is gone.** It now asks for `min(innerWidth × devicePixelRatio, nativeWidth)`, and the default ladder reaches UHD4K; 5K and larger stay opt-in via `photoPreviewFitInside`.
- **`excludedExifTags`** lists metadata fields to ignore during import. ExifTool’s flat tag namespace defines `City`, `Country`, `State`, `Location`, and `Title` inside `Panasonic::Main` and `Nikon::LocationInfo` as `undef` binary blobs, and PhotoStructure was asking for them by unqualified name. Those group-qualified fields are excluded by default. If you use Panasonic’s on-camera travel mode, which writes real city and country names, remove the matching entries. Set it from the environment as a JSON array or with `¦` separators — `:` can’t be the delimiter here, since it’s part of the tag name.
- **`exifCharsetByCamera`** maps a camera `Make` (or `Make/Model`) to the character set to use when recovering EXIF text that isn’t valid UTF-8 — for example `{ "KODAK/DCS Pro 14nx" = "macintosh" }`. You rarely need it: PhotoStructure infers MacRoman versus Windows-1252 from the line-ending style embedded in the text, and only falls back to this map when a file offers no such evidence.
- **Temperature limits come from the hardware.** Sensors carry their driver-declared max, crit, and crit\_hyst limits (lm-sensors, hwmon, and thermal-zone trip points). The `system-temp` check treats each sensor’s own max as the stop-work boundary and keeps its hardware critical limit as a later emergency boundary, never synthesizing one from the other, latched with hysteresis so a temperature hovering at the limit can’t flap pause/resume. `warningCelsiusDelta` (default `5`) sets how many °C below that boundary the warning band starts, since Linux hwmon defines no separate warning threshold. Sensors declaring no max fall back to `maxTempCelsius` (default 95 °C, up from 80 °C). Drives are handled differently: firmware states when the drive itself gives up — an NVMe advertising 82.85 °C is not describing a temperature a library sync should hold it at — so `maxHddTempCelsius` (52 °C) and `maxSsdTempCelsius` (70 °C) are ceilings rather than fallbacks, and the lower of the ceiling and the drive’s own max applies. `drivetemp` binds to both media, so the drive’s own rotational flag picks which ceiling it gets and an unclassifiable drive is treated as spinning. Stop hysteresis is a fixed 7 °C. The same four-tier evaluation is exposed through the health check and `info --temps`. On desktop, Electron’s `powerMonitor` thermal state is forwarded too — warning at `serious`, pausing at `critical` — routed as a health check so thermal recovery can’t unpause a sync that suspend or on-battery had paused.
- **`stopSyncSensorNames`** is the allowlist of sensors permitted to pause sync. Its defaults cover dedicated CPU, GPU, storage, and memory drivers; unknown and generic mainboard sensors warn without pausing, because their wiring and thresholds are frequently wrong — a Super-I/O pin reporting a fictional critical temperature could otherwise pause sync forever. Add a glob to trust another sensor, use `*` to trust every sensor, or empty the list to disable temperature-based pauses entirely. Use `excludedSensorNames` to hide a sensor altogether.
- **Dropdown bucketing keys on each child’s sort name**, so flag-prefixed Where countries bucket by code instead of collapsing into one, and the scrub rail gets 44 px targets on touch. Purely presentational, and because every breadcrumb dropdown funnels through one component it landed for Keywords, When, Where, and Who at once.
- **Linux and Docker install media tools into a per-user Pixi prefix.** conda-forge packages are distro-independent and install without elevation, so one implementation covers Ubuntu, Fedora, and the rest — and the same property is what makes Docker work under `--security-opt no-new-privileges=true`, which every TrueNAS SCALE catalog app sets and which makes the kernel ignore `sudo`’s setuid bit. The prefix lives under the config dir (~700 MB, per-architecture) so it survives `docker pull` and a bind mount moved between a Raspberry Pi and an amd64 host doesn’t hand either the other’s binaries. musl hosts such as Alpine are declined rather than offered a button that can’t work: conda-forge declares `__glibc >= 2.17` on essentially every package, so the install fails after a multi-minute download. `installerPlatform()` is the single source of truth for what’s offered and what runs, so the two can’t drift.
- **Windows installs codec tools through Pixi too, replacing MSYS2.** Windows was the last platform with a mechanism of its own: an MSYS2 base install plus two full `pacman -Suy` rolling upgrades, 1,966 MB and several minutes, to land ffmpeg. The Pixi prefix does it in 421 MB and about 21 seconds, in `%USERPROFILE%\.pixi`. An existing `C:\msys64` is left exactly where it is; nothing is uninstalled, and its `mingw64\bin` stays on the search path behind the Pixi prefix, so an MSYS2 ffmpeg that already meets the release floor still satisfies the check. Two consequences worth knowing: `heif-thumbnailer` is no longer installed — conda-forge doesn’t ship libheif’s tools split — so a fresh Windows machine loses an optional embedded-thumbnail shortcut but not HEIF support, which goes through ffmpeg; and codec installs are offered only to the x64 build.
- **PhotoStructure-managed tool prefixes now outrank `PATH`.** Otherwise consenting to an install changes nothing — the distro’s older build stays selected. On Linux that means a child process may now get a different binary than before; on macOS an existing Homebrew prefix is elevated above `PATH` whether or not PhotoStructure installed anything. `toolPaths` still outranks everything and remains the escape hatch.
- **Installs verify their own work.** A package manager exiting `0` isn’t proof: it may have honored a pin, skipped an upgrade opt-out, or left a held package in place. Each installer re-probes the selected binary against the release floor afterward and fails if it still falls short. Windows gained the floor at both ends, so an install that would be a no-op is skipped instead of run.
- **The tool versions PhotoStructure installs are pinned, not “whatever shipped this week”.** Reading the digest from `/releases/latest` verified transport, not provenance — hash and bytes came from the same origin. Pixi is now pinned by digest, and ffmpeg gets a version bound: the oldest build the decode tests cover, up to the first unvalidated major. Keeping that bound separate from the acceptance floor is what gives an ffmpeg fix a delivery path — `ensureTools()` used to return early forever once any acceptable build was present.
- **`syncReportMaxLinesPerFile`** (default `100000`) caps how many rows a single sync report holds before rolling to a new file. Reports were previously closed only when a scan path finished, so one large folder produced a single unbounded JSONL — slow to open, and able to exceed the maximum file size on FAT and other older filesystems. Set it to `0` to disable the row cap. Rolled reports are stitched back together for download: the run travels as `run=<progressId>` rather than as a list of filenames, which a real 520-roll run turned into a 24 KB URL that Node rejected with a 431 before the handler ran.
- **`tagKeywordsFromVariations`** chooses whether keywords are combined from `all` variations of an asset (the default, preserving keywords found only in a RAW, JPEG, edited copy, or sidecar) or read only from the `primary` variation. `primary` narrows both keyword sources — file metadata and `--` pathname segments — and leaves camera, lens, geo, date, type, album, `fs|`, and face-region Who tags reading every variation. Changing it flags existing shown assets for a bounded, tag-only re-tag rather than requiring a re-import, and that pass deliberately does not re-decide visibility: `tagAndUpsertAsset_()` refreshes `rating`, which gates `isActive()`, so one settings flip would otherwise silently hide every asset whose stored rating had drifted past `rejectRating` since its last sync.
- **`writeVolumeUuidFilesystemBlocklist` and `writeVolumeUuidPathBlocklist`** replace the single conflated list, which had never been read. Wiring it up naively would have stranded the very volumes its own defaults name: sshfs and s3fs expose no filesystem UUID and no fsid, so a `.uuid` file is their only route to a durable identity. The built-in defaults therefore yield when a `.uuid` is the volume’s last resort; an explicitly configured blocklist is always honored, because a user directive outranks a cost-avoidance default.
- **Exceptional sync state moved into a disposable per-library database.** Files that repeatedly fail to import, and task rows that can’t be constructed at all, used to live in whole-file JSON snapshots and in an ever-growing SQL exclusion list that marched toward SQLite’s 32k-variable ceiling — at which point a fully-poisoned batch stopped refilling and the producer stalled. Both now use an indexed store under `cacheDir`, versioned rather than migrated because it holds nothing you can’t afford to lose. The sync process is its sole writer; the web UI reads and retries through RPC, which removes the race where web cleared its copy while sync rejected the retry from a stale map. If the store fails at runtime — a full or read-only cache volume is the likely cause — it degrades to bounded in-memory state rather than halting sync, the health check names the cause, and retry-all reports itself unavailable instead of quietly clearing your blocked list without scheduling anything.
- **Licensing moves to browser activation with a separate renewal credential.** The previous scheme treated a signed license plus the installation’s public system IDs as authority to mint the next one — so anything that could copy both could mint. Browser activation now issues an opaque bearer credential; the account stores only its digest, the installation stores the bearer in its persistent configuration, and offline leases are signed separately and bounded, so a lease already issued keeps working offline until its `exp` while revocation stops future renewals. Renewal runs on server-issued deadlines under restart-safe traffic limits, and each failure class gets a directed outcome: billing attention needed, reactivation required because the saved license no longer matches this installation, network access switched off, or a temporary failure with a named retry time. Renewal secrets — query strings, bearer data, response bodies, and PASETOs — are kept out of the logs while paths, status, timing, and stable failure codes remain. Signing keys rotate at the V2 boundary: the previous key stops signing on 2026-09-28 but stays published for verification, so licenses already issued under it keep validating.
- **Linux and Docker installs get host-backed identity evidence.** New identifier sources — the DMI `product_uuid`, `board_serial`, `product_serial`, and `chassis_serial` fields, plus `sn=` and `root=UUID=`/`root=PARTUUID=` from `/proc/cmdline` — join the vocabulary, and known or structurally obvious firmware placeholders are rejected before hashing (Azure’s constant chassis asset tag `7783-7084-3265-9085-8269-3286-77` among them: nine distinct digits and no ascending run, so no structural rule catches it). All four DMI files are `0400 root:root`, so the Docker entrypoint stages copies under `/run` before `setpriv` drops privileges, clearing a stale copy whenever its source disappears — `/run` survives `docker restart`, and identity from a previous host must not keep counting as current evidence. Inside a container, `/etc/machine-id` is still ignored; PhotoStructure reads only `/ps/host/machine-id`, the path the compose wizard bind-mounts read-only from the host, so a Node-to-Docker migration keeps its identity. A missing mount, or the empty directory Docker substitutes for a missing host file, contributes nothing rather than something unstable.
- **AVIF is decoded through the HEIF path.** `isAssetFileMimeType()` admitted AVIF, but nothing in `SharpReadable` matched it, so every one threw “Failed to render with any strategy” and the import validator recorded a good file as corrupt. It now routes through the HEIF decoders — `sips` on macOS, ffmpeg elsewhere — which also gates admission on `isHeifSupported()`, so a host that can’t render AVIF declines it instead of calling it corrupt. Rejections cached by an earlier version are only invalidated by an mtime or size change, so a one-shot Operation drops the rows for the extensions each version widens. AVIF originals are served directly to Chrome and Firefox rather than transcoded, with `Vary: User-Agent` on that response. macOS 12 is the one host this doesn’t reach: `sips` drives ImageIO, which learned AVIF in Ventura, and the health check warns rather than probing because `sips` has no capability query. That warning needs a version it can trust, so `macProductVersion()` reads `/usr/bin/sw_vers` by absolute path and returns undefined when that fails, rather than falling back to `os.release()` — the Darwin sequence, which every `>=` macOS gate would have silently accepted.
- **A storage-less Docker container is held on `/docker-setup`.** The library fell back to the container’s overlay filesystem and everything imported was discarded on the next recreate, while the health check called the container fine. The earlier gate was one URL deep — `POST /settings` wrote `settings.toml` onto the layer about to be thrown away, after which `routerForRunState()` never consulted the mount check again. The redirect is keyed on whether the library directory is actually usable, a fact no saved file can fabricate; main no longer starts sync into that storage, and no longer restarts web over it, which had been flapping the one process serving the page.
- **The sync report distinguishes “decoded and clean” from “never decoded”.** `whyInvalidFile()` returned `""` for both and `isValidFile()` treated either as valid. `validateFile()` now returns a three-state result, reported in a new, non-terminal `validated` row. Rejection is unchanged — only `invalid` fails a file, and unchecked stays importable, because video validation is off by default and rejecting it would exclude every video in a stock library.
- **`missingFileVolumeProofTtlMs`** (default `7s`) bounds how long a volume’s cached identity may be reused while proving a file was deleted. Raise it if volume metadata reads are slow on your setup.
- **Import concurrency adapts to measured CPU utilization.** `cpuBusyPercent` was a target that produced a fixed `maxConcurrentImports` up front, and the only reactive mechanism was the emergency gate, which pauses and resumes the entire queue — so a heterogeneous queue flapped `stop-sync` instead of settling. A captured `ffmpegThreads=1` transcode sustained 160–177% CPU, which one-task/one-CPU accounting cannot represent. `Promises` now integrates its running count over time and `TaskList` consumes that interval on the matching CPU-counter boundary; on each paired sample it drops the adaptive ceiling to `floor(averageRunning × target / observed)` when utilization is above target, raises it by one when utilization is more than 10 points below target and the interval actually reached the ceiling with work still queued, and holds otherwise. After a decrease it waits out a full interval governed by the new ceiling before reducing again. `maxConcurrentImports` remains a hard ceiling the controller never exceeds, and running work is never aborted. Pairing the interval measurement with the interval’s *average* running count rather than the instantaneous count at its end is what keeps the controller from under-scheduling. Per-task CPU weights were considered and rejected: the weights are estimates tied to current codecs and hardware.
- **`maxTasksPerProcess` drops from 500 to 256.** Worker processes and threads recycle after this many requests, and recycling is what releases native decoder memory back to the OS. The smaller default trades a little startup cost for lower memory pressure during long syncs.
- **Duration settings can declare bounds.** An operator-supplied value too small to be useful used to be accepted silently. Bounds are validated at every layer, and an out-of-range override falls through to the next-lower-precedence value instead of taking effect.

### 🐛 Bug fixes

- **RAW previews were planned from dimensions they never rendered at.** A RAW file reports its sensor size but renders from an embedded JPEG of a different size, so the plan named files that were never written. The build now resolves its render source first and plans from what it will actually produce. That also ends the cropping: naming a file by a planned width×height pair forced Sharp to reproduce that exact pair, which required `fit: "cover"` — and a Sony a7R V’s 9504×6336 embedded JPEG sits inside the 0.1 aspect-ratio tolerance for its 9728×6656 sensor, so `cover` shaved about 2.6% off every size.
- **A repair or re-tag no longer invalidates every thumbnail URL.** Thumbnail addresses carry `?v=<updateCount>` so they can be cached for a year, and `TimestampedModel.$beforeSave()` increments `updateCount` on any save. `Asset.markShownAndUpsert_()` guarded against that with a no-change comparison; `tagAndUpsertAsset_()` ended in an unconditional `upsert()` and did not. It moves only on real change now, which also fixes a primary-variation flip being silently dropped.
- **Every image URL is built by `AssetUrls`.** The share and clipboard-copy paths hand-assembled `/img/<id>/fit/2000` and the sync strip `/img/<id>/sq/60`, so neither carried `?v=<updateCount>` and `ImgRouter` could only promise `max-age=86400`. The viewer held no `AssetId` for the asset it was on, so `/api/asset/:id` publishes one, and a rotation’s `PUT` response now names the version it produced — previously only a full page reload taught the client the new `?v=`. `seedAssetId()` also stopped falling back to the `ApiAsset`, which `isAssetId()` rejects: a deep-linked asset seeded as `{capturedAtLocal: 0}`, and every later URL builder read that stub — no `?v=`, and the library-wide thumbnail ladder instead of the rungs that asset has.
- **Open Graph video URLs carry the asset version.** Without it a rebuilt transcode stayed cached at a stable address for a day, so crawlers and link previews kept serving the old bytes.
- **Preview reuse compares the source’s revision, not just its shape.** It checked `assetFileId` and dimensions — neither of which an in-place edit changes — while ignoring the mtime and filesize it already recorded. Primary selection is also a pure function of its candidates now: the sort key was read back from the column finalization writes, so it latched whichever file won the adoption race and made library paths vary between runs of identical code. Thanks to tkohhh for reporting this issue.
- **Missing previews schedule a repair instead of a redirect loop.** The width-aliasing fallback sent the browser back to the file it had just failed to find, as a cacheable redirect. Repairs are rate-limited, so a library that lost its previews directory hands sync a trickle rather than one task per thumbnail. Serving also tolerates up to 15% undershoot: requests are estimates and cached rungs are geometric, so a 960 px request was taking the 1715 rung over the 908 it is 5% wider than — about 3.6× the pixels to avoid a 1.06× upscale. Thanks to tkohhh for reporting this issue.
- **HEIF decoding selects the file’s declared primary item.** libheif’s CLI can’t safely supply full-resolution output — multi-image inputs produce numbered siblings, and the first is not necessarily the `pitm` primary — so Linux, Docker, and Windows now decode with ffmpeg 8.1, which also assembles the tile grid that older builds truncated to a single tile. macOS keeps native `sips`. ffmpeg drops the source ICC profile, so it’s copied back explicitly; establishing whether there *is* one to copy is the subtle part, because ExifTool reports a present-but-unextractable profile with the same string it uses for an absent one — which previously downgraded a failed copy into “there wasn’t one” and cached a desaturated intermediate as if it were good.
- **A decoder’s exit status decides, not its wording.** LibRaw was the one bundled decoder missing from `corruptMediaPatterns`, so a truncated CR3 or DNG logged at warn and reached Sentry while equivalent libjpeg, libvips, and ffmpeg failures were demoted — and the prefix alone isn’t enough to match on, because `dcraw_emu` prints “Cannot unpack” for memory exhaustion and cancellation too, and a failing disk reports EIO on a file that is perfectly fine. Prefix and result are matched together now, and exit status and output size both decide whether a decode succeeded: a CR3 that LibRaw can’t unpack exits 2 having written nothing, which used to leave `applyWip_()` polling an empty file for `ShortCommandTimeoutMs` before blaming the file rather than the decoder. Stderr is classified by complete line rather than by stream chunk, since `dcraw_emu` writes long messages in several chunks with the first capped at 128 bytes; an unreadable stderr stream counts as a decode failure, because a corrupt RAW can still exit 0 with a complete TIFF and leave that line as the only evidence. Unrecognized stderr no longer excuses a non-zero exit, and libjpeg’s bogus-progression warning — reported at exit 0 while decoding continues — needed its own blocklist entry.
- **Corruption detection stopped crying wolf.** Empty values were treated as garbage, so every blank ICC field — an iPhone’s `DeviceModel`, for instance — logged “dropping garbage EXIF string” during a scan, and legitimate all-zero values like an ICC `MediaBlackPoint` of `0 0 0` were flagged too. Blank now means “nothing written”, the placeholder rule requires an actual `:` or `?`, and tab, newline, and carriage return pass so multi-line descriptions survive. Album descriptions from nested tag fields and sidecars, which bypassed the top-level sweep, are now sanitized before they’re stored.
- **`readTags()` preserves metadata until its semantic owner validates it.** The global string sweep judged values before knowing their role, so an album description survived as a nested field and vanished as a top-level one. Tag navigation, preview writes, canonical make/model, and album descriptions each validate what they use. The derived title/description had no consumer and was removed rather than re-homed.
- **Sibling inference isolates its failures.** PhotoStructure reads a file’s neighbors to decide which files are variations of the same photo, and a neighbor that threw — a truncated video that failed out of ffprobe, or a parent directory it could not list — failed the target file’s own `readTags()` along with it. Unreadable neighbors and unlistable directories are skipped, while cancellation and diagnostic context are preserved.
- **Perceptual hash records are all-or-nothing.** A per-algorithm failure could put a partial record in the database, which is harmful in both directions: comparison averaged the survivors — deleting dissenting votes rather than weakening the verdict, so an incomplete record scored *higher* than a complete one — while the `vec0` filter requires all three and dropped the same file from similarity search entirely. Separately, comparison selected fields by their `*Hash` suffix, so callers passing whole `MediaInfo` objects dragged ExifTool’s cryptographic `ImageDataHash` in as a fourth algorithm; its ~0 confidence scaled the weighted mean by ~0.9, pushing genuine matches in the 70–78% band below `imageHashThresholdPct`.
- **Adoption and revalidation now agree about byte-identical files.** Adoption predicted one clustering and revalidation enforced another, so a pair of identical files could be adopted and immediately evicted, forever. Both paths call one `clusterShaGroups()`, which compares SHA groups through every member rather than through a lowest-id representative. This is the same failure shape as two loops fixed earlier, but neither of those fixes covered it. Reported against 2026.4.9-beta on macOS Desktop.
- **Worker progress crosses the process boundary.** A worker building a long preview ladder was indistinguishable from a wedged one, so the stall watchdog could recycle a child working steadily through a 60-megapixel raw. Adoption feeds the same size-aware estimator as `syncFile`, and stall detection waits for five observations on the task’s own timer before trusting a file-size estimate. This also quiets a warning storm: every rung of every delegated preview build had been logging “ignoring json line from worker”.
- **Large files no longer trigger false stall warnings.** `DurationEstimator` extrapolated outside its measured landmarks with an unbounded interpolation factor, producing negative estimates — production reports carried p50/p99 pairs like `-321359/-291004` ms — and p99 values below p50, which `StallWatcher` then consumed as deadlines. Interpolation is clamped to the measured range, and files above it extend at the tail’s marginal mean rate.
- **A progress callback parked past `end()` stops refreshing the watchdog.** `EndableInterval` clears the timer but can’t cancel a callback already suspended past its `await`, and `setInterval` doesn’t serialize them, so a `progress()` slower than `throttleMs` could leave several parked — and each one that resumed refreshed `lastPing` on behalf of a finished copy or transcode, deferring `isWedged()` by a full timeout and masking a stall exactly when one should surface. Live on `PosixFile.copy`, video transcode, and the file-progress watcher, which could report progress during an `end()` that landed mid-`stat()`.
- **Deleted source files are detected across scheduled syncs.** The scan generation was read from a freshly-inserted per-run `Progress` row, so it pinned at 1 every run and a file stamped generation 1 never failed the stale check. It’s now derived from the highest existing stamp, so it strictly increases across runs and is immune to wall-clock steps. The stale, max-stamp, and expunge queries are also scoped to the directory subtree with a literal, case-sensitive predicate, so `Photos` no longer bleeds into `Photos-old` and `Album_A` no longer matches `AlbumZA`. Thanks to tkohhh for reporting this issue.
- **Interrupted syncs resume instead of silently rescanning.** The resume machinery had never actually engaged: `priorMeta()` queried the current run’s own just-inserted `Progress` row, which always sorted first, so every “resumed” sync did a full fresh re-scan. Activating it un-masked two more defects, fixed together — an interrupted run is now retired once a later sync completes (previously a crashed run lingered for `progressStaleDays`, and resuming from it could hide newly-added files for up to six days), and forced scans opt out of resume so they re-visit every file.
- **Path resolution skips unreachable mount points.** fs-metadata resolves by device ID, so it stats every mount point it’s handed — once per imported file. A dead systemd automount parks each of those stats in uninterruptible `autofs_wait` for the device timeout, and four libuv workers against 30 concurrent `syncFile` tasks starve the sync process of all filesystem I/O. Filtering has to precede the call: a timeout rejects the promise but cannot recall a dispatched libuv request. Ancestry decides, not health — a known-bad ancestor stays, because dropping it would catalogue the file under a shallower volume’s `volsha`.
- **Finalization no longer un-shows an asset whose sources are offline.** It passed only readable `AssetFile` rows to preview generation, so an asset with every source on an unplugged volume got an empty list, preview generation threw, and the generic failure path hid an asset whose complete, current preview set was still in the library. Deletion proof moves entirely into `isUriDeleted()`, which decodes the URI, looks for any candidate path, and asks the mount table whether the volume that would hold it is here — an ancestor walk can’t tell a mounted-but-empty volume from an unmounted one, so `BaseFile.isDeleted()` went with it. One unambiguous live candidate is required to claim URI authority, and unavailable topology fails closed. Adoption also sets its finalize flag before path resolution and report output, so an unavailable volume or a broken report path can’t strand an unshown asset.
- **An ejected volume can’t spin the task queue.** A `lockNamesFn` that throws for every row — `AssetFile.nativePath` does exactly that for each file on an ejected volume — drove an immediate refill chain whose every `await` was a microtask, so the sweep ran the table dry without ever returning to the event loop, and past the degraded quarantine’s 10k in-memory cap it never terminated at all. Refills are paced and bounded now.
- **A sync that can’t resolve a volume retires the failure instead of blocking.** Finalization treated an unresolvable volume as a retryable error, so the asset stayed in the queue indefinitely.
- **Free-space policy is applied by what a volume holds.** `LibraryFreeSpaceCheck` counted the image cache under `~/Library/Caches/PhotoStructure`, applied the library’s 16 GB floor to every volume, labelled every failure “Library volume,” and returned `stop-sync` — twelve user-facing pause-note rows in one 30-minute window on a Mac whose external library had ample room. Volumes are now classified by directory role: durable roles (database, originals, previews, reports) keep the configured floor and may stop sync, and warn below 125% of it so the warning is reachable — the old warning branch compared against 75% of a threshold already crossed, so it could never fire. A volume holding only the disposable cache neither inherits that setting nor gets called the library, and warns only when space is genuinely exhausted or unmeasurable. Thanks to Mike and Gavin for reporting this issue.
- **Phantom “low disk space” warnings are gone, and volume change detection went back to polling.** Mount lists get their own 15-minute TTL and volume health expires on that budget; setting `0` disables polling. Paths on excluded system mounts resolve to their real volume again, so `LibraryFreeSpaceCheck` stops degrading the whole health summary to `warn` over a directory it couldn’t place.
- **Removable drives and network shares are visible again on Linux.** `/run` is runtime tmpfs, but udisks2 mounts removable drives at `/run/media/<user>/<label>` (Fedora, and Ubuntu as of 26.04) and GNOME and KDE mount network shares under `/run/user/<uid>/gvfs/` and `.../kio-fuse-*`. Excluding `/run` wholesale condemned all of them, so those drives weren’t offered as library directories and files on them got bogus rejection reasons.
- **libvirt storage pools are treated as infrastructure**, so unreadable VM disk images no longer trigger volume-health warnings or turn up as scan candidates.
- **A `.uuid` file is only rewritten when it could actually be read.** An unreadable one may still hold the volume’s real identity, and overwriting it strands every `psfile://` URI already catalogued for that volume. A rewrite now requires a successful read — so prior identities carry forward as aliases — or proof that nothing is there. This is deliberately independent of `readVolumeUuidFiles`, which means “don’t trust these files for identity”, never “destroy them”.
- **`psfile://` URIs round-trip correctly when two mounts share a filesystem UUID.** On a btrfs tie where the target exists at neither mount, the shortest mountpoint now wins, so `/data/x.jpg` resolves back to `/data/x.jpg` rather than `/home/data/x.jpg`.
- **Cached directory listings are validated before reuse.** The `readdirSorted` cache was invalidated only by an in-process event, so a worker’s listing went stale the moment another process wrote the directory — and sibling inference was then silently skipped, warning “can’t find self in siblings” for files that demonstrably existed. Listings are validated against the directory’s mtime, with a basename backstop for filesystems whose directory mtime is too coarse.
- **Directory iteration is deterministic again.** `sortByCaseInsensitive` returns a new array rather than sorting in place, so its result was discarded and descendants were walked in raw `readdir` order.
- **File-name search is bounded by attempts, not by an absolute ceiling.** Both file rollers feed a monotonic index in so a name they already picked is never re-tested, and bounding the search absolutely made that index walk into a wall — past 512 the loop body never ran and it threw instead of returning a name. The log writer’s index never resets, so a long-lived process reached it on uptime alone; the sync report hit it synchronously.
- **Lockfiles are no longer orphaned when acquisition times out.** A release landing mid-acquisition saw the not-yet-created path as absent and cleared its own reference; the acquirer then created a lockfile with a live PID and a fresh mtime that nothing would vacuum, blocking later acquirers until it aged out. A companion fix stops watch callbacks from firing after their timeout and recreating state the caller had already torn down.
- **`Serialized` no longer starts a second concurrent run.** A call landing in the gap between one run completing and the next being scheduled could violate the at-most-one-run contract; `nextRun` is now checked before `currentRun`. Callers include the filesystem lock, the log writer, the main service, and library maintenance.
- **A queue wait can’t park forever at shutdown.** `emit()` and `emitSync()` drop every event once `ending()` latches, so a waiter blocked in `awaitDrain()` or `awaitLowWaterMark()` was never woken and `TaskList.awaitQuiescent()` could park permanently. Both waits race the queue event against `awaitEnd()` now.
- **A worker RPC rejection stopped reaching the process handler.** `onChild()` observed each enqueued task twice but handed only one of those promises to a caller, so the copy discarded by the soft-timeout warning left its rejection reachable only through `process.on("unhandledRejection")`. Retry backoff also keeps its default delay referenced, so an otherwise-idle Node process can’t exit mid-retry.
- **A brief startup failure no longer permanently pauses sync auto-restarts.** A child process that couldn’t start burned the entire 10-restart flap budget in ~300 ms — long before the transient condition cleared — and sync stayed down. Automatic restarts now honor the documented `minTimeBetweenServiceRestartsMs` spacing, the backoff sleep wakes on `end()`/`stop()` so shutdown is never delayed, and the flap threshold is capped at the attempt count the spacing allows.
- **A dying sync child can’t talk over its replacement.** Lifecycle events are bound to managed child generations and replacement requires confirmed termination, so stale callbacks from an exiting process no longer overlap the new one. An unexpected zero exit counts as a failure, so restart storms reach the flap limit instead of looping quietly. Sync ownership and its last PID are retained until both adapter shutdown and OS exit are confirmed.
- **Two processes opening the same library is now terminal, not a crash-loop.** The losing sync is classified without fatal banners or respawns, and Desktop shows a single native warning before quitting. Instance-lock I/O faults surface as a structured service error rather than a bare failure.
- **An idle instance no longer claims sync isn’t running.** The `operationalState` RPC handed the per-pass `SyncManager` to `whyDoNotRun`, and a completed pass is an ended `Endable` — so between scheduled runs every idle instance’s health check reported “Sync is not running: endable ended”.
- **A health check that fails during setup now blocks the sync run.** Recording an error immediately after a reset left the aggregate summary unset, and the equality guard read that as “nothing changed” — so no watcher fired and the run gate never learned setup had failed.
- **Raising `taskTimeoutMs` no longer kills the ExifTool health check.** BatchCluster’s verifier requires `maxProcAgeMillis >= taskTimeoutMillis`; `maxProcAgeMillis` now scales as a multiple of `taskTimeoutMs` behind a shared five-minute floor, so both the ExifTool (×2) and heavier worker (×4) pools stay valid and never recycle a child mid-task.
- **Turning off `enableSimilaritySearch` no longer crash-loops sync.** Skipping the sqlite-vec extension load meant any later schema or integrity check on a database that already contained a `vec0` table raised `no such module: vec0`. The extension is now loaded whenever a `vec0` table is present, detected by reading `sqlite_master.sql` text; the feature stays gated, so the index is neither updated nor queried.
- **Thousands of orphaned SQLite write-probe files are swept up.** SQLite normally deletes `-shm` on last close, but on filesystems where it can’t take the required exclusive lock — some macOS external drives, some network mounts — it leaves them behind, and `sqliteFiles()` had been written for cold backup and skipped `-shm`. One user’s models directory held 764 orphans totaling 25 MB. Write probes are also memoized per directory for five minutes to stop the leak at the source. Thanks to Mike for reporting this issue.
- **Setting `maxMemoryMb` to 0 keeps the memory watchdog off.** Database-derived limit raising could re-enable it, at startup and on live resizing. That calculation is now centralized so the disabled setting is preserved.
- **The memory row on the Health page counts every live process.** Web and sync each published an RSS reading under the same id, so the page showed whichever reported last. They’re combined into one row with a total, and sync’s cached readings are withdrawn when the liveness ping says it’s gone. RSS is also measured against per-process budgets, with service and worker recycling deferred during startup, so the watchdog can’t drive a restart loop.
- **Tag counts are decremented when an exclusion hides an asset.** Removal now uses the asset’s pre-exclusion activity while additions keep its post-exclusion state.
- **An empty HTTP request no longer crashes a handler.** Express 5 leaves `req.body` undefined when no body parser matched, so endpoints that destructured it threw before their own parameter guards could answer. Every affected route — in the app and in the account site’s login, plans, and license paths — now falls through to its existing “missing parameter” response, so a malformed request can’t crash a handler, create a phantom asset revision, or persist empty settings.
- **The sync report path is advertised only once the file exists.** A freshly-connected peer was handed the report path before `createWriteStream` had opened it, so the web UI could tail a file that wasn’t there yet. Reconnecting peers also re-pull progress instead of showing a stale value.
- **A missing codec tool says why it’s missing.** The HEIF and video health-check warnings now carry the error from the most recent failed install attempt, instead of a bare “not installed”.
- **`toolPaths` can override a bundled tool, as documented.** `pathToTool_()` checked PhotoStructure’s own `tools/` directory before calling `pathTo()`, so for everything in it — `dcraw_emu`, `jpegtran`, `raw-identify`, `rg`, `sqlite3`, and the Windows recycle-bin helper — the setting silently did nothing, and an operator with a broken or too-old bundled binary had no way around it.
- **Environment variables are honored whatever their case.** `getFirst()` indexed the wrapped object directly, the one case-sensitive method on a case-insensitive collection, so `ps_http_port` was read by nothing — while `verifyPsEnvSettings()` resolved the same name through `lookup()`, found a valid setting, and stayed quiet. No effect and no warning is worse than a typo.
- **`PS_ENV_FILE` stopped being mistaken for a setting.** `verifyPsEnvSettings()` ran it through the nearest-setting search and answered `Did you mean setting "PS_PID_FILE"?` — advice that would break a working config, since these variables name `.env` files rather than settings. They’re exempt now, and the warning instead covers the casings `PsEnvReader` won’t resolve: it indexes `process.env` exactly, while every Setting reads through a case-insensitive lookup, so `ps_env_file` is honored everywhere except the `.env` selectors themselves — and on Windows, where the env lookup is case-insensitive, it works and must not warn.
- **An unrecognized value no longer empties a whole enum list.** `compact()` turned “no member was recognized” into an empty array, which outranked `settings.toml` and returned non-null, suppressing the warning that would have reported it — so a typo in `assetVariationSortCriteria` dropped all 15 ranking criteria and left variations ranked by URI alone. A nonblank value with no parseable members is now absent, so it falls through to the default and gets reported; `"[]"` still means explicitly empty, and a partial list keeps its recognized members, since these enums track identifiers that move between releases.
- **An unreadable `settings.toml` warns instead of whispering.** Discarding every setting in a file was reported at info level as “Cannot read”, carrying neither the path nor an errno — below the default `logLevel` of `warn`, so an operator couldn’t tell “no settings file” from “permission denied”. `--reset` archives the file, so absence is expected and stays quiet at debug; anything unreachable warns with the path and the errno. Windows reports ENOENT rather than ENOTDIR when a path component is a regular file, so a misconfigured settings path warns too instead of looking like an ordinary absent file.
- **Settings files holding invalid input are no longer rewritten.** Unreadable, malformed, invalid, unknown, and wrong-scope values are preserved, while intentional removed-setting upgrades still apply. Sensitive persisted values and TOML source lines are redacted from diagnostics, invalid-input markers are reconciled across scoped reloads and health validation, and untyped configuration values that aren’t strings no longer throw.
- **Windows paths are recognized as absolute.** `DriveRE` matched only a bare drive root, so `isAbsolute()` rejected every real Windows path and `BaseFile.join()` nested an absolute `syncReportsDir` under the library as `D:\lib\C:\reports`. `pathTo({tool: "foo.cmd"})` also stopped looking for `foo.cmd.exe`, and domain-qualified Windows user names are normalized.
- **Multibyte characters survive chunk boundaries.** File reads split at 64 KiB and child stderr can split around `write(2)` calls, so decoding each `Buffer` with `.toString()` replaced a character spanning two chunks with two U+FFFD marks. `LineReader` retains an incomplete byte sequence until the next chunk completes it, which protects file, log, child-process, and JSON-lines RPC consumers alike.
- **The Docker entrypoint drops privileges with `setpriv`.** `su` truncated graceful shutdown after two seconds and reset configured resource limits; `setpriv` execs the app directly, preserving argv, limits, and the full shutdown window. Before that, `su` took its command as a single string and claimed everything after it as its own operands, so under `PUID`/`PGID` `docker run photostructure/server info` started with an empty argv, `--version` printed `su`’s version, and `--debug` kept the container from starting at all. The documented `0022` umask default is preserved across both the root and `PUID` paths, and explicit `UMASK` values are honored exactly.
- **The Docker entrypoint keeps one identity per PUID mapping.** Renaming the base account at build time and renumbering it at startup replaces the duplicate `passwd` entry that made uid 1000 resolve to `node`, which is what made PAM’s umask depend on which name won the lookup. The account tools are skipped when the IDs already match, so the common case writes nothing to `/etc` and works on a read-only root; when they do run and fail, the container refuses to start rather than launching under stale IDs. A mis-owned library root is also chowned non-recursively whenever the dropped user can’t already write it — first runs, libraries migrated as root, later-adopted `PUID` — and the operator’s `cacheDir` is resolved from the environment or `settings.toml` for the recursive loop.
- **`maybe_chown_dir` had been chowning nothing since 2022.** It tested `$0` — the script’s own name — rather than `$1`, and the source-text-only coverage agreed with it. The entrypoint now runs inside a disposable container in the test suite, each assertion checked against a mutated copy of the script. Now that the loop works it refuses the filesystem root, since `PS_CONFIG_DIR` is user-supplied and `chown` defaults to `--no-preserve-root`.
- **The Docker config dir follows the library dir.** `dockerConfigDirs()` hardcoded `/ps/library`, so a container setting `PS_LIBRARY_DIR` looked for its config where the entrypoint never wrote it and `docker exec` tools disagreed with the running app. The entrypoint exports the directory it picked, honors `PS_LIBRARY_PATH`, and resolves `settings.toml` with the app’s own TOML reader rather than a hand-rolled `sed` — which only understood basic double-quoted strings, so a UTF-16LE BOM, smart quotes, or a decomposed Unicode path left the two disagreeing.
- **The container marker is trusted over an inherited environment hint**, so a `false` value passed into a containerized process no longer masks the filesystem evidence.
- **Library controls match how the library was configured.** Docker and env-managed setups are routed to documentation instead of actions that can’t work, and app choices are persisted without mutating the operator’s environment — which had been a route to a web/sync split-brain.
- **A failed startup serves `/health` instead of exiting.** The bound web service stays up in recovery mode and routes the failure there. Accepted tradeoff: removing the exit also removes the automatic retry, so a transient `library.ready()` failure stays in recovery mode until you restart.
- **Homebrew is detected on Apple Silicon.** `isBrew` probed only `/usr/local/bin/brew` — the Intel prefix — so on M-series Macs bootstrap fell through to `python -m pip install setuptools`, which a brew-managed Python refuses as an externally-managed environment.
- **macOS window-header buttons respond to clicks again.** Per-element drag carve-outs silently broke whenever a header grew a new interactive child that didn’t match the blacklist. Headers now use a `.no-drag` opt-in on their left, middle, and right containers.
- **macOS fullscreen has one scroll container, not two.** The fullscreen and Electron/macOS rules were keyed independently, so macOS fullscreen matched both and nested the scrolling `main` inside `#app-wrapper`. `getScrollContainer()` returned the outer one, whose `scrollTop` tops out at `--header-height`, so scroll position silently failed to restore on returning to a gallery. Scoping the fullscreen rule away from `.electron.mac` also collapses the nested gutter count to 0-or-1; counting two had been under-reporting every gallery track by a scrollbar width.
- **The tag pill’s icon is the right size again.** Its style rule used Vue scoped-style syntax that browsers reject as an unknown pseudo-class, and it targeted an ancestor of the `<svg>` rather than the `<svg>` itself, so the icon has rendered at the default 18 px with inherited fill since v2026.4.
- **Pull-to-reseed no longer fires while a modal is open** in the tag gallery.
- **The About page renders the real wordmark.** The heading set “PhotoStructure” in the UI font, which never matched the actual brand mark. The logo’s text paths are now inlined with `currentColor` so they follow your theme, the duplicate screen-reader announcement is gone, and the mark scales down on narrow screens. The page and CLI help also carry the PhotoStructure® registered-trademark notice (U.S. Reg. No. 8,272,183) and the corrected “PhotoStructure, Inc.” copyright line.
- **Update-channel setting descriptions now spell out the tradeoffs** between channels instead of leaving you to infer them.

### 🏗️ Under the hood

- **A re-sync confirms previews are current without decoding anything.** Sync records which source revision and recipe produced each preview set, so a later pass compares a checksum, the source’s mtime and size (within the sync epsilons, which is why those two get their own columns rather than living inside the fingerprint), and the effective recipe — instead of re-deriving the plan, which cost a measured 300–650 ms per RAW on every pass. The render source is resolved only when the build is about to render.
- **The library’s `-wal` file is trimmed rather than held at its high-water mark.** `wal_autocheckpoint` runs a PASSIVE checkpoint: it copies pages out but leaves the WAL as large as the busiest sync left it, so a library’s `-wal` stayed that size indefinitely. `journal_size_limit` now trims at each WAL reset, derived from `page_size × dbWalAutoCheckpointPages` — which is why that setting is now bounded to 100–100,000 pages: `0` would both disable autocheckpoint and derive a zero limit that truncates on every later reset. The web fallback janitor tier went with it; it never checkpointed anything.
- **web↔sync IPC rebuilt on a single symmetric RPC peer.** The two processes share one full-duplex socket at a fixed per-library path, carrying id-correlated requests and fire-and-forget notifications in both directions. This replaces the previous TCP client/server pair plus a separate file-watched event channel, deleting the discovery file, the file-drop state/health channels, and the liveness poller. A per-library file lock guarantees a single working sync process, and `photostructure sync <dir>` now either becomes that worker or hands the directory to the running one and exits.
- **The perceptual-hash index rebuilds atomically.** `AssetFileHash` dropped and recreated its derived vector table outside a transaction, then retried every failed batch one statement at a time without classifying the error — one database lock became 14,506 failed batches and 2.9 million failed per-statement inserts in a single captured incident, and could commit a partial or empty index. Rebuild now takes a write lock with an IMMEDIATE transaction and keeps DROP, populate, and verify in one atomic unit, so readers see either the old complete index or the new one; on any failure it rolls back. Staleness detection compares exact eligible IDs rather than counts, duplicate matching is gated until the index is verified, and repair retries after quiescence instead of riding the cleanup and backup cadence.
- **Volumes are identified by a set of hashes, not a single filesystem UUID.** All btrfs subvolumes of a filesystem share one UUID — commonly `/` and `/home` on desktop installs — so a UUID-derived `volsha` collided and resolved `psfile://` URIs to the wrong sibling; ZFS exposed no usable UUID at all. Using fs-metadata 2.x’s `subvolumeUuid`/`fsid`, each volume now carries a derived set: new URIs use the strongest intrinsic identity, while resolution and `AssetFileFinder` match and re-key across the whole set, so URIs written under an older identity migrate on rescan without duplicating and with no alias table. ZFS prefers a copy-specific `zfs-<poolGuid>-<datasetGuid>` composite, since OpenZFS may remap `statfs` `f_fsid` after a `zpool split`. A `.uuid` file copied from another pool is rejected component-wise, and the new `includeZfsGuids` setting is an escape hatch for hosts where the `zfs` and `zpool` CLIs misbehave.
- **Long sync runs no longer leak `Endable` registrations.** Completed `DoneWrapper` and `DirectoryIterator` registrations stayed in the global registry, producing monotonic memory growth after the four-hour sync restart default was removed. `DoneWrapper` now ends when its latch settles, and the service reports periodic memory diagnostics with RSS-doubling warnings. Validated on a 67,000-file, 918 GB scan. Thanks to tkohhh for reporting this issue.
- **Four native-memory leaks closed.** Sharp pipelines are lazy and pin their full-resolution input until executed, so memoizing one for the life of each instance held memory V8 cannot see — every cached `ImageHashes` entry retained two, and sync accumulated ~1 GB of native growth against a JS heap that looked healthy. `Promise.race` appends a reaction record to each promise it’s given and V8 frees those only when the promise settles, so racing a never-settling abort latch retained one record plus its closure per call, forever: 200k calls held 62 MB after a forced GC. `Cache`’s `maxSize` was enforced only by a timer that TTL-less caches never start, so process-lifetime caches grew unbounded despite declaring a limit. And the asset-file cluster map inserted on every lookup miss with a vacuum that couldn’t release deleted or orphaned rows; the live task set is now its reference count.
- **Image hashing left the sync process.** It was the last ordinary-sync path still decoding images with Sharp/libvips inside the long-lived sync process, so libvips allocator residency accumulated there with nothing to reclaim it. Record-only hash computation is delegated across the existing worker boundary and returns only `ImageHashFields`, so the memory is released when a worker recycles, and both `MediaInfoFactory` paths share one cache so de-duplication survives without retaining Sharp pipelines. A busy production sync generation held about 690 MiB RSS for nearly an hour with no watchdog restart. Separately, `imgFromExif_()` stopped cloning through `.raw().toBuffer()` to learn post-rotation dimensions — that materialized the entire image as an RGB buffer, tens of megabytes for a full-resolution `JpgFromRaw` — and reads the header instead.
- **Sync queue depth is sized from import concurrency, not core count.** `TaskList`’s concurrency is `maxConcurrentImports()`, and the queue refills only once it drains to half its cap — so a 2× cap put the refill threshold exactly at the running-task count. Measured with 22 workers, that floored the queue at 23: one pending task for the whole pool. It’s 3× now, and not deeper, because each unsettled task pins a whole cluster’s membership. Separately, an unreachable ISO re-validation was removed from the sync hot path: `isSimilarMedia` went from 187 µs to 36 µs, and `Clusters.add()` from 100.6 ms to 3.25 ms on average.
- **Temperature reads carry a deadline.** Reading `/sys` is a driver call — `drivetemp` issues an ATA SMART command — yet `hwmonTemps`, `thermalZoneTemps`, and `DriveRotational` used `readFileSync`, so a spun-down or failing disk held the event loop and `HealthCheck`’s own timeout could not fire. Docker is always on that path, since no lm-sensors ships in the image. One reader now carries the whole report’s deadline, and overrunning it throws rather than returning a report the hot sensor could be missing from.
- **Tag-cache staleness across the sync↔web boundary is bounded by a TTL.** The web process’s `TagDao` cache held a stale parent that kept fast-forwarding to the single child it had seen, so a tag created by sync never appeared until restart. `Tag`’s root-tags TTL is now shared with the `TagDao` cache as a backstop; the IPC layer still drives immediate invalidation.
- **Maintenance scheduling moved into a library-level coordinator**, with implementation left next to the domain modules that own the work. Repair and cleanup watermarks are tracked separately, so a transient repair failure retries without replaying cleanup or backup, and `fix` can run individual stages without making sync the conceptual owner.
- **The log flight recorder flushes on exit, and keeps what matters.** `#end()` drained only pending writes, discarding the buffered info and debug entries that describe a shutdown — systematic for the main process, whose exit is logged at info, leaving `main-*.jsonl` with no exit record at all. It also skipped entries at its own trigger level, so a warning rejected by `logLevel=error` was neither logged, buffered, nor counted, while the debug lines on either side of it were preserved. Overflow now writes a marker naming the dropped count and span, so a reader can tell an idle gap from a wrapped buffer, and shrinking the buffer keeps the newest entries rather than the oldest. `logFlightRecorderCapacity` drops from 250 to 64: now that every process exit flushes, 250 dumped hundreds of routine entries per clean worker exit across the whole fleet.
- **Diagnostics bundles are truncated by severity, not by arrival order.** A bundle pulled during an active sync filled its whole 5,000-row budget with worker debug from a 2m16s slice of a 10-minute window: all 7 errors dropped, main and web absent entirely, and 78% of the sync-report half spent on `excluded` rows. Truncation now fills severity tiers in order, splits each tier across the services and states that produced rows, and spreads each share over the time slices it occupies. Grouping is by service rather than by process on purpose — that window held 39 worker processes against one main, so a share per process would have handed workers 91% of the budget and rebuilt the starvation.
- **File-cache keys are canonicalized on demand, one pass at a time.** Canonicalizing at insertion cost a `realpath()` on every cache write — the hottest path during sync — to serve the rare alias-spelled key. Resolving lazily on the first non-matching `fileChanged` takes insertion from four `realpath` calls to none, and a reused key drops its memo so a retargeted symlink still invalidates. The invalidation pass iterates a snapshot rather than a live cache iterator and runs singly, coalescing repeat events for the same path, so passes cannot overlap and pin key sets larger than the cache itself. A 51-minute sync now holds a post-major-GC heap floor of 78–201 MB, and a 3.5-hour write-heavy sync peaked at 1279 MB RSS against the 2048 MB ceiling.
- **Each model gets its own logger context.** `$logger` was a `defer()`ed static field, so its initializer ran in `Model`’s own static block with `this` bound to `Model` — where `$tableName` is declared and never assigned — and memoized that single instance for every subclass. All model logging landed in `models.undefined`, so no per-model log context filter has ever matched.
- **`touch_()` stopped inventing timestamps.** `utimes()` writes atime and mtime together, so `touch_` has to supply both, and it defaulted the unnamed one to now — meaning `touch_({file, btimeMs})` *raised* mtime on Linux and Windows, the opposite of the backdate the caller asked for. Unnamed times are read back off the file and preserved to whole-millisecond precision.
- **Updated ExifTool to v13.59**, via `exiftool-vendored` v37, whose sparse byte-provenance map is what makes the legacy-charset recovery above possible without re-reading the file. An interim `-api Filter=…FixUTF8…` workaround was removed along the way: on v37 it suppresses that provenance map entirely, and it cost a measured 5.7× ExifTool slowdown (1.5 s → 8.5 s on the JPEG corpus) by scanning every value, including multi-kilobyte embedded previews.

### 📦 Packaging changes

- **Desktop editions moved to Electron 43** (from 41), which brings a newer Chromium and V8 to the macOS, Windows, and Linux apps.
- **Core native dependencies took major-version bumps** across the Node, Docker, and desktop editions: sharp 0.34.5 → 0.35.3 (image scaling), fs-metadata 1.4.1 → 2.5.0 (volume and mount detection, which is what makes the btrfs and ZFS identity work above possible), @photostructure/sqlite 1.2.1 → 2.3.0, @photostructure/sqlite-vec 1.2.0 → 2.0.0 (the similarity-search index), and batch-cluster 17 → 19 (child-process pooling).
- **Bundled command-line tools were refreshed for release.** LibRaw moves to the 0.22.2 release tag on every platform — base-tools-debian left the master snapshot behind, and neither macOS (built from the tag) nor Windows (libraw.org’s prebuilt zip) can follow a master commit; `raw-identify` output is unchanged across all 66 example RAWs. ripgrep moves to 15.2.0 on Linux, which had been left on 15.1.0 after the macOS and Windows refreshes, and the Windows and macOS binaries were re-downloaded or rebuilt with recorded provenance, license texts, and malware-scan results. The version-consistency check now watches ripgrep too — it had covered only SQLite, libjpeg-turbo, and LibRaw, which is how Windows came to ship libjpeg-turbo 1.5.3 while Linux moved to 3.2.0. The Pixi installer is pinned to 0.77.1 by digest.
- **`sudo` is gone from the Docker image.** It existed only to run a root-owned codec-install helper, which nothing invokes now that installs go into a per-user prefix. Shipping `sudo` plus a sudoers drop-in was the only root-executable surface the app process could reach, for a mechanism already broken under `no-new-privileges`. `libglib2.0-0t64` also left the runtime images — @photostructure/fs-metadata dropped its GIO/GLib integration, and the prebuilds we ship need only `libblkid`, which the Debian base already provides.
- **Package versions were re-vetted.** Pinia’s devtools peer is pinned, the unused account-local npm runtime is gone, and the obsolete Snyk ignores that covered npm’s vulnerable transitive tree were dropped along with it.

## 🤔 Questions? 🌹 Feature requests? 🐞 Found a bug?

Visit the [**official PhotoStructure forum**](https://forum.photostructure.com) or [**Discord**](/go/discord)!
