Release v1.6.0 — Weekly Roundup, Library Seed, persistence hardening - #106
Merged
Conversation
- Add Weekly Roundup config section to dashboard (enable toggle, channel select, weekday, hour, embed color) - Wire WEEKLY_ROUNDUP_CHANNEL_ID into Discord channel loader - Bump package.json to 1.5.4 and add CHANGELOG entry
- Add utils/i18n.js: minimal Node-side loader that reads
locales/<LANGUAGE>.json with fallback to en.json, exports t(key, vars)
with dot-notation lookup and {placeholder} interpolation
- Add roundup.* namespace to en/de/sv/template locales
- Replace all user-visible strings in bot/weeklyRoundup.js with t() calls
(embed title, season/episode labels, footer, fallbacks)
- Logger messages remain in English by convention
Must-fix from code-review + silent-failure-hunter:
- Scheduler: now.getHours() === targetHour (was <, caused re-posts
throughout the target hour+)
- Failure counter scoped per-week with weekKey, resets automatically
on entering a new week (was process-global, permanently died after
3 lifetime fails)
- fetchRecentlyAdded throws on error instead of returning []; roundup
and poller wrap it properly so a Jellyfin outage no longer looks
like a quiet week
- Rename Jellyfin filter param: MinDateLastSaved -> MinDateCreated
(LastSaved changes on metadata refresh, wrong semantics)
- escapeMd now also escapes * _ ~ ` to prevent titles like *Batman*
from breaking bold formatting inside the link label
- sendWeeklyRoundup: channel-fetch failure now logs a warn, no more
silent return
- runTick wrapper catches rejected promises from setTimeout/setInterval
- markPosted: on persistence failure, do NOT set process.env so the
next tick retries (was in-memory-only success)
- resolveLibraryNames: propagates errors to caller instead of silently
returning {} (caller now fails the tick and bumps failure count)
- parseIntInRange for WEEKDAY/HOUR rejects NaN/out-of-range values
- i18n: whitelist LANGUAGE env var against ^[a-zA-Z]{2,3}([_-]...)?$
so a malicious or typoed value cannot point fs.readFileSync at an
arbitrary path
- formatDate: normalize LANGUAGE to primary BCP-47 subtag before
passing to Intl
Also: buildJellyfinUrl: log fallback at error level so malformed
JELLYFIN_BASE_URL is loud in ops logs.
If the Discord post succeeded but writing lastPostedAt to config failed, the previous logic bumped the weekly failure counter. After 3 of those the whole week was skipped despite users having already seen the post. Set the in-memory env var unconditionally so the same process won't re-post, and log the persistence failure without touching the counter.
- resolveLibraryNames: catch fetchLibraries errors and fall back to the generic library label instead of letting a names-only blip nuke the whole weekly post - formatDate: log the Intl failure before falling back to ISO date - buildJellyfinUrl: ensure the concat fallback is a syntactically valid URL (prefix with http://invalid.local/ sentinel when JELLYFIN_BASE_URL lacks a scheme) so downstream ButtonBuilder.setURL surfaces the misconfig clearly rather than with an opaque validation error
The weekly_roundup_* and weekday_* data-i18n keys were present in the HTML but never added to the locale files. Users on non-English locales saw raw keys like 'config.weekly_roundup_title' instead of translated labels. Added all keys to en, de, sv, and template.
Mirrors the existing 'Test Random Pick' pattern — posts the weekly roundup immediately without waiting for the scheduled weekday/hour. sendWeeklyRoundup gains an options.test flag. In test mode it rethrows errors (so the HTTP handler can surface them) and skips all state side effects (lastPostedAt, failure counter) so a test run never masks or replaces the real scheduled post.
Log the raw Jellyfin item count and the post-library-filter count on every fetch so the actual filtering stage is visible. In test mode, distinguish three failure cases when nothing would be posted: - no notification libraries configured at all - Jellyfin returned items but the library filter dropped all of them - Jellyfin returned nothing in the 7-day window The scheduled path is unchanged — a library mismatch is still a normal quiet week for that user, not a failure to count.
Jellyfin libraries have a CollectionId (referenced by Item.ParentId / AncestorIds) and a separate VirtualFolderItemId (stored in JELLYFIN_NOTIFICATION_LIBRARIES via the dashboard). Comparing AncestorIds directly against the config keys never matched, so the roundup filtered every item out even when Jellyfin returned 200. fetchWindowItems now calls fetchLibraryMap() and runs every candidate ID through resolveConfigLibraryId() before checking membership — the same translation the webhook flow already does. The resolved id is stashed on each item as _configLibraryId so groupItems can reuse it without redoing the lookup.
When Jellyfin returns items but every one is filtered out, dump the configured library ids, the known Jellyfin library ids in both forms, and the first item's ParentId/AncestorIds (raw + translated) so we can see exactly where the comparison diverges.
…ering The previous approach fetched the 200 most recent Jellyfin items globally and then filtered by AncestorIds. That breaks when items live in libraries that /Library/VirtualFolders doesn't return (BoxSets, Collections, unmapped folders) — every item gets dropped even though they're really inside a configured library. Now we issue one /Items query per configured library id with ParentId + Recursive, which delegates membership resolution to Jellyfin itself. No more two-form-id dance, no more ancestor walks. Also defensively skip non-hex-32 entries in JELLYFIN_NOTIFICATION_LIBRARIES — observed an 'on' string leaking in from somewhere upstream, will need a separate fix for the source.
Sonarr quality upgrades delete and re-import the same episode file,
which Jellyfin can register as multiple new items with the same
SeriesId + ParentIndexNumber + IndexNumber within the same week. The
previous counter would then report '3 new episodes' for what was
actually the same episode imported three times.
seasons is now Map<seasonNum, Set<episodeKey>>, where episodeKey is
'e{IndexNumber}' for normal episodes and falls back to the Jellyfin
item id for unnumbered specials (which keeps per-item dedup but lets
specials still appear).
Episode dedup key now prefers, in order: IndexNumber + IndexNumberEnd (2-parters), IndexNumber alone, lowercased Name (Sonarr re-imports keep the title), then item id as last resort. The previous version fell straight to item id when IndexNumber was missing, which made every re-import look like a different episode. Also log the raw identity fields for the first 30 episodes per run so we can see exactly what Jellyfin returns and confirm where dedup breaks if a case still slips through.
# Conflicts: # .gitignore # CHANGELOG.md
- Corrupt WEEKLY_ROUNDUP_LAST_POSTED_AT now warns and skips the tick instead of silently falling through and re-posting. - Drop the redundant outer try/catch in runTick — the setInterval wrapper already catches and logs, and the inner try only masked design intent (no path inside throws synchronously today). - Empty-week branch logs at warn (with a diagnostic) when no items match because of misconfig (no notification libraries, or N items all filtered out by library mismatch). A genuinely quiet week still logs at info.
- Preflight JELLYFIN_BASE_URL in sendWeeklyRoundup so a malformed value no longer makes it into the embed as http://invalid.local sentinels. Logs error, bumps the failure counter, aborts the post. - resolveLibraryNames now returns { map, failed }; embed footer notes "library names unavailable" when fallback was hit and there is more than one library section, so Discord viewers see why headers look generic. - Embed field truncation builds entry-by-entry up to the 1024-char budget instead of byte-slicing the joined string. Adds a translated "… and N more" line when entries had to be dropped, so a markdown link is never cut in half. - Hourly tick uses now.getHours() >= targetHour. If the bot was down or the tick drifted past the boundary, the digest catches up later the same day rather than skipping the entire week. The 6-day idempotency guard prevents duplicates on re-tick. - Episode raw-fields diagnostic dump downgraded from info to debug — no longer noise during normal operation. - utils/i18n.js: resolve LOCALES_DIR relative to the module via fileURLToPath so the loader works regardless of cwd. Drop the existsSync/readFileSync race window (single readFileSync, treat ENOENT as "missing locale", log other errors). - /api/test-weekly-roundup error response: coerce error.message to string and slice to 500 chars so a future axios/discord upgrade can't leak unbounded data into the dashboard. Locale keys added: roundup.library_names_unavailable, roundup.field_more in en, de, sv, template.
…-seen map Roundup queried Jellyfin /Items?MinDateCreated, which surfaces re-imported files (quality upgrades) as if brand-new — fresh ItemId AND fresh DateCreated. Now records each item under a stable key (TMDB for movies/series, SeriesId+S/E for episodes/seasons) the first time it's seen, and filters out items whose firstSeenAt is older than the 7-day window.
…ponse - weeklyRoundup: JELLYFIN_BASE_URL preflight now also enforces http(s) scheme, matching the SSRF guard used by the config-test routes - app.js /api/test-weekly-roundup: strip URL-shaped substrings from the error message before returning, so axios-embedded URLs (which can carry api_key query params) never leak into the dashboard response
## Summary
Adds an optional **Weekly Roundup** that posts a weekly digest of new Jellyfin content to a Discord channel. Disabled by default; configurable via the dashboard.
## Changes
- **`bot/weeklyRoundup.js`** (new): hourly scheduler tick, 7-day rolling window fetch from Jellyfin, per-library grouping, series/season/episode collapsing (e.g. _"My Show — Seasons 1 & 2 (12 episodes)"_), embed builder with Jellyfin deeplinks, 3-strike back-off on consecutive failures
- **`bot/botManager.js`**: wires `scheduleWeeklyRoundup(client)` into the `clientReady` handler next to the daily random pick
- **Config** (`lib/config.js`, `utils/validation.js`): adds `WEEKLY_ROUNDUP_{ENABLED,CHANNEL_ID,WEEKDAY,HOUR,EMBED_COLOR,LAST_POSTED_AT}` keys and Joi validators
- **Dashboard** (`web/index.html`, `web/script.js`): new "Weekly Roundup" section with enable toggle, channel select, weekday dropdown, hour input, embed color; `WEEKLY_ROUNDUP_CHANNEL_ID` is populated via the existing Discord channel loader
- **`utils/i18n.js`** (new): minimal server-side i18n loader. Reads `locales/<LANGUAGE>.json` with fallback to `en.json`; exports `t(key, vars)` with dot-notation lookup and `{placeholder}` interpolation
- **Locales** (`locales/{en,de,sv,template}.json`): new `roundup.*` namespace covering embed title, season/episode labels, footer, and fallbacks — all user-visible strings in the roundup go through `t()`
- **Helpers**: `utils/jellyfinUrl.js` extracted from `jellyfinWebhook.js` so the roundup can build Jellyfin deeplinks without duplicating logic; `api/jellyfin.js::fetchRecentlyAdded` gained an optional `minDateCreated` param (maps to Jellyfin's `MinDateLastSaved`) to support the rolling 7-day window
## Idempotency
The scheduler ticks every hour and gates on a persisted `WEEKLY_ROUNDUP_LAST_POSTED_AT` timestamp, so:
- Docker restarts don't cause duplicate posts within the same week
- The hour/weekday gate is checked on every tick — a missed window (e.g. container down at the exact hour) posts on the next matching hour
- `ALREADY_POSTED_MIN_AGE_MS` is 6 days (not 7) to tolerate small scheduler drift
## Version
Bumps `package.json` to `1.5.4` and adds a `CHANGELOG.md` entry.
> AI-assisted documentation. Code logic manually verified.
Jellyfin's /Items endpoint silently ignores MinDateCreated, so passing it returned the most recent N items regardless of the requested cutoff. Switch to the supported MinDateLastSaved param and add StartIndex-based pagination with an early-break once results fall below the cutoff.
Three fixes that together stop bloated digests: - installedAt floor: persists the bot's first-start timestamp to config/dedup-roundup-state.json. Items with DateCreated older than installedAt are dropped — the first roundup after install/upgrade no longer pulls in the back-catalogue. - Client-side DateCreated cutoff: MinDateLastSaved is a superset of recently added (advances on metadata refresh too), so enforce the 7-day window in code before grouping. - TZ-aware scheduler: now.getHours() returned UTC inside default Docker images. New optional WEEKLY_ROUNDUP_TZ env var pins weekday/hour to a specific timezone via Intl.DateTimeFormat; absent, falls back to host time (which already respects the TZ env var).
…s, silent caps - fetchRecentlyAdded: per-page try/catch so a transient error on page N returns the items already collected from pages <N instead of throwing the whole call away. Tag every break path with a stopReason for diagnosis. - maxTotal cap is no longer silent: warn when hit so an operator knows older items in the window were truncated. - weeklyRoundup filter: items with missing/unparseable DateCreated are now dropped explicitly (with their own counter) instead of slipping past both the installedAt floor and the 7-day cutoff. Filter summary always logs at debug level. - WEEKLY_ROUNDUP_TZ validation runs once at scheduler start with a loud warn; per-tick fallback drops to debug to avoid hourly log spam. - roundupState: log on every installedAt stamp (first-ever or restamp after PersistentMap rejected a corrupt value).
- fetchRecentlyAdded: add isAxiosError guard and response shape validation, consistent with fetchAllLibraryItems - pruneLibrary: set/clear scanInProgress via new setScanInProgress() export so a re-seed triggered during a prune run is also blocked
Previously all 403s showed a generic or auth-focused message. Jellyseerr
returns { message: "Series Quota exceeded." } in the response body —
this is now detected and surfaced to the user directly.
Extracts getSeerrErrorMessage() helper used in all three request catch
blocks (handleSearchOrRequest, button handler, daily pick handler) so
quota detection is consistent and not duplicated.
…0+, form-data security)
retardgerman
marked this pull request as ready for review
July 7, 2026 14:03
Resolve dev/main merge conflict (package-lock version)
- libraryPruner: run once shortly after boot, not only on a 24h setInterval, so frequent restarts (e.g. config saves) don't skip every prune cycle and let seeded dedup keys expire - seen-items TTL raised to match roundup-first-seen's ~5yr backstop; actual removal for deleted items still happens via the daily prune scan, not TTL expiry - roundupScheduler: rebind to the new Discord client on bot restart instead of ignoring the second start() call, matching the existing daily-pick scheduler pattern - configFile: migrate legacy EMBED_SHOW_OVERVIEW to the new _MOVIES/_EPISODES split so users who disabled it (often to avoid episode spoilers) don't get overviews silently re-enabled - interactions: move the Seerr request error messages (including the new quota message) through locales/ instead of hardcoded English - weeklyRoundup: log the underlying parse error instead of an empty catch block in the JELLYFIN_BASE_URL preflight
- roundupScheduler: clearTimeout alone doesn't stop a chain whose tick already fired and is in flight when start() is called again on restart — its .finally(scheduleNext) would still re-arm using the stale client/closure. Add a generation token so a superseded chain becomes a no-op instead of quietly ticking against the destroyed client. - i18n: t() now falls back to the English value for a key missing in the active locale (instead of leaking the raw key string into Discord messages), for locale files that exist but lag behind on newer keys. - locales/fr.json: add the seerr_request_errors block directly too, consistent with de/sv/template. - CHANGELOG: fix the 1.5.6 EMBED_SHOW_OVERVIEW entry, which told users to manually re-configure the setting — it's now migrated automatically.
- roundupScheduler: add stop() (bumps generation, clears the pending timer) and call it from both discordClient.destroy() sites (routes/botRoutes.js stop-bot handler, app.js config-triggered restart). Without this, stopping the bot (not restarting it) left the scheduler ticking against a destroyed client until the failure circuit opened for the week — the same failure mode the previous round's restart fix addressed, just for a different trigger. - locales/fr.json: drop the seerr_request_errors block added last round — it was the English source text copy-pasted in, not an actual French translation, and it silently suppressed the new per-key en fallback warning. Leaving the keys absent lets the fallback (and its logger.warn) do its job until someone translates them for real. - utils/i18n.js: minor cleanup — reuse the already-loaded en.json fallback instead of reading it from disk twice, and log an error if en.json itself fails to load while another locale is active (previously a silent no-fallback-left edge case).
npm audit --audit-level=high started failing on the fix branch with two newly-published high-severity advisories, unrelated to the review fixes in this PR: - body-parser <1.20.6 (GHSA-v422-hmwv-36x6): DoS via invalid limit value silently disabling size enforcement. body-parser is a transitive dep of express, not a direct one, so pinned via overrides like the existing undici/follow-redirects entries. - axios 1.0.0-1.17.0: several newly-disclosed advisories (prototype pollution, DoS via recursion, maxBodyLength bypasses). Resolved by `npm audit fix`, which bumped the resolved version to 1.19.0 — still satisfies the existing `^1.17.0` range in package.json, so no version constraint change needed there. `npm audit --audit-level=high` now reports 0 vulnerabilities.
- renderFieldGroup: truncate oversized entries and never flush an empty field value, which Discord rejects with a 400 and which would burn the scheduler's weekly failure budget - librarySeeder: build episode series/season keys from SeriesId, not from the episode's own ProviderIds.Tmdb, which never matched a real Series or Season key - libraryPruner: stop pruning "id:" keys — they can originate from item types fetchAllLibraryItems does not enumerate, so the daily scan would drop them and the next poll would re-notify - app.js: clear the initial prune timeout on SIGTERM/SIGINT - i18n: warn once per missing key instead of on every t() call - weeklyRoundup: return an explicit stats object instead of array expandos, rename the `t` shadow in groupItems, escape "|", drop the episode debug dump - de/sv: add missing reseed_library keys - CHANGELOG: correct release date, drop the stale WEEKLY_ROUNDUP_LAST_POSTED_AT description, add role mention and the undici/axios/body-parser/joi security bumps
Critical, from the code review:
- updateConfig() refused to write when config.json did not exist yet, not
just when it was corrupt. On a fresh install that stopped JWT_SECRET and
WEBHOOK_SECRET from ever being persisted: both stayed memory-only, were
regenerated on restart, and the X-Webhook-Secret the user had already
copied into Jellyfin no longer matched. Now only an existing-but-unreadable
config blocks the write, and auth.js/secrets.js expose the generated secret
via process.env on the failure path too.
From the silent-failure audit:
- PersistentMap.flush() returns whether state reached disk. librarySeeder
throws instead of setting LIBRARY_SEEDED=true on a failed flush, which
otherwise skipped the seed on the next boot and re-announced the whole
library. libraryPruner reports the same condition.
- A read error in _load() now blocks flushing, so the unread file is no
longer overwritten with empty in-memory state on the first set(). The
existing comment claimed this invariant; nothing enforced it.
- Persistent flush failures escalate to error instead of fading to debug.
- fetchRecentlyAdded() returns { items, complete } like fetchAllLibraryItems.
fetchWindowItems throws on an incomplete fetch rather than posting a
truncated roundup or reporting "no new items" when the API errored.
Cleanups: drop the unreachable onError branches in weeklyRoundup, remove the
dead HOUR_MS constant, correct fr.json completion metadata, drop async from a
handler with no await.
Version bumped to 1.6.0 (minor, not patch): this release adds the Weekly
Roundup, library seed and prune features and replaces the EMBED_SHOW_OVERVIEW
config key.
From the re-run review agents. - fetchRecentlyAdded() reports `truncated` separately from `complete`. Hitting the per-library item cap previously returned success-shaped, so a bulk import produced a digest missing its oldest entries with only a warn in the log. Routing it through the existing `!complete` throw would be worse — a retry truncates identically, so the week would fail three ticks and open the circuit — hence a distinct flag, an error log, and a footer note in the embed. - resolveLibraryNames() no longer reports success when it resolved nothing. A CollectionId/ItemId form mismatch made every section header fall back to the generic label with no log line and no footer note. - writeConfig() and PersistentMap.flush() chmod the temp file before the rename. `mode` in writeFileSync only applies on creation, so a leftover or pre-created .tmp kept its old mode and carried it onto the real file. - msUntilNextHour() floors at 1s so a clock step cannot produce a tight tick loop. - fetchAllLibraryItems() requests ProductionYear explicitly; buildIdentityKey uses it for TMDB-less movies and the pruner must rebuild the same key. Adds roundup.items_truncated to en/de/sv/template.
…locales - Circuit breaker warns once on the transition into open instead of only logging skip:circuit-open at info, so a week-long stop is visible. - parseIntInRange reports a rejected WEEKLY_ROUNDUP_WEEKDAY / _HOUR value instead of silently substituting the default. Named only from start() so the hourly tick log stays quiet. - Embed field trimming now annotates the footer, matching how the existing library-names fallback already discloses itself. - t() logs an error when a key is missing in the active locale AND in English — that path renders the raw key to users and was silent. - /seed/reset checks its preconditions synchronously and answers 409 with the real reason rather than confirming a scan that never starts. Extracted as checkSeedPreconditions() so seedLibrary() and the route agree. - The roundup "why was nothing posted" diagnostics were hardcoded English but reach the dashboard through the test route; moved to locales/ per the project i18n rule. Adds roundup.sections_trimmed and five roundup.diag_* keys to en/de/sv/template. Closes #127
From the final review pass. The "no items" diagnostic derived its count from `beforeFilter - items.length`, which lumps all four drop reasons together, then asserted the cause was "seen before (Sonarr/Radarr upgrade or older import)". On the first roundup after install every item is dropped by the installedAt floor, so users were told their dedup store had suppressed content that had simply been added before Anchorr existed — and the test route threw that same wrong string to the dashboard. The diagnostic now branches on the specific counters and has dedicated messages for the pre-install and missing-DateCreated cases. Also: - sendWeeklyRoundup returns whether it posted; the scheduler only stamps lastPostedAt when it did, so a misconfigured week no longer blocks retries for six days after the user fixes it. - resolveLibraryNames no longer reports failed:true for an empty id list. - stop() resets circuitOpenWarned so the warning re-fires after a restart. - fetchRecentlyAdded requests ProductionYear, matching fetchAllLibraryItems, and its JSDoc documents the complete/truncated distinction. Adds roundup.diag_all_pre_install and roundup.diag_no_date_created to en/de/sv/template.
Adds the config/state persistence hardening, the roundup limit disclosure, and the known season-dedup-key issue (#126), which were implemented but never listed. Reorders the Added section by impact.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings v1.6.0 from
devtomain. Minor rather than patch: this release adds three user-facing features and replaces theEMBED_SHOW_OVERVIEWconfig key, which is outside what a patch bump covers under the SemVer commitment inCHANGELOG.md.Weekly Roundup
config/dedup-roundup-state.jsonguarantee one post per week, even if the container restarts on the trigger day.Library Seed
Other changes
EMBED_SHOW_OVERVIEWvalue migrates automatically.discordIdsarray alongside the legacydiscordIdstring.Config and persistence hardening
updateConfigrefuses to write when an existing config is unreadable instead of silently replacing it with a partial one.PersistentMapreports whether state actually reached disk. A failed flush no longer lets the seeder mark the library as seeded, which would otherwise skip the seed on the next boot and re-announce the whole library..tmpcannot carry loose permissions onto the real file.Dependency and security updates
form-data4.0.6 (GHSA-hmw2-7cc7-3qxx)undici^6.26.1 (GHSA-p88m-4jfj-68fv, GHSA-vxpw-j846-p89q, GHSA-35p6-xmwp-9g52, GHSA-g8m3-5g58-fq7m)axios1.19.0,body-parser^1.20.6,joi18.2.1Review status
Three review rounds ran against this branch. The first surfaced a critical first-run regression:
updateConfigrefused to createconfig.jsonwhen none existed, so fresh installs never persistedJWT_SECRETorWEBHOOK_SECRET. That invalidated sessions on restart and broke the webhook secret users had already copied into Jellyfin. That finding and the silent-failure findings alongside it are fixed in ac5115c, 07feee8, 51b0256 and 35773a5. The observability follow-ups tracked in #127 are done.Known and deferred to #126:
buildIdentityKey()uses an item's own TMDB id as the series identity in itsSeason/Episodebranches. Episode dedup is unaffected in practice, since episode TMDB ids are unique and the key stays stable even though its meaning is wrong. Only season-granularity suppression is affected. The fix changes the key format and needs a startup migration, so it is scheduled for 1.6.1 rather than landed right before a release.Not verified: no live Jellyfin or Discord run, and this project has no automated test suite.
AI was used for the written material in this release only: this description, the changelog entries, and the user-facing strings. The code was written and reviewed by hand, line by line. Each change traces back to a concrete cause, went through three review rounds, and was reworked where the review found something. The deferred item in #126 is listed above rather than quietly shipped for the same reason.