Changelog: Hub

Every hub release, newest first. The API, the web app, this documentation site and the inference worker ship together under this version number, with the broker and backup setup that come with them.

0.79

0.79.0

Upgrade notes
  • Links shared before this version keep opening for 90 days counted from the upgrade (migration 105), then stop; anyone who still needs one asks for a new link.
  • The first hourly sweep after the upgrade deletes the backlog of old telemetry, alerts, model-comparison results and answered join requests at once.
Security
  • Share links expire: a link opens for 90 days after it is made, then its page, recording and preview answer 410 SHARE_EXPIRED. The share button says until when the copied link opens, and an expired link's page says it has expired instead of calling it invalid.
  • Records that were kept forever have a retention period, applied every hour: satellite telemetry 30 days (each satellite keeps its latest reading, the only one a page shows), alerts a year, model-comparison results 90 days (the comparison page's longest window), answered requests to join a team 12 months; the configuration history stays. The privacy page gives these durations.
  • The docs site serves its diagram library (mermaid 11.17.2) itself instead of loading it from jsDelivr, and its CSP allows no other host.
Chores
  • share-links.test.ts covers making, expiring and forging links and the grace period for undated ones; record-retention.test.ts covers each retention period.

0.78

0.78.2

Upgrade notes
  • OTAVI_TLS_CERTRESOLVER is required: the web, MQTT and docs routers name the Traefik certificate resolver for their names instead of relying on the entrypoint's default. Set it to the resolver that issued your certificates so far, unless your names move to a domain another resolver serves.
  • OTAVI_S3_FQDN is no longer read: remove it from .env, and its DNS record.
Features
  • docker-compose.previous-names.yml keeps a hub's previous public names working during a move to new ones: the API and the broker stay served under the previous names, since satellites keep the addresses they registered with and a redirect would drop their key, and everything else redirects to the same path on the new name. It is enabled by COMPOSE_FILE and OTAVI_PREVIOUS_APP_FQDN / _MQTT_FQDN / _DOCS_FQDN in .env, and removed the same way once the move is over.
Security
  • MinIO's console is off and has no public route (it sat behind the proxy's sign-in at OTAVI_S3_FQDN); MinIO is on the backend network only, where the hub and the backup use its S3 API.
Docs
  • The deployment guide describes three public names, the certificate resolver, how to look inside the buckets with mc, and a move to new names: who signs in again, passkeys, how a phone and a Pi move, and two certificate pitfalls (a wildcard record catching the challenge names, a network that answers DNS itself).
Chores
  • ops-check.sh checks the previous names while a move lasts (API served, pages and docs redirected, the broker reachable) and checks that MinIO has no route instead of probing its console.

0.78.1

Upgrade notes
  • Detection rules with species or hours typed in the rule editor start applying them, and trend rules their own threshold: before, the engine read neither what the editor saves, so such a rule fired for every species at any hour, and trends used 30%.
Fixes
  • A detection in a category the team hides (engine noise, human voices, dogs...) fired every alert rule, the built-in ones included; it now reaches only the rules that name its species, and the others skip it like the pages do.
  • The share button on a tentative detection copied a link that would not open; it is hidden there, and says so when a link cannot be made.
Security
  • Share links refuse detections in a category the team hides. An alert sent to a channel carries a share link, and a rule posting to Google Chat had sent links that played recordings of human voices to anyone holding them. GET /api/detections/:id/share answers 409 NOT_SHAREABLE for those and for tentative detections.
  • The privacy page states that share links and alerts leave out the categories the team hides, such as human voices.
Docs
  • The features page, the API reference and the security notes describe what alerts and share links leave out.
Chores
  • hidden-categories.test.ts pins the alert rules against hidden categories, the rule conditions as the editor saves them, and the share links.

0.78.0

Upgrade notes
  • Figures drop on the pages that counted every row, by about a third where many songs are stitched across chunks; no detection is deleted. Every page now counts what the Detections page lists: promoted detections, one per call, in the categories the team shows.
Fixes
  • The Dashboard, Species (catalog and profile), Analytics, Compare, the satellite list and the System summary counted tentative detections and each 3-second piece of a stitched call; they count each call once, promoted detections only, like the Detections page.
  • The PDF report and the Map counted tentative detections.
  • Manual exports (CSV and JSON, eBird, iNaturalist, xeno-canto) included tentative detections, every piece of a stitched call and hidden categories such as engine noise; they export what the Detections page lists, and scheduled exports leave out pieces and hidden categories too.
  • Trend alerts (species diversity, detection count) counted hidden categories and stitched pieces; their figures match Analytics.
  • The Dashboard's highlights could present a species heard only tentatively as new, and a species profile opened directly showed a hidden category's figures.
  • An alert channel's test message could link to a tentative detection, which share links refuse.
Docs
  • The features page and the API reference state that every page counts the same detections.
Chores
  • One filter, shownDetections() in services/detection-visibility.ts, decides what every count, chart, report and export shows; internal readers (the detection watcher, retention, rarity history, calibration, model comparison, Prometheus metrics) keep every row. shown-detections.test.ts checks each counting endpoint against a detection of each kind.

0.77

0.77.3

Features
  • The Detections page's By species view says how many tentative detections it leaves out, and of how many species, gives the team's rule for counting them and how long they are kept, and links to them in the full list.
  • The Timeline draws tentative detections hatched and dims species heard only tentatively; its subtitle, counts and cells give the number of tentative detections, and a legend under the grid gives the team's rule.
Fixes
  • The tooltip of a tentative detection's badge said it awaited "a second detection" whatever the team required; it gives the team's required hits, window and high-confidence bypass.
Docs
  • The API reference lists /detections/summary and /detections/species-timeline with their tentative counts, and the list's start, end and includeTentative parameters; the features page describes tentative detections and today's Timeline.
Chores
  • The detection watcher and the detection pages read a team's aggregation rule through one helper, services/temporal-aggregation.ts; tentative-detections.test.ts covers the three endpoints.

0.77.2

Docs
  • The docs site's Changelog pages show each release's changes as bullets grouped by type (Upgrade notes, Features, Fixes, Security, Performance, Docs, Chores), each type with its own colour.
  • Every earlier release note, from 0.1.0 on, now lists its changes as classified bullets instead of prose, keeping the versions, identifiers and numbers of the original notes.
  • The release protocol describes the format, with the types and their order.
Chores
  • changelog-format.test.ts fails on a release note written as prose, an unknown heading or one out of order.

0.77.1

Docs
  • Each changelog page opens with a switch between the three components (hub, Raspberry Pi satellite, Android phone) showing their current versions.
  • The home page lists the latest release of each component: the Pi and phone changelog pages were only in the sidebar's last section.
  • The settings reference shows the levels of every setting as badges, check marks gone.
Chores
  • The shared UI theme library now carries the hub's version number (it had stayed at 0.29.0), so every shipped component has a number through one of the three release streams.

0.77.0

Docs
  • The documentation site takes the app's look: its olive and caramel palette, fonts and wordmark, dark by default with a switch for light, and a menu on narrow screens.
  • Badges mark what a passage applies to, one colour per kind: the level (hub, team, account, satellite), the lowest role allowed, where it runs (web app, phone, Raspberry Pi) and the release it arrived in.
  • A new Changelog section has one page per component with a version number (the hub with its web app, docs and inference worker, the Raspberry Pi satellite, the Android phone), drawn from CHANGELOG.md.
  • Every version the pages mention becomes a badge linked to its entry in the Changelog section.
  • The generated settings and permissions tables show levels and roles as badges.
  • The home page's feature cards describe the product as it is today.

0.76

0.76.1

Fixes
  • Backups now mirror every storage bucket, read from MinIO at each run: the fixed list had left field-note photos and the app releases out.
  • Restoring a backup brings back every mirrored bucket.
Security
  • IP addresses leave the activity log after 12 months: an hourly sweep clears them from older entries (the entries stay); the privacy page says so.
  • The hourly sweep also deletes expired remembered devices and password-reset tokens, which held an address.

0.76.0

Features
  • Every hub has a privacy page at /privacy, for guests and signed-in people: what it keeps about people and satellites and why, who sees it, which outside services it uses, how long each thing stays, cookies, and how to export, correct or delete your data.
  • What varies from one hub to another comes from the hub itself (how long recordings stay, backups, eBird, iNaturalist, email); the name and contact of whoever runs the hub are set in Hub settings, Access (a name up to 120 characters, an email address or an https:// link).
  • The privacy page is linked from the sign-in pages, the public pages, the user menu, the Account page and the phone.
  • A phone may ask for On-satellite inference from its own settings screen, within the hub's and the team's locks.
Security
  • Deleting an account also clears the failed sign-ins typed with the person's address, their name and address in the details of entries about them and the invitations sent to them, and deletes their field-note photos.
  • Positions sent to eBird, Open-Meteo and Nominatim are rounded to about 1 km (Nominatim received the exact position for public team pages).
Chores
  • The broker no longer recreates the retired shared satellite role at start; its last 14 logins, all for deleted satellites, were removed from the live broker.

0.75

0.75.1

Security
  • A satellite's configuration history names who made a change instead of showing their email address: any viewer of the satellite reads it, and teammates see names, not addresses.
  • Deleting an account also clears the address the configuration history kept for that person (the link to the account was already cleared; production held no such leftover).

0.75.0

Features
  • Minimum confidence and the range filter gain a satellite level (migration 104): a satellite's page can set its own, within the team's and the hub's locks.
  • A team can keep its satellites on its minimum confidence and range filter values (padlock on Team settings, Detection).
  • Minimum confidence and the range filter apply wherever a satellite's chunks are analysed: the satellite receives them for on-satellite inference as before, and the hub's worker now reads them per satellite from satellite_settings_effective.
  • Values on the satellite card are formatted as on the team and hub pages, percentages included, and a percentage is typed as one when edited.
Chores
  • satellite_settings_effective is a view generated from the registry like tenant_settings_effective, compared with the resolver on random values and locks.

0.74

0.74.4

Security
  • Security maintenance before the hub is shared: the dependencies that face the internet move to fixed versions, namely fastify 5.12, @fastify/websocket with ws 8.22 (memory exhaustion from tiny fragments), nodemailer 9.1 (quadratic address parsing), sharp 0.35.4 (libheif), @simplewebauthn/server 13.3.3, bcrypt 6 (bundled builds, so no tar at install), mqtt 5.16, react-router 7.18 on the web and the phone, express 4.22.3 and markdown-it 14.3 on the docs site, and the packages beneath them within their ranges.
  • fastify 5.12 reads a bare proxy hop count as "trust nobody", which would have given every visitor nginx's address and so one login limit for everyone: the hub now trusts at most OTAVI_HUB_TRUST_PROXY_HOPS proxies, each on a private network.
  • The web app's Content-Security-Policy is enforced (it was report-only, and no page showed a violation).
  • nginx and the docs server no longer announce their versions.
Chores
  • A test checks that each visitor keeps their own address instead of nginx's.

0.74.3

Features
  • The team picked last is kept in the browser: pages that need one team (Activity log, Team settings, Field notes, alert rules) go back to it after a reload instead of the first team in the list.
  • People who are not hub admins start on the team picked last.

0.74.2

Features
  • The Activity log picks a team by itself, as Team settings and Field notes do: on "All teams" it asked to pick one, which on a phone meant finding the selector in the menu. While the page shows, the selector now moves to the team picked last (else the first) and hides "All teams".

0.74.1

Fixes
  • The sidebar and its Team admin links come back: in 0.74.0 the app's shell checked permissions against contexts it provides itself, so it read no user and hid Activity log, Members and Team settings from everyone, hub admins included.

0.74.0

Features
  • The hub-level language setting says it is also everyone's default for species names.
Fixes
  • Pages offer what a role allows: those that offered actions the hub would refuse now hide or disable them, namely alert rule and channel editing (shown read-only, with a line saying the team's admins change them), Team settings reached by URL, voting, pinning, comments, new field notes, and marking alerts read (on the dashboard, dismissing hides an alert for the person only).
Chores
  • Permissions registry, step 4: the web decides what to show from the same declarations the hub enforces, through one check (the shared rule, with the person's role in the team at hand) instead of role tests written page by page.

0.73

0.73.0

Upgrade notes
  • Migration 103: the preference columns hold only what a person chose; a value equal to the built-in default counts as not chosen (the rule used for teams), so nobody's display changes.
Features
  • Personal preferences join the settings registry: each person's languages, date and time formats, start of week, PDF paper size and voting from the detection list are registry settings at the account level.
  • The hub sets everyone's default preferences in Hub settings, Defaults; a person sets their own on the Account page, where "Hub default" goes back to the hub's value.
  • A person who has not chosen a language reads in their team's language within that team (their PDF reports too), else the hub's; the hub's lock on the language binds people as well.
  • The Account page's two preference cards are drawn from the declarations.
Fixes
  • Preference values are checked like every other setting: the old preferences route stored whatever it was sent.
Docs
  • The settings reference lists the new settings.

0.72

0.72.0

Features
  • The hub's audit log labels every action (42 showed their raw name).
  • Each team's activity page reads the new audit entries as sentences, in English and French.
Security
  • Permissions registry, step 3: every change is recorded. Changes that left no trace now leave one: minting a team API key, registering or re-registering a satellite, fetching a satellite's logs, listening to a satellite live, deleting a comment or a field note, refitting the confidence calibration, changing a mobile release's forced update or a model, revoking a remembered device, withdrawing a request to join.
Docs
  • The audit action list of the API reference is generated.
Chores
  • Each route that changes something declares the audit entries it writes and writes them through one helper, which records who acted, from which address and in which team (the team the guard checked), so an entry can no longer miss its team.
  • The checks that routes repeated after the guard are gone.
  • Tests fail when an action lacks its audit log label or its activity page sentence.

0.71

0.71.1

Features
  • Alert badges on the Alerts inbox and the Activity page read as words: built-in types have their own labels in English and French ("First of the day", "Rare species").
  • An alert fired by one of the team's rules shows that rule's name where it showed its internal id ("RULE:BC9E20DB-...").
  • The Alerts inbox's type filter lists the same labels.

0.71.0

Features
  • A readable activity feed: the team's Activity page reads each entry as a sentence naming who did what to whom ("Alice changed Bob's role to member"), in English and French, with an icon per kind of event, where it used to show raw action names and the actor's email address.
Security
  • Team rules decided with Rocky: a team has one owner, which the database enforces (migration 102); a hub admin can make someone owner only of a team without one, and handing a team over runs in one transaction.
  • An admin's role and membership are the owner's to change; admins still manage viewers and members, and the Members page offers the controls on admin rows to the owner only.
  • Photos go on a field note by its author only, like editing it.
  • Teammates see each other's names, not their email addresses, in the verification leaderboard, the field notes and the activity feed; team admins keep addresses in Members, invites and join requests.

0.70

0.70.1

Features
  • A role change also records the previous role.
Fixes
  • The team's activity feed shows membership changes: a role change, a removal, someone leaving and someone joining by invite were logged without their team, and the feed reads a team's own entries only, so it never showed them.

0.70.0

Fixes
  • The role counts in the team the action concerns only: ten routes (invites, member roles and removal, ownership transfer, team API keys, satellite registration, calibration) also checked it in the caller's active team, so a person in several teams was refused where their role allowed it.
  • A hub admin's "all teams" reaches the routes as every team instead of reaching the database as a team id (the alert channel list answered 500).
  • Answers are the same everywhere: 401 when no one is signed in (hub routes answered 403), 403 when the rule refuses the role or the caller, 404 when a team or an item is out of reach (a team named in a request body answered 403).
Security
  • Permissions registry, step 2, one guard: each route's declared action is applied by one guard before any route code; the per-route checks and the satellite-key allowlist are gone, and a route without a declaration stops the hub at start-up.
  • Actions that need a person refuse API keys on all their routes (a key could revoke invites, and reached the insert of a comment or a field note before failing).
  • The password re-checks and the passkey sign-in step are rate limited.
Chores
  • Every route is tested against anonymous callers, a satellite's key, a team key and a person who is no hub admin.

0.69

0.69.0

Security
  • Enforcement is unchanged except for one fix found by comparing the declarations with the routes: scheduled exports, whose delivery holds addresses and webhook URLs, are listed to the team's admins only (any viewer could list them through the API; no page uses the list).
Docs
  • The permissions tables of the settings and permissions reference are generated from the declarations.
Chores
  • Permissions registry, step 1: every action of the hub is declared once in the shared package (52 actions: who may perform it and what gates it), and every API route names the action it performs and where its team comes from.
  • A test fails on a route that names none, on an unused action, and on a declaration that disagrees with the route's guard or with the satellite-key allowlist; ten routes that check the role in two teams are listed for the next step, which enforces the declarations in one guard.

0.68

0.68.2

Security
  • Reads stay in the caller's team (found by the route inventory for the permissions registry and reproduced before the fix): statistics routes that name a satellite (expected species, weather correlation, compare) and the PDF report answer "not found" for another team's satellite; they returned its name and exact position to any viewer who knew its id.
  • The expected-species list marks as detected only what the caller's team has heard; it counted every team.
  • The queue and worker routes, which list every team's jobs and the worker hosts, are for hub admins, as the pages that show them already were.
  • An alert channel's settings, webhook URLs and tokens included, go only to the admins of its team; others see its name and type.

0.68.1

Fixes
  • The satellite detail answers 404 for a satellite out of reach; it said "not found" with a 200.
Security
  • Only a hub admin gets the "all teams" scope: a signed-in person who belonged to no team could send ?tenantId=__all__ and keep it, and since every access check reads that value as hub-wide, such a person could read, change, archive, delete or take over any team's satellites, and reach other routes whose real check is on another team. No account on this hub was without a team, so it was never reachable here; it would have been once self-registration opened, or someone left their last team. Found by the route inventory for the permissions registry and reproduced before the fix.

0.68.0

Fixes
  • A comparison sample rate of 0 now compares no chunk; the worker read it as all of them.
Docs
  • Settings registry, step 6, which completes it: the settings table of the reference is generated from the registry (pnpm --filter @birdnet-ng/hub settings:docs).
  • The documentation catches up: settings and permissions, the API, features, and a roadmap whose released list had stopped at 0.38.
Chores
  • A test fails when the generated settings table of the reference falls behind the registry.
  • What nothing read is gone (migration 101): the two lock flags of 096 (setting_locks holds those locks; the whole-form routes still accept the old field names), the team's secondary languages and bird-ratio filter columns, and the hub's invite-links switch and embedding keys. Nothing a satellite receives changed (144 values compared).

0.67

0.67.5

Features
  • pnpm ops:check now fails when the last backup is older than 26 hours, since a running sidecar said nothing about its schedule.
Fixes
  • Nightly backups run again, and probably ran for the first time: the backup sidecar wrote its schedule with a user column that busybox crond does not take, so every scheduled run failed with "root: not found" and only the run at container start ever happened (reproduced in a throwaway container with both formats). The last backup was 33 hours old; a new one ran at the fix.

0.67.4

Fixes
  • An update request names the latest release of the satellite's own stream (the Pi's, the phone's) instead of the hub's version, in the message, the audit log and the reply to the Update button. The Pi's updater installs the latest Pi release whatever the message says, so no update went wrong; the audit log said "0.67.3" for this afternoon's two Pi updates to 1.6.5.
  • The message sent when moving a satellite to another team names the same release (its own stream's latest), and heartbeats use the same rule to clear a finished update.

0.67.3

Chores
  • Settings registry, step 5: the mapping from the settings to the configuration message satellites receive lives in the shared package, so the hub, the phone and the Pi build it the same way. The hub sends what it sent before.
  • A test proves the defaults the phone and the Pi start from are exactly what the hub sends a team that sets nothing.

0.67.2

Upgrade notes
  • Settings registry, step 4, done: minimum confidence, range filter and language follow the hub for every team that had left them at their defaults (migration 100); only Lattes keeps its own minimum confidence, 25%. Nothing a satellite receives changed (144 values compared), and the inference worker reads the same thresholds as before.
Features
  • A new team follows the hub for every setting, and stores its creator's language only when it differs from the hub's.

0.67.1

Features
  • Settings registry, step 4, second half: minimum confidence, the range filter and the team language get a hub level too. Values stay as they are in this release; nothing a satellite receives changed (144 values compared).
  • Hub settings has a Defaults tab listing every setting a team inherits, with its padlock.
Chores
  • The inference worker and the hub services that read a team's language in SQL now go through one view generated from the registry (tenant_settings_effective, recreated at every start), so they apply the same rule as everything else: the team's value, else the hub's, else the default.

0.67.0

Upgrade notes
  • For the settings a satellite can set for itself, every team value equal to the built-in default now follows the hub (migration 098); no team had moved these off their defaults. Nothing a satellite receives changed: the 3 teams and 5 satellites resolve to the same 144 values, and no configuration history was written.
Features
  • Settings registry, step 4, first half: the settings a satellite can set for itself (audio filter, silence gate, bird threshold, the five pre-upload drops, the outbox, the heartbeat) now have a hub level; Hub settings, Models, lists them with the value every team gets, and a padlock on each keeps teams on the hub's value. Minimum confidence, range filter and language stay team settings until the inference worker and the hub services that read them directly go through the registry.
  • A team follows the hub for anything it does not set: each row offers "Hub default", with a padlock of its own for its satellites.
Fixes
  • Configuration history no longer counts a single-precision copy of a number as a change.

0.66

0.66.1

Fixes
  • Screens that work on one team (team settings, field notes, the alert rules and channels) no longer sit under "All teams": while they show, the team selector hides that option and moves to the team picked last, or the first. Team settings and the alert rules used to show the first team's settings under "All teams" without saying so, and field notes asked to pick a team. The alerts inbox keeps its view across teams.

0.66.0

Features
  • Settings registry, step 3: every setting satellites receive is written through one path, PATCH /api/settings/effective at a satellite, a team or the hub, which changes only the keys it is sent, checks each value against the registry (type, range, whether the level may leave it to the level above) and refuses a new value under a lock from above.
  • The team page's Satellites tab, its detection thresholds and its language, and the hub page's satellite card are drawn from the declarations.
Fixes
  • The team page saves only what changed: a team that opened up before the hub switched public teams off can save again, and the silence gate can go back to 0.
  • The pre-upload drop descriptions give the right class counts.
Chores
  • Locks live in one table, setting_locks (migration 097), which takes over the two on-satellite inference flags. Checked on the live hub: the 3 teams and 5 satellites resolve to the same 144 values as before.
  • The older routes hand their registry fields to the same writer, and a satellite's own settings screen applies only the fields it may set that pass the checks.

0.65

0.65.0

Upgrade notes
  • API callers: GET /satellites/:id/config no longer returns inherited and deviceInference, which the new route GET /api/settings/effective?satelliteId= carries.
Features
  • Settings registry, step 2: GET /api/settings/effective?satelliteId= gives every setting at a satellite: its value, the level it comes from, what the satellite gets by setting nothing, who locks it, and the satellite's own value.
  • The satellite page's configuration card is drawn from the shared declarations: values carry their units, the silence gate and bird threshold are hidden while the filter is off, and a value that can come from the hub names its source.
Chores
  • The registry test compares what the page shows with the 0.64.3 route on 150 random combinations.

0.64

0.64.5

Security
  • The rest of the security review, each fix reproduced first: live listening checks the satellite's own team, since a member of another team could join a team's live session, or open it under the wrong team and break that team's listening.
  • Share links carry only what the share page shows: they also gave anyone with the link the team, the satellite's name and its exact position.
  • The audio of a share link now needs a promoted detection, like the share page.
  • Votes and annotations need a signed-in person: a team API key could vote in anyone's name.
  • A hub admin's account deletion now does what self-deletion does (a team's sole owner must hand it over first, the person's satellites are disconnected, their email and IP leave the audit log); it used to remove the row only.
  • A second hub-settings route that skipped validation, the audit log and the push to satellites is removed.

0.64.4

Chores
  • First step of the settings registry, with no change in what satellites receive: every setting a satellite gets is declared once in the shared package (default, levels, locks, range, storage), and one rule resolves them all, the one on-satellite inference already followed.
  • A test keeps the previous resolver as the reference and compares both on 150 random combinations of hub, team and satellite values.

0.64.3

Security
  • Security fixes from the 2026-09-25 review, each reproduced first: a satellite's own key could list the team's satellites and register new ones through a trailing slash (/api/satellites/); every satellite route now has to name the key's own satellite.
  • A satellite's device id, which proves possession when it re-registers, was shown to every member of the team; it now reaches only those who manage that satellite.
  • A team's admin API key could read another team's pending invites and member list; those lists now pin keys to their team.
  • Edits of existing rows (alert rules and channels, scheduled exports, alert acknowledgement, votes, annotations, comments, pins, field notes, a satellite's settings lock) checked the role in the caller's active team: an admin of one team who was only a viewer of another could change the other's rules, and a team's own admin was refused while another team was active. They now check the role in the edited row's team.

0.64.2

Features
  • One vocabulary everywhere people read it: the hub-wide level is the hub (Hub settings, hub admin, Hub default), a registered phone or Pi is a satellite, and device only means the computer or phone someone signs in from.
  • The inference setting is called On-satellite inference.
  • A satellite's settings lock now says what it does: only admins can change its settings.
Docs
  • The settings and permissions page defines this vocabulary.

0.64.1

Fixes
  • Saving Platform settings › Access no longer puts back what the other tabs saved since the page was opened: an on-device inference, retention or image change made meanwhile used to be reverted, and pushed to every device.
Docs
  • The documentation gains a page listing every setting and permission with the levels where it lives.

0.64.0

Features
  • On-device inference is decided platform > team > device: the platform sets it for the whole hub and says whether teams may choose their own, a team says whether its devices may choose theirs, and a level that does not allow it wins over everything below (Platform Settings → Models, Team settings → Satellites, each device page).
  • The default shown beside each override on a device page is now what the device inherits.
  • Team or platform changes reach online devices as soon as their configuration changes.
Chores
  • The node and nginx images are pinned to their minor versions.

0.63

0.63.0

Upgrade notes
  • The phone asks the replacement question from 1.26.0, so a reinstall no longer starts a new entry on team defaults.
Features
  • A registering device can take over an entry it replaces, keeping that entry's id, settings and history; the entry it used until then is archived with its data and its credentials revoked.

0.62

0.62.5

Performance
  • The web photo viewer loads a full-resolution copy only when it weighs 8 MB or less; the larger scans (54 main photos and 141 gallery photos, up to 172 MB) show the display copy. The image routes take maxBytes for that.

0.62.4

Features
  • The species gallery lists the size of each photo's full-resolution copy. The phone uses it to fetch only copies of 8 MB or less when you zoom; a few are 20 to 170 MB scans.

0.62.3

Fixes
  • The main photo of a species now credits its author through its Wikimedia Commons file page, in the web lightbox and in the phone's new species sheet; it used to link to the bare image file.

0.62.2

Features
  • The smoke check now verifies that every inference worker can write its model cache. On this hub the folder had been root-owned since June: Docker creates a missing bind folder as root and the workers run as PUID, so every job fell back to the bundled model and computed no Perch embedding.
Docs
  • The deployment guide says which storage folders to create, and for whom, before the first start.

0.62.1

Upgrade notes
  • This release replaces 0.62.0, which was tagged but never deployed.
Fixes
  • The per-device broker roles now switch safely: the hub reads each device's roles first and writes only what differs, attaches the new role and confirms it before removing the old one, and repairs drifted permissions, so a restart neither rewrites the broker store nor disconnects a device that is already in order. Every step was rehearsed on a copy of the live credential store.
  • Re-registering a device whose broker login was dropped when its owner left the team now recreates the login instead of returning credentials for a login that no longer existed.
Security
  • The hub warns at every start when a listed platform-admin address has no account yet.
  • getting-started now generates every secret the startup guard checks.

0.62.0

Upgrade notes
  • The hub refuses to start with placeholder or short secrets and names them (OTAVI_HUB_ALLOW_WEAK_SECRETS=1 keeps a development hub going).
Security
  • The last items of the pre-public security audit are in this release.
  • Platform admin by listed email is granted at registration and at a password login only; a profile edit can no longer claim a listed address.
  • Every device's broker client now has its own role whose topics name its team and id; the shared wildcard role is retired and older clients are moved over when the hub starts.
  • Log requests use random ids.
  • A TOTP code is accepted once.

0.61

0.61.0

Features
  • A Prometheus scrape endpoint at GET /api/metrics, on once OTAVI_HUB_METRICS_TOKEN is set: inference queue by state, worker count, pending and failed chunks, detections, devices by status and the seconds since each device's last heartbeat, storage by kind against the cap, process memory and uptime, plus a per-collector error flag.
  • The smoke check probes the metrics endpoint.
Docs
  • See docs/reference/metrics.md for a scrape config and alerting ideas.

0.60

0.60.2

Fixes
  • The Pi registration screen prints the OTAVI_SAT_* names that Pi 1.6.0 reads.
Docs
  • The P4.53 roadmap entry finally carries its done mark.

0.60.1

Fixes
  • Bucket listings work again on large buckets: the XML parser behind the MinIO client refuses pages of 1000 keys, so every listing over 500 objects had been failing, silently in the retention size sums, the system storage row and the image-cache clear, and loudly in the new orphan sweep. Listings now page at 400 keys.

0.60.0

Upgrade notes
  • A new pnpm --filter @birdnet-ng/hub audio:orphans reports the audio files that earlier deletions left behind; --delete removes them.
Features
  • A retired phone or Pi can now be archived instead of deleted: it leaves the fleet page, the pickers, the map and the public counts and stops raising offline alerts, while its detections, audio, history and credentials stay.
  • A heartbeat or a re-registration brings an archived phone or Pi back on its own.
  • Delete remains the destructive path and now says what it costs (detections, chunks, audio size) and asks for the device name typed back.
Fixes
  • Deleting a device now removes its audio files from the bucket, which it never did before.

0.59

0.59.1

Fixes
  • Teams that were still at the old 0.003 silence floor are now moved to 0 as intended: the 0.59.0 migration compared the 4-byte float column with an exact literal and matched nothing.

0.59.0

Upgrade notes
  • Teams still at the old 0.003 silence floor follow the new default; a value set on purpose is kept.
  • Pair with Pi 1.5.0 and phone 1.20.0, which change the YAMNet rule on the devices: an excluded category now drops a chunk only when the bird is weak, the bird score includes the specific call classes, and whistling, breathing and domestic animals no longer count against birds.
Fixes
  • The on-device silence floor is off by default (filter_min_rms 0). Real microphones sit far under the old 0.003 floor: 72% of the phone chunks with bird detections and 99% of a Pi's were below it, and a Pi that kept the gate on never sent a chunk.

0.58

0.58.0

Features
  • Every device now has a configuration history: one entry per change of its effective configuration, with what caused it (a web edit and by whom, the phone's own settings screen, a team settings change, drift noticed on reconnect), the raw request, and each field's old and new value.
  • A device changing its own settings is also written to the activity log, which was not the case before.
  • The satellite page shows the configuration history under the configuration card; the API serves it at GET /api/satellites/:id/config/history.
  • A baseline snapshot is written for every existing device on first start.

0.57

0.57.1

Chores
  • The store images are pinned by exact version tag rather than by digest: postgres:17.9-alpine, redis:7.4.8-alpine and eclipse-mosquitto:2.1.2-alpine. Same versions as before, readable in the compose file, and a bump is a one-line diff.

0.57.0

Upgrade notes
  • Point OTAVI_BACKUP_REMOTE at a private bucket or an rclone crypt remote, since the backup tree now holds secrets.
Features
  • New pnpm ops:check smoke check to run after every deploy: container states, hub readiness with per-dependency latency, an MQTT login with the hub credentials and a refused bogus one, the MQTT-over-WSS path through the public broker name, the four hostnames through the proxy, public stats, leftover pre-rename variables and each device's last contact; exit 1 on a failure.
  • The backup sidecar gains a fourth tier: the Android release keystore and the hub .env land under secrets/ (0600), so a rebuilt host can sign phone updates and read its sealed secrets again; the runbook covers the restore.
Chores
  • Postgres and Redis are pinned by digest like MinIO and the broker.

0.56

0.56.0

Upgrade notes
  • Every setting is now named OTAVI_ (HUB, DB, REDIS, S3, MQTT, AUTH, SMTP, EBIRD, BACKUP, MOBILE, WORKER); only PUID, PGID, TZ and COMPOSE* stay bare.
  • Before starting this version run scripts/env-rename.sh .env (keys and ${...} references are rewritten, the original is kept beside it); the hub lists any old name it still finds with its replacement.
  • Containers no longer receive the whole .env: docker-compose.yml hands each service exactly what it reads.
Fixes
  • The broker image is pinned by digest (2.1.2) and runs as PUID:PGID: the floating :2 tag had moved it to 2.1 unnoticed, and a recreate refused every MQTT login for about 25 minutes until the pin and the uid were fixed.

0.55

0.55.0

Upgrade notes
  • Environment variables are named OTAVI_* instead of BNG_*, same meaning and values; containers, certificates and data stay in place.
  • Before starting this version on an existing install run cp .env .env.bak-rename && sed -i -E 's/\bBNG_/OTAVI_/g' .env.
  • The hub logs a warning at startup for any BNG_* key left behind and ignores it.

0.54

0.54.0

Features
  • Thumbnails are cut around the bird everywhere. The bird detector only ran on each species' main photo and only the PDF report used its result; every thumbnail in the app was a plain centre crop, which cut off birds sitting to one side. Species avatars in every list, the species profile's gallery strip, and the welcome and public team showcases now use the same head-first placement as the report.
  • Gallery photos are analysed too, in the background after the main photos, every species' first gallery photos first. Until a photo has been analysed its thumbnail uses plain saliency, then switches on its own.
  • Platform settings, Images counts both kinds of photos (main and gallery); Re-run detection covers both.
Performance
  • Thumbnails are served at thumbnail size: a 60 px avatar used to download the full 800 px photo (70 to 110 KB); it is now a square of about 3 KB.

0.53

0.53.2

Performance
  • Pages over long time windows are fast again: with All time selected, the dashboard, analytics, map, detections list and highlights each took 3 to 7 seconds on a team with 157,000 detections; they now answer in 0.14 to 0.5 seconds. The per-detection check that hides non-bird categories (engines, voices, frogs, insects) held a sub-select that the database could not inline, so every detection paid a full function call. Results are identical on every existing detection; nothing to configure.

0.53.1

Features
  • Members page: Make owner hands a team to another member (the team's owner, or a platform admin, with a confirmation); the hub route existed but no screen used it.
  • In All teams the Members list can be sorted by team.
Fixes
  • Handing a team over makes the previous owner an admin; the route demoted the caller instead of the actual owner, so a platform admin handing over someone else's team would have left two owners.
  • Platform admins now see every membership in the Members list, their own included (marked Platform admin, read-only), so a team owned by an operator no longer looks ownerless; team admins still do not see the hub's operators.
Security
  • A platform admin is admin in every team, member of it or not. The session previously fell back to viewer for a team they did not belong to, which would have locked operators out of admin actions on teams created by other people.

0.53.0

Features
  • Public team pages: a team can publish a read-only page at /t/<team> that anyone opens without an account, with its species, detection and station counters, the most-heard species with credited photos, recent firsts (new here, rare, first of the season), a 30-day activity line, and a join button when the team welcomes newcomers.
  • Public team pages come in English and French, with species names in the visitor's language and link previews in chat apps.
Security
  • Public team pages are private by default, twice: a hub-wide switch Allow public team pages (Platform settings, Access, off) and a per-team Publish this team's page in Team settings. Turning a page off takes effect at once.
  • A public team page never carries device or member names, coordinates, audio, confidence or times finer than a day; it counts confirmed detections only and respects what the team hides in the app. Towns appear only if the team switches them on separately.
Performance
  • The welcome page's counters and species showcase answer in about 0.1 s on a cold cache instead of 5 s on a hub with 150k detections.

0.52

0.52.0

Features
  • Teams can open up. A new hub-wide switch, Allow teams to open up (Platform settings, Access, off by default), lets a team's admins choose how people join: invitation only (as before), on request (anyone signed in can ask, an admin accepts as viewer or member, or declines) or open (join at once with the role the team chose). While the switch is off every team stays invitation-only.
  • A team may list itself in a directory that shows its name, description and member and device counts, nothing else, and gets a link to share that also works unlisted.
  • Find a team on the no-team welcome screen and under Account, Your teams.
  • Members page: requests to join, a blocked list, and block when removing or declining someone (an admin's invitation lifts it).
  • The invite field on the Members page takes up to 50 pasted addresses for a class or a volunteer list.
Fixes
  • The no-team welcome screen no longer offers Create a new team on hubs that disallow it.
Security
  • The long-lived key a phone or Pi holds now reaches only what devices call (its own satellite, its detections feed, species names and photos, update checks); it could previously vote, comment, read other devices' audio and browse team data.
  • Invitation emails escape the team name and inviter, which were inserted raw into the HTML.

0.51

0.51.0

Upgrade notes
  • Devices registered before this release belong to the team and stay admin-managed, so nothing changes for existing setups.
Features
  • Devices now have an owner: the member who registered a phone or Pi manages it (rename, key, config, recording profile, forced update, logs, delete); team admins manage every device.
  • New team setting, Who adds recording devices: members (default) or admins only, for teams that place and configure their hardware centrally.
  • New Leave team button on the account page; owners transfer ownership first.
Fixes
  • Saving team settings works for a team whose settings page had never been opened (it silently saved nothing before).
Security
  • When someone is removed from a team, leaves it or deletes their account, their devices are disconnected by default so a phone never keeps uploading to a team its owner can no longer see. Admins can choose to keep shared hardware running; the detections stay with the team either way.
  • Device actions are now authorized against the device's own team: previously someone who was admin in one team and viewer in another could administer the second team's devices.
  • Registering with the name of an existing device no longer hands out that device's credentials unless you registered it or are an admin.

0.50

0.50.0

Upgrade notes
  • The classifier is still BirdNET; internal identifiers (MQTT birdnet/ topics, buckets, BNG_ env, package scope, Android package) are unchanged, so nothing to migrate.
  • Operators who set WEBAUTHN_RP_NAME or SMTP_FROM in .env may want to update those two display strings.
Features
  • The product is now Otavi (oto- ear + avi- bird) everywhere a person reads it: web and phone chrome, emails and alert headings, PDF report, exports, docs site, passkey prompt.
  • First brand identity: a feather on a trunk slice (Phosphor Icons' feather, MIT, on an olive disc inside three caramel rings that thicken outward) and a wordmark where the disc is the o.
  • Team members can register their own recording devices (was admin only), so a family or a class no longer needs everyone invited as admin. Viewers still cannot; deleting devices, forced updates and logs stay with admins.
  • Welcome page showcase tiles are square whatever the photo's orientation.
Chores
  • Favicon, PWA manifest and icons, docs logo, PDF title-band mark and every Android launcher, notification and splash asset are generated from four masters by pnpm brand:assets (--check verifies them).

0.49

0.49.1

Features
  • The verification review card auto-plays each new clip (persisted toggle).
  • The verification review card draws the model's call segments on the spectrogram.
  • The verification keyboard shortcuts gain Space (play/pause) and R (replay).
  • Each detection in the verification queue links to xeno-canto reference recordings and the species page.
Fixes
  • Verification queue: the review card mounted a render-only spectrogram, so reviewers could see the call but never hear it; it now uses the full player (play/pause, loop, drag-to-select, download, signal boost).

0.49.0

Features
  • /welcome is now a landing page in the app's theme: hero with an animated listening visual and this hub's live numbers, a three-step "how it works", a "Heard on this hub" photo showcase of the most-recorded species (Creative Commons photos credited via their source page), a nine-term vocabulary section, then the get-started timeline.
  • The landing page is backed by two public aggregate endpoints (/api/stats/public, /api/species/showcase) that expose no per-team, place or time detail.

0.48

0.48.2

Features
  • Every user-facing string now says "team" (fr "équipe") where the app mixed tenant, team and organisation: sidebar group, settings page, selectors, audit labels and platform pages included.
  • The new public GET /api/auth/registration-options is also consumed by the phone.

0.48.1

Features
  • Login and register forms carry username / current-password / new-password autocomplete hints and field names so browser and Android password managers recognise them; backup-code fields opt out of autofill.

0.48.0

Features
  • New public Getting Started page at /welcome, in the app's own look: six steps from account creation to the first detection, with the Android app download button, the hub address to type into the phone, an EN/FR toggle for visitors, and "go further" pointers.
  • The Getting Started page's first step adapts to the hub's registration mode (open vs invitation-only) through the new public GET /api/auth/registration-options.
  • The Getting Started page is linked from the login page, the create-team welcome screen and the dashboard onboarding checklist.
Docs
  • The docs-site walkthrough now points to the Getting Started page as the shareable link.

0.47

0.47.0

Security
  • Pre-public security hardening from a full read-only audit with live verification; details in docs/reference/security.md.
  • Rotated the broker's dynsec admin + hub passwords off the shipped admin/admin defaults (they authenticated over the public MQTT-over-WSS listener).
  • Browser hardening: @fastify/helmet (CSP, HSTS, nosniff, frame-deny) on the API, a shared Traefik headers middleware on the app/docs/mqtt routers, and the same headers in nginx (SPA CSP ships Report-Only for one release).
  • CORS pinned to the app origin instead of reflecting any origin.
  • Fixed reflected XSS on the two text/html surfaces: the Open Graph share endpoint and the docs server (escaping, version allowlist, path-traversal guard).
  • MQTT ingest derives tenant/satellite identity from the ACL-pinned topic and the database, not the message body, so a device can no longer write into another tenant or forge object storage keys.
  • SSRF guard on webhook alert channels and scheduled-export delivery: https only, DNS-resolved, private/loopback/link-local and bare hostnames rejected, redirects not followed.
  • trustProxy bounded to the proxy hop count so a spoofed X-Forwarded-For no longer bypasses the login limiter or forges audit IPs.
  • @fastify/rate-limit on /auth/*, tenant creation and invites.
  • Session revocation on password change/reset via users.token_version (migration 086).
  • jwt.verify pins HS256; token comparisons are constant-time.
  • Login no longer reveals whether an account exists or is locked.
  • Invite email-binding and TTL clamp.
  • Tenant API-key expiry.
  • Multipart limit dropped to 10 MB with per-route raises for APK/model uploads.
  • Field-note photos are magic-byte-sniffed and served with nosniff + Content-Disposition.
  • CSV exports neutralise spreadsheet formula injection.
  • Alert emails and JSON templates escape user-controlled values.
  • Health readiness reveals only the verdict to anonymous callers.
  • WebSocket per-principal connection cap and Origin check.

0.46

0.46.0

Features
  • The create-team welcome screen is translated en/fr.
Fixes
  • Tenant creation seeds the system alert rules (the removed /auth/create-tenant duplicate silently skipped seeding).
Security
  • The WebSocket event stream requires authentication; anonymous clients previously received every tenant's live detections and alerts and could start live audio from any satellite.
  • Non-platform sockets on the WebSocket event stream are pinned to their tenant; live listening needs member+ there.
  • Invite create/revoke and member role-change/removal now authorize against the target tenant.
  • Role allowlists on invites and API-key minting close owner-escalation paths.
  • Stats and tenant-settings endpoints are tenant-scoped for everyone but platform admins (an omitted tenantId no longer returns platform-wide data; the leaderboard leaked user names and emails cross-tenant).
  • Reading votes requires a user session.
  • Satellite-scoped keys are refused on export endpoints.
  • The application owns authentication end to end: no app route sits behind reverse-proxy SSO anymore.
  • Tenant creation is a single endpoint, POST /api/tenants, gated by the allow_tenant_creation platform setting; the creator becomes owner and slugs are validated.
  • GET /api/tenants lists only the caller's memberships (it was public and listed every tenant).
Docs
  • New "Your First Detections" onboarding guide on the docs site.

0.45

0.45.0

Features
  • Dashboard rework: period tiles with deltas (detections today vs yesterday same-time in the viewer's timezone, species this week vs prior week; all-time totals in the sub lines).
  • The dashboard's Awaiting-review and Satellites tiles are clickable.
  • The dashboard gains a Highlights card (new species, rare flags, first-of-season returns of the last 7 days, with photos) via the new /stats/highlights endpoint.
  • Platform-admin System health strip on the dashboard: DB pending chunks, queue waiting/active, failed jobs, worker count, storage vs soft cap.
  • Biodiversity indices and the fleet card moved from the dashboard to Analytics / Satellites.
  • /stats/dashboard accepts the client tz.
  • APK release uploads announce on the per-satellite MQTT update channel so running phones surface the update banner within seconds.
Fixes
  • /api/mobile/latest compares SemVer so devices ahead of the latest release are not nagged, with force_update as the explicit rollback path.
Performance
  • /stats/dashboard computes all counters in a single scan.

0.44

0.44.2

Features
  • Alert badges (sidebar link + header bell) now update the moment something changes instead of on the next 30-second poll: acknowledging an alert, bulk-acknowledging the inbox, and new alerts arriving over WebSocket all trigger an immediate recount through a shared invalidation event.

0.44.1

Fixes
  • An inference-queue watchdog now sweeps every 15 minutes for chunks stuck "pending" with no live queue job: it retries jobs parked by transient failures, re-enqueues jobs that were lost outright, and permanently fails poison chunks after three recoveries. It codifies a real incident: Docker DNS blips last spring parked 840 jobs that sat unnoticed for four months (recovering them yielded 398 correctly-backdated detections across 25 species).
  • The species-image backfill no longer loops on species whose Wikipedia page has no usable photos: they're marked extras-exhausted like any other dead end. One such species (Balearic shearwater) had been probed once a second for hours, burning a third of the authenticated Wikimedia request budget on nothing.

0.44.0

Features
  • Navigation rework: the sidebar now reads as three activities, Observe (Dashboard, Detections, Timeline, Species, Map, Satellites), Analyze (Analytics, Compare) and Contribute (Verification, Field notes, Alerts, Activity log); Tenant admin is slimmed to Members and Settings.
  • Satellites and Alerts moved out of the admin section: fleet status is viewer information, and the alerts inbox was already reachable by everyone through the bell.
  • The Detections entry is two views behind a toggle: the species-grouped overview and the full filterable list, which had been built but never routed.
  • Species profiles link into a species-scoped variant of the full detections list ("Full list").
  • Dashboard cards click through to Analytics and the full detections list.
  • The "Activity" page is renamed "Activity log".
  • The dev-only design-system page left the nav.
  • The Detections toolbar settled into two rows.
  • The Map defaults to the detections layer only.
  • "All time" joins every time picker: no time filtering at all, with the endpoints that can't take an unbounded span clamping sensibly (the species timeline starts at your first detection; weather caps at the last year).
  • Every async block app-wide now shows skeleton placeholders instead of "Loading…" text that jumped on arrival.
Fixes
  • Category visibility is now consistent everywhere: the per-tenant Detection-visibility toggles (frogs, insects, machine noise, human voice, other animals) previously only filtered the detection lists, while the species catalog, top species, timeline rows, map markers, analytics charts, dashboard tiles, verification counters, and PDF reports all leaked hidden categories. The same per-row predicate now applies across every one of those surfaces, so a category you hide is hidden everywhere, and all counts agree.

0.43

0.43.0

Features
  • One time context, everywhere it makes sense: the window you pick follows you across Timeline, Map, Compare, Analytics, and both Detections views, with the same control, same toolbar bar, same right-edge position on every page. Deliberately excluded: non-temporal pages (Species catalog, Satellites, work queues) and the 12-month migration view.
  • The Detections overview was migrated from legacy markup to the design system.
  • Analytics cards (trend, hourly activity, weekly heatmap, top species, biodiversity, weather) and the Detections list now honor the time window; Detections data exports scope to it too.
  • Every page's filters and sorts now survive refresh and navigation (per-tab persistence).
  • Toolbars follow one ordering convention (search, scope filters, toggles, sort).
  • The Dashboard's recent detections list shows each species once, at its latest detection.
  • The PDF report becomes a composable report builder with a single home: a PDF icon in the app header (members and up) opens a modal showing the exact window it will cover, an optional satellite scope, and section checkboxes (site map, activity chart, species list, species notes).
  • Three new PDF report sections: a tz-aware hour-by-weekday heatmap, a weather correlation chart (detection bars, max-temperature line, precipitation from Open-Meteo's historical API), and a biodiversity indices table (Shannon, Simpson, evenness per satellite with a combined row).
  • PDF species cards now say WHERE each species was heard ("Berkeley · Costa Mesa, United States") instead of repeating first/last dates: positions collapse to ~1 km cells, reverse-geocoded once ever via Nominatim into a permanent per-language cache (migration 085), with the dates as fallback.
  • Scheduled exports gain pdf as a format: each run renders the report for the window since the previous run, in the tenant's language, with configurable sections, attached to email deliveries and base64-encoded for webhooks.

0.42

0.42.0

Features
  • Species notes: every detected species now carries its Wikipedia intro in each configured language (English plus whatever users and tenants have set; other-language articles resolved through Wikidata sitelinks) and a set of structured facts.
  • The species profile page gains an "About" card with the text, attribution links, and trait badges.
  • The PDF report gains a second export variant, "with species notes", that lays each species out as a full row: detection data on the left, the intro text and a facts line on the right.
  • Translated names in both PDF variants are prefixed with a small language shortcode (EN, FR).
  • Species notes and facts are fetched continuously by the image worker into species_descriptions / species_facts (migrations 083-084); misses are marked and never retried in a loop.
  • Species facts come from two complementary sources, Wikidata and the vendored AVONET dataset: Wikidata provides IUCN conservation status, wingspan, and mass ranges, with egg-role claims filtered out (the whooper swan's "331 g" was its egg) and preferred-rank claims honored.
  • The vendored AVONET morphology dataset (Tobias et al. 2022, CC BY, eBird taxonomy, ~10,700 species) fills the gaps: species-mean weight (coverage 41% → 95%), wing and tail chord length, habitat, trophic niche, and migration behavior, all translated.
  • "Least concern" is deliberately not shown: only elevated conservation statuses get ink.

0.41

0.41.0

Features
  • One time selector to rule them all: Timeline and Map share a new TimeRangeControl with rolling presets (last hour through last year, resolved at fetch time so they slide), calendar periods (hour through year, now including quarters), and custom ranges with start/end times down to the minute (end is inclusive: 23:59 means end of day).
  • The time selector's arrows shift calendar periods by one unit and freeze rolling/custom windows into a range shifted by its own span; "Now" resets.
  • The time selection persists across pages.
  • PDF detection report: GET /api/export/report.pdf (also in the Timeline export menu) renders a print-ready report for any window, with a dark-green title band, summary stat cards, an OSM site map with cluster counts, a full-width activity chart with offline shading derived from audio-chunk coverage, and a species list two cards per row with thumbnails, every configured language's name plus the scientific name, detection counts, colored average/best confidence, and first/last heard.
  • PDF report labels are in English or French from the reader's primary language; all dates honor the client's timezone.
  • PDF paper size follows a new Account preference (A4 or US Letter, migration 082), with a ?size= override per request; Letter trims the map height so map and chart still share page 1.
  • Smart thumbnail crops: the image worker now locates the bird in every species photo with COCO-SSD (pure-JS TensorFlow, no native deps) and stores a padded bounding box (migration 081).
  • Thumbnail consumers place the square window head-first: tall boxes anchor to the top, wide boxes find the head by probing saliency on the box's top strip, and images without a detectable bird fall back to saliency cropping.
  • A Platform Settings card exposes the smart-crop toggle, progress stats, and a re-run action.
Fixes
  • Map and stats endpoints now count stitch primaries only, so Timeline and Map report the same detection count for the same window.

0.40

0.40.0

Features
  • Custom date ranges everywhere the period picker lives: Timeline, Compare, and Map gain a "Custom" period with a range calendar (click start, click end, hover previews; prev/next arrows shift the range by its own span, forward disabled at today).
  • /detections/species-timeline, /stats/compare, /stats/map, and /stats/map/detections accept explicit start/end ISO params.
  • The species-timeline endpoint picks its bucket width adaptively (15 min ≤2 days → 1 week beyond 400 days) and returns bucket_seconds for the axis.
  • The Map heatmap weights by cell density in aggregated mode.
  • Both detections + satellites Map layers are on by default.
  • The Map viewport auto-fits the fetched window's extent (never re-framing on zoom-driven re-fetches).
Fixes
  • The period picker popover now renders through a Mantine Popover portal, fixing the dropdown being clipped by the toolbar card's overflow: hidden.
  • Map satellite marker stats (popup counts, top species) follow the selected time window instead of being all-time.
  • Map pins are position-only (a count inside the pin read as "detections here", wrong for a phone that recorded elsewhere).
  • Detection points on the Map fall back to the satellite's site position when they carry no GPS fix.
  • The device-inference ingest path (hub 0.39.0..0.39.3) dropped latitude/longitude on detections; they are now stamped at ingest, and migration 080 backfills historical rows from their chunk's GPS (1566 detections repaired, e.g. a phone's California recordings that rendered at its home site).
  • A device-inference hit without a GPS fix no longer wipes the satellite's last known position.
Performance
  • Above 2000 detections in a window, the hub returns zoom-keyed grid-cell aggregates (centroid, count, species count, last detection) instead of individual Map markers: counts are true totals at any scale and the payload stays bounded (153k detections ≈ a few KB instead of ~25 MB).

0.39

0.39.3

Features
  • Satellite config gains inferenceRangeFilterThreshold (from tenant_settings.range_filter_threshold) in resolveConfig and the ConfigMessage, so device-inference clients (Pi + mobile) apply the same lat/lon/week range filter threshold as the hub worker. Additive; hub-side inference unchanged.

0.39.2

Features
  • Satellite config now carries inferenceMinConfidence (from tenant_settings.min_confidence) and primaryLanguage (from tenant_settings.primary_language) in resolveConfig and the ConfigMessage. Device-inference clients use the former so on-device results match the hub's detection threshold, and the latter to localize species names. Additive; hub-side inference is unaffected.

0.39.1

Upgrade notes
  • The device-inference toggle pairs with the Pi support in Pi 1.2.0.
Features
  • On-device inference, Phase 4 (UI): the per-satellite device_inference toggle is now in the satellite config card on the Satellite detail page (a tenant-default / on / off override alongside the audio-filter and outbox controls), with a hint that it needs a capable Pi and falls back to hub inference if unavailable.
  • The config-write route persists the toggle and pushes the updated inferenceMode to the satellite over MQTT, which makes device mode reachable without manual SQL.

0.39.0

Features
  • On-device inference, Phase 1 (hub spine): lays the foundation for satellites to run BirdNET locally and upload detections instead of audio. It ships inert (per-satellite setting, default off) until the satellite ML lands in later phases.
  • Per-satellite satellite_config_overrides.device_inference (migration 079, NULL inherits hub-side inference), surfaced through resolveConfig as ConfigMessage.inferenceMode (hub/device) plus inferenceModelId (the active default classifier the device should run).
  • New inference-result MQTT channel (satellite to hub): a device-mode satellite publishes the chunk audio + its detections together, only for hits (detections + audio for hits).
  • The ingester stores an inference-result chunk (no inference job) and inserts detections flagged watcher_processed=false; the DetectionWatcher's new triggerScan() then enriches them through the same path as queue-inferred detections (temporal aggregation, rarity, first-of-day/season, trust, alerts, calibration, live feed), so device detections are first-class. Ingestion is idempotent on redelivery.
Chores
  • Broker dynsec grant for the new inference-result channel.
  • Four regression tests (hub suite now 50).

0.38

0.38.3

Upgrade notes
  • The backup sidecar is now opt-in via BACKUP_ENABLED (default false).
  • When BACKUP_ENABLED is off, the entrypoint exits 0 and the container stays Exited (no idle container, no restart loop, via restart: on-failure); when on, it runs the scheduler in the foreground.
  • Manual docker compose run --rm backup backup.sh / restore.sh work regardless of the toggle.

0.38.2

Upgrade notes
  • Off-site DR requires two operator-supplied values: an rclone remote in config/backup/rclone.conf + BACKUP_REMOTE, and BNG_BACKUP_VOLUMES pointed at a separate disk.
Features
  • Backup and disaster recovery: a new backup sidecar (pinned Alpine + postgresql17-client + rclone + crond) takes nightly backups: a rotated pg_dump (7 daily + 4 weekly), an incremental rclone mirror of the MinIO buckets, and the Mosquitto credential file, written to a local volume then pushed off-site via a user-configured rclone remote.
  • Redis is intentionally excluded from the backups (queue rebuilds, unsent chunks re-drain from satellite outboxes).
  • A restore script ships with the sidecar, validated by a test restore that round-tripped 146k detections exactly into a scratch database.
Docs
  • A runbook (deployment/backup-restore.md) covers in-place restore, fresh-host recovery, and a non-destructive verify procedure.

0.38.1

Security
  • GDPR follow-up: login_attempts (the rate-limit ledger, queried over a 15-minute window) was insert-only and never pruned, so IP addresses accumulated indefinitely. It now self-purges rows older than 24 h, hourly, in dispatcher/full mode.
Performance
  • GDPR follow-up: the data-export audit-log slice is capped at the 10,000 most recent entries with an auditLogTruncated flag and note, so a long-lived account can't balloon the synchronous response into memory. User-contributed collections (votes, comments, annotations, field notes) stay complete; only the machine-rate activity log is bounded.

0.38.0

Features
  • GDPR data export: new GET /api/auth/me/export returns all of the caller's personal data as a downloadable JSON document (profile, memberships, verification votes, comments, annotations, field notes, MFA status, passkey/device metadata, and audit entries), with secrets (password hash, TOTP secret, device tokens) excluded. MFA/passkey lookups are best-effort so they can't fail the core export.
  • The export is surfaced as "Export my data" on the Account page; together, export and erasure cover the access, portability, and erasure rights.
Security
  • GDPR erasure: DELETE /api/auth/me already cascade-wiped the user's child rows (votes, comments, field notes, annotations, passkeys, remembered devices, reset tokens) but left their email and IP in the audit log, where ON DELETE SET NULL only clears user_id. The route now anonymizes those audit rows in place (email + IP nulled) before the delete, keeping the action trail intact while removing the personal data, and logs the deletion itself without PII.
Chores
  • Four regression tests added (hub suite now 31).

0.37

0.37.7

Features
  • Full web i18n straggler sweep: the remaining hardcoded strings across the UI are wrapped (Verification was largely un-internationalized, plus stragglers in Alerts, System, Workers, PlatformSettings, ModelsCompare, Satellites, Dashboard, Timeline, SpeciesProfile and others): 96 new EN/FR keys, dictionaries now 1567 each at parity, and every t() key referenced in code resolves.
Docs
  • Documentation refresh: the living docs site (features, roadmap) is brought up to date with the mobile capture/upload reliability work, the tenant-isolation and webhook-secret hardening, health checks and worker hardening, the test suites, and account-event emails.
  • Shipped items are pruned from the roadmap's Planned section.

0.37.6

Fixes
  • Satellite-offline alert spam: a down satellite re-alerted every hour for the entire outage. The engine already supports on_state_change (one alert per offline episode, auto-rearmed when the satellite is seen again) and migration 041 set existing rules to it, but seedSystemAlertRules never set fire_mode, so every tenant created since 041 got the cooldown default back. The seed now sets fire_mode per rule, and migration 078 re-fixes any system offline rule still on cooldown.
  • Verification page playback bar: the review card rendered a SpectrogramPlayer (which has a moving playback cursor) and a redundant bare <audio> control below it, and playing via the native control moved no cursor on the spectrogram. The duplicate is removed; playback now runs through the SpectrogramPlayer, so the position bar tracks the audio over the spectrogram.

0.37.5

Fixes
  • Detections are timestamped by when the bird sang, not when the worker ran: detected_at was never set by the inference worker, so it defaulted to NOW() at analysis time (seconds late for live chunks, hours or days late for store-and-forward backlogs), which skewed feeds, notifications, first-of-day and first-of-season flags, timeline buckets, and the eBird rarity week. The worker now writes detected_at = chunk recorded_at + the call's start offset inside the chunk.
  • Migration 077 backfills all existing detections the same way (verified: 0 of 144,955 rows remain more than a minute from their chunk's recording time).

0.37.4

Upgrade notes
  • Mobile 1.10.1's stale-'sent' requeue needs this release: a chunk whose ack was lost is re-sent and must converge, not error unacked forever.
Fixes
  • Idempotent audio re-ingest: the audio_chunks insert gains ON CONFLICT (id) DO NOTHING, and a duplicate chunkId is acked without re-queueing inference. This also covers plain QoS 1 redelivery.

0.37.3

Features
  • Web quality pass, error handling: new errorMessage() helper in the api client; 28 user-action toasts across the admin pages now carry the actual failure reason instead of a bare "failed".
  • Accessibility: the custom confirm Modal gains a focus trap, Escape-to-close, dialog ARIA attributes, and focus restoration to the opener.
  • Accessibility: verification vote buttons pair an icon (check / cross / question mark) with their color so vote state isn't color-only.
  • i18n templates: t() accepts a params object with {placeholder} substitution; the four template keys that were untranslatable string concatenations (satellite transfer title/result/counts, detection trust tooltip) are now proper en/fr entries.
Fixes
  • usePolling gains a per-request sequence guard so a slow early response can never overwrite a newer one (the pagination/filter race).
  • A deps audit fixed two real staleness bugs (species detail navigation in Detections, tenant switch in the satellite activity heatmap).
Performance
  • The SpeciesI18n context value is memoized (every translation arrival used to re-render every species name in the app).
  • SpeciesName and DetectionCard are memo-wrapped, so detection-list re-renders stop cascading through 50 cards of spectrograms.
Chores
  • 23 silent .catch(() => {}) on user-visible loads (comments, field notes, page lists, filter dropdowns) log a console warning instead of vanishing.

0.37.2

Upgrade notes
  • Legacy tenant webhooks are removed in favor of alert channels: gone are the Tenant Settings > Webhooks tab, the /api/webhooks routes, the api-client methods, the Spotlight entry, and the table itself (migration 076; zero rows existed). The standalone webhooks table predated the alerts unification and was never wired to event delivery (its dispatcher was dead code, found during the 0.37.1 work); its rows could be created and tested but never fired.
  • Webhook delivery lives exclusively in Alerts > Channels, which signs, seals secrets at rest, and actually fires on rules.
  • Audit history rows referencing the old webhook actions remain renderable.
Docs

0.37.1

Upgrade notes
  • Without the MFA_ENCRYPTION_KEY, everything keeps working in plaintext with a single warning (mailer-style fail-open).
  • Channels are the webhook delivery path; the legacy tenant-webhooks table/UI remains manageable but is documented as legacy.
Security
  • Webhook secrets encrypted at rest: a new secret-box service (AES-256-GCM, same MFA_ENCRYPTION_KEY as the TOTP store, enc:v1: prefix) seals webhook signing secrets in both stores, the legacy webhooks.secret column and alert_channels.config->>'secret' for webhook channels. Sealing is idempotent (edit round-trips can't double-encrypt), opening passes legacy plaintext through, and a startup pass converges pre-existing rows.
  • The webhook list API now returns has_secret instead of the value; secrets are write-only.
  • The signature format is unified to GitHub-style X-BirdNet-Signature: sha256=<hex> across the test route and the channel dispatcher (zero existing webhook rows in the wild, so no receiver breaks).
  • Satellite-scoped API keys are actually enforced: request.auth.satelliteId was recorded since 0.32.5 but never enforced, so a key lifted from one device granted tenant-wide access. Two route-collection hooks pin it down: satellite /:id routes addressing another satellite answer 404 (read, rename, update, logs, delete, rotate), and the detections list filter is force-set to the bound satellite. User sessions and tenant-wide keys are untouched.
  • The webhooks routes also gained the tenant-scoping guards the 0.36.4 sweep missed (cross-tenant update/delete/test, body-tenant create).
Docs
  • docs/reference/webhooks.md is rewritten around alert channels with Python + Node verification examples and the correct header name (it documented X-Webhook-Signature, which nothing ever sent).
Chores
  • Removed the WebhookDispatcher service, which was dead code: nothing ever instantiated it, so rows in the legacy tenant-webhooks table never fired on events.
  • Test suite grows to 28 (satellite-key pinning matrix, seal round-trip, sealed-at-rest assertions, webhook cross-tenant cases).

0.37.0

Features
  • Deep health checks: /api/health stays a cheap liveness stub (satellites poll it for the version), and a new /api/health/ready actually touches each dependency with a 3 s per-check timeout: Postgres (SELECT 1), Redis (ping via the queue connection), MinIO (bucketExists), MQTT (config-pusher connection state). It returns 503 with per-check detail when anything is down.
Fixes
  • Inference-worker production hardening: inference jobs get five exponential retry attempts (5 s base) instead of infinite tight-loop retries on poison jobs.
  • The worker's per-coordinate range-filter cache is LRU-capped at 512 entries (it previously grew without bound).
  • The worker size-checks audio objects (20 MB cap) before download so a corrupted upload can't fill /tmp.
  • The worker cleans temp WAVs in a finally (they leaked on mid-pipeline exceptions).
Performance
  • Worker psycopg2 connections come from a ThreadedConnectionPool (was one fresh connection per job); broken connections are evicted on release.
  • Registry models cache to a mounted volume (worker-models/) so worker container restarts stop re-downloading from MinIO.
Chores
  • Docker now probes /api/health/ready for the api container; the dispatcher (no HTTP server) and the Python workers get heartbeat-file healthchecks instead, touched every 15 s by their existing status-reporter loops.
  • One probe gotcha fixed along the way: localhost resolves to ::1 in the alpine containers while Fastify listens IPv4-only, so the probe targets 127.0.0.1.
  • The worker containers get cpu/memory limits (2 cores / 3 GB) so a runaway decode can't starve the stack.

0.36

0.36.5

Upgrade notes
  • A one-shot pnpm --filter @birdnet-ng/hub queue:clean script trims pre-existing Redis backlogs (ran in prod: 680 MB to 13 MB).
  • Worker compose default is now replicas: 2 to match how the stack is actually operated, so a plain redeploy no longer collapses the fleet to one.
Fixes
  • Redis memory fix: the inference queue never cleaned finished jobs, so every job ever processed stayed in memory (observed 520k jobs, 680 MB). The queue now sets removeOnComplete (24 h / 5000) and removeOnFail (7 d / 5000) defaults.
Chores
  • First automated test suite: pnpm --filter @birdnet-ng/hub test (vitest). The global setup spins an ephemeral postgres:17-alpine container (no published ports; reached via its bridge IP), applies the full migration chain, then builds the real Fastify app with the real auth middleware and throw-on-use stubs for MinIO/MQTT/queue.
  • The 18-test tenant-isolation matrix locks in the 0.36.4 hardening: foreign-user and foreign-API-key 404s on detections, audio, satellites, and field notes; the multi-tenant membership fallback (user resolved in tenant B reaching their tenant A data); create-route role re-anchoring including the cross-tenant API-key mint; and vote identity coming from the session rather than the body.

0.36.4

Security
  • Tenant-isolation hardening across every UUID-addressed route (three fixes, one model): routes like GET /api/audio/:chunkId or GET /api/detections/:id carry no tenant in the URL, and requireRole validates the caller's role only in their own resolved tenant, so any authenticated user could read (and in places mutate) another tenant's rows by UUID.
  • New canAccessTenant helper: platform admins and the matching resolved tenant pass; session users get a tenant_members fallback so multi-tenant users aren't locked out of their own data on URLs without ?tenantId= (audio src attributes, for instance). Failures answer 404, never 403, so foreign UUID existence isn't oracled. It applies to ~30 routes across audio, detections (detail, comments, pin, share-token mint, annotations), satellites (detail, schedule, config, update, logs, delete), field-notes, alerts ack, alert rules/channels, scheduled exports.
  • New hasRoleInTenant helper for create routes whose target tenant comes from the body (POST /api/satellites, alert rules/channels, scheduled exports, field notes, and POST /api/tenants/:tenantId/api-keys): the role check is re-anchored on the target tenant, closing "admin of tenant A creates resources or API keys in tenant B".
  • Verify / annotate / votes endpoints now take the acting user from the session and the tenant from the detection row instead of trusting forgeable body fields; the body fields remain accepted (machine callers without a user identity) but a session identity always wins.
Chores
  • The dead GET /api/audio/live/:chunkId route is removed: nothing ever populated its cache (live audio flows over the WebSocket payload).

0.36.3

Fixes
  • Live detection feed fix: the Mosquitto satellite role was never granted subscribePattern birdnet/+/%u/detection, so the broker silently refused every mobile/Pi subscription to the live feed since its introduction in 0.32.4, and clients only ever saw the REST backfill at app launch. The entrypoint now provisions the grant (idempotent, applies to existing brokers on restart).
  • The mobile MQTT client stops logging "Subscribed" unconditionally: it now inspects the per-topic SUBACK and warns with the topic list when the broker refuses a subscription (granted qos 128), which is what would have made this visible months earlier. Ships with the next APK.

0.36.2

Features
  • Devices can now change their own recording profile: config-request accepts a recording_profile override (validated against the known profile set), updates the satellite row, and pushes the merged config back. Same trust model as the other device-initiated overrides: a hub-side config lock still rejects the request and re-pushes the locked config.

0.36.1

Features
  • Invite flow polish from review: the inline invite form on Members read as a settings block, not as "add a user"; it's now a modal opened by a "+ Invite user" button in the page header, matching the header-action pattern used everywhere else.
  • Emailed invites close the invite modal on send; link-only invites keep it open so the one-time link can be copied.
  • Migration 075 stores the recipient email on the invite row (nullable; link-only invites have none, display-only since registration still accepts the token from any address), and the pending-invites table gains a Recipient column.
  • The pending-invites table refreshes immediately when an invite is created instead of waiting for its 30 s poll.

0.36.0

Upgrade notes
  • Historical audit rows are not backfilled, so admin actions appear in the Activity feed from this deploy forward while alerts cover the full history.
Features
  • Unified Activity feed: "What happened in my organisation since I last looked" was previously split across the bell badge, the Alerts inbox, and the platform-admin-only Audit Log. The new /activity page (Browse section, member+) merges alerts with tenant-scoped admin actions (settings changes, member and invite changes, satellite operations), newest first with day separators and cursor-based load-more.
  • The Activity page is read-only by design: acknowledging alerts stays on the Alerts inbox.
Fixes
  • i18n sweep: Field notes, the annotation modal, the verify vote buttons, and the new Activity page all had keys that existed only as inline English fallbacks; ~45 keys are now present in both locales, so FR users stop seeing English on those surfaces.
Security
  • Invite creation and revocation, which never audit-logged at all, now do.
Chores
  • Migration 074 adds audit_log.tenant_id (nullable; platform-level actions legitimately have none) as the enabler of the Activity feed. AuditService.log accepts an optional tenantId, and ~30 tenant-scoped call sites now pass it (satellites, alert rules and channels, webhooks, scheduled exports, tenant settings, detections votes and annotations, member operations).

0.35

0.35.1

Features
  • Scrollbar tuning from review: 6px thumb, shown the moment the cursor enters any part of the navbar (Mantine's type="hover" hides the bar with display:none and only reveals it when the ScrollArea itself is hovered, so the navbar-hover case needs a CSS override).
  • The sidebar scrollbar is dimmed toward the panel in both schemes instead of the default bright rgba stripe, and rides the true sidebar edge by cancelling the navbar's right padding inside the scroll region.
Fixes
  • Sidebar scrolling: the nav outgrew the viewport once Verification and Field notes joined it, and the navbar had no scroll container, so links past the fold were unreachable. The nav now lives in a ScrollArea section (WS-status footer stays pinned below it).

0.35.0

Features
  • Onboarding checklist for new tenants: tenant admins see a "Getting started" card at the top of the Dashboard until the organisation is operational (register a satellite, receive a first detection, invite a member, set up an alert channel). Each unfinished step links to the page where it happens.
  • Checklist steps are derived from live data (satellite count, detection presence, member count, channel count), not stored wizard state, so the card self-completes as the tenant comes alive.
  • Once everything passes, or on explicit dismiss, a per-tenant localStorage flag hides the card and stops the probes. Browser-local on purpose: it's a hint, not hub-owned state, and admins on a fresh browser see it again briefly until the checks pass.
  • The checklist is visible to tenant admins and owners only; the member-count probe needs an admin-gated endpoint anyway and onboarding actions are all admin scoped.

0.34

0.34.4

Features
  • Tenant Settings now uses the same hash-routed tab pattern as Platform Settings: Detection (thresholds, temporal aggregation, visibility), Satellites (category drops, audio filter, communication, outbox caps), General (language, watchlist), Webhooks. It was a single scrolling card with nine header blocks, and finding the outbox caps meant scrolling past inference thresholds, temporal aggregation, and visibility toggles.
  • The three settings tabs share one Save because the API writes the whole tenant_settings row; the form component stays mounted across tab switches (hidden on Webhooks) so unsaved edits survive.
  • Spotlight gains one entry per Tenant Settings tab with keywords, so "outbox" / "watchlist" / "yamnet" land on the right tab directly.

0.34.3

Features
  • Information-architecture wave 2: Workers was a standalone platform page whose content (queue stats, worker registry, recent jobs) is one facet of System; it is now the "Workers" tab there, using the same hash-routed tab pattern as PlatformSettings so #workers deep-links and Spotlight keep working. The old /platform/workers route redirects to /platform/system#workers.
  • Map is renamed "Locations" (nav + page title, FR "Lieux") since it shows satellite sites first and detections second.
  • Locations gains a ?focus=<satelliteId> mode that zooms to one satellite and opens its popup; the coordinates on SatelliteDetail now link there, while Satellites list rows stay single-target (whole-row navigates to detail) to avoid nested interactive elements.

0.34.2

Features
  • Sidebar and Spotlight tidy: the two "Settings" sidebar entries that read identically are now "Tenant settings" and "Platform settings".
  • Model Compare is renamed "Model precision" so it stops colliding with the tenant-side Compare page.
  • The browse-style first sidebar block gains a header label.
Fixes
  • The Verification page was imported in App.tsx but never routed, so users could not reach the queue that ModelsCompare's own copy points them to; it now has a route, a sidebar link, and a Spotlight entry.
  • Field notes picks up a Spotlight entry it was missing.

0.34.1

Features
  • Invites can now be emailed straight to the recipient: the Members invite form gains an optional recipient-email field, and the underlying POST /api/auth/invites accepts an optional email. When the field is empty the existing copy-paste path is unchanged.
  • The invite response carries an emailed boolean so the UI shows the right toast.
  • The mailer fails open as before: if SMTP_HOST is unset the invite still generates and the admin can copy the link; the response reports emailed: false and the UI surfaces a "SMTP not configured, email skipped" toast so the operator notices.

0.34.0

Upgrade notes
  • Operators should verify SMTP is configured before relying on the password reset path: the mailer fails open as before, so without SMTP_HOST the request still records the audit entry but the email is silently skipped.
Features
  • Password reset flow, closing the deferred half of the email work that hub 0.33.0 left open: registering and locking already mailed users, but users who forgot their password had no self-service path and had to ping a platform admin for a manual reset.
  • POST /api/auth/forgot-password accepts {email}, generates a 32-byte hex token, stores the SHA-256 hash with a 60-minute expiry in the new password_reset_tokens table (migration 073), and mails a reset link.
  • POST /api/auth/reset-password accepts {token, password}, verifies the token is unused and unexpired, updates the password hash, marks the token used, and clears failed_login_attempts + locked_until so a locked-out user is unblocked by the reset.
  • Web UI adds /forgot-password and /reset-password?token=... routes and a "Forgot password?" link on the login screen, with new i18n keys in both English and French.
Security
  • POST /api/auth/forgot-password returns 204 regardless of whether the email matches a user, so the response leaks no signal about which addresses have accounts.
  • Forgot-password requests are rate-limited via the existing login_attempts ledger so one IP cannot carpet-bomb the user table.
  • MFA-enabled users still face their TOTP / passkey on the next sign-in; the password reset never bypasses the second factor.
  • Three new audit actions: user.password_reset_requested, user.password_reset_completed, user.password_reset_failed.

0.33

0.33.2

Chores
  • Pin minio/minio to RELEASE.2025-09-07T16-13-09Z so upgrades are intentional. The previous shape (minio/minio bare, defaulting to :latest) was a CLAUDE.md violation and could silently roll the storage tier on any docker compose pull.

0.33.1

Features
  • Operator-facing cleanup: every container env var is now overridable from .env. Every hardcoded literal (PG_HOST: postgres, REDIS_HOST: redis, MINIO_ENDPOINT: minio, MINIO_PORT: 9000, MINIO_BUCKET: birdnet-audio, API_PORT: 3000, the dispatcher's HUB_MODE: dispatcher) is now ${VAR:-default}, so operators can point at external Postgres / MinIO, run the api on a different port, or rename buckets without touching docker-compose.yml.
Docs
  • .env.example gains commented entries for every new override so the surface is discoverable; BNG_APP_VOLUMES (previously required with no compose default) and SMTP_* (hub 0.33.0's mailer) are now present and documented.
Chores
  • docker-compose.yml gains an x-hub-env: &hub-env YAML anchor that holds every connection var shared by api, dispatcher, and worker (PG, Redis, MinIO, MQTT, plus LOG_LEVEL, MINIO_USE_SSL, MINIO_SPECIES_BUCKET, DOCS_INTERNAL_URL, WEB_HOST). Each service merges with <<: *hub-env and adds only the role-specific values it needs (line counts: api env block 30 to 11, dispatcher 18 to 3, worker 13 to 5).
  • The worker keeps its combined MINIO_ENDPOINT: ${MINIO_ENDPOINT:-minio}:${MINIO_PORT:-9000} override to match Python pydantic-settings convention.

0.33.0

Features
  • Account-event email, one of three planned-list items shipped with docs search and the field notes UI: the shared mailer is wired into two account events and fails open (a missing SMTP_HOST logs a warn and skips the send so registration / login never blocks on mail config).
  • Welcome email on successful registration: fire-and-forget after the audit log entry; SMTP failures don't roll back the user.
  • Account-locked notification on the lockout-triggering attempt, detected via the same UPDATE … RETURNING that increments failed_login_attempts (predicate failed_login_attempts = 5 AND locked_until > NOW()), so it emails once per lockout, not once per wrong password after the lock is in place.
  • Password reset is deferred to a follow-up: it needs a token table + a public POST endpoint, more than a config-only feature.
  • Field notes UI: field_notes + field_note_photos tables (migration 026) and the seven /api/field-notes/* routes have existed since v0.14, but the page that reads them was missing. New pages/FieldNotes.tsx is mounted at /field-notes, linked from the main tenant section of the sidebar.
  • The Field notes page has a per-satellite filter, a create modal (text + optional satellite + optional detection UUID + free-form JSON weather + optional photo upload), a delete action for the note's author (or platform admin), and an inline photo gallery on each row.
Docs
  • Docs search: the Express + markdown-it docs site gains a topnav search input. The server walks every latest .md at boot, extracts title + h2/h3 + plain-text body, and exposes the flat array as GET /search.json (~150 KB).
  • Docs search client-side JS in /static/search.js fetches once on first focus and filters by substring across title (score 100) / headings (score 30) / body (score 10). Keyboard: Ctrl+K / Cmd+K focuses, Esc clears, click-outside closes.
  • Versioned snapshots in docs/versions/v* are intentionally excluded from docs search so searches don't fan out to mostly-stale duplicates.
Chores
  • New services/mailer.ts factors the SMTP transport from the two existing call sites (alert-channels.ts, export-scheduler.ts) into a shared Mailer class, which reads SMTP_HOST / SMTP_PORT / SMTP_SECURE / SMTP_USER / SMTP_PASS / SMTP_FROM from env.
  • The existing getFieldNotes API method gained a satelliteId filter.
  • The inline-typed list response was replaced by a FieldNote interface so the per-detection field-note panel in Detections.tsx and the new Field notes page share a single shape.

0.32

0.32.10

Upgrade notes
  • No new settings introduced: the existing tenant-level toggles, already covered on the docs/ site, are at Tenant Settings → On-device filter (Pi/satellite drop) and Tenant Settings → Visibility (UI filter).
Fixes
  • Live detection feeds now honor the show_* visibility toggles. GET /api/detections already hid categories the tenant has set to show_* = false (detection_visible_for_tenant() SQL function, migration 056), but the new MQTT /detection topic hub 0.32.4 and the WebSocket /ws/events channel bypassed it, so a tenant with show_other_animals = false could still see "Dog" land in the mobile DetectionsFeed and as a web-UI live toast.
  • DetectionEvent gains a categoryVisible field, resolved once in DetectionWatcher via the same SQL function; DetectionPublisher (MQTT) and the WebSocket consumer skip when it's false.
  • WebhookDispatcher and AlertRulesEngine ignore categoryVisible on purpose: operator-configured webhooks and rules may legitimately want to fire on "Dog" for security use cases, so their visibility decision stays opt-in via the rule conditions instead of forcing the UI toggle on them.

0.32.9

Fixes
  • Worker self-exits when it stops draining a non-empty queue. hub 0.32.8 caught the case where Redis was unreachable; this catches the case where Redis is reachable, status reports flow, but the BullMQ subsystem has wedged for some other reason (lock-acquisition deadlock, stalled job that never times out, library bug), with the same symptom as last time: chunks accumulate, no detections, no visible error.
  • Same status reporter, same 15 s tick: it tracks _worker_stats["processed"] and last_progress_at, and if the counter hasn't moved for 5 minutes AND LLEN bull:inference:wait > 0, calls os._exit(1), so Docker restarts the worker, the lock is released and the queue drains. 5 minutes is chosen because real inference jobs are sub-second on cached models, so a 5-minute gap against a non-empty queue is unambiguously stuck rather than idle.

0.32.8

Fixes
  • Inference worker exits on persistent Redis loss so Docker can restart it. In a field outage, the BullMQ Python worker hit Error -3 connecting to redis:6379. Temporary failure in name resolution and got stuck in an internal reconnect loop; the process never exited, so restart: unless-stopped had nothing to restart. Result: 1338 inference jobs queued, 0 processing, 0 new detections, silent for hours until someone noticed "the Pi 4 is detecting but nothing shows up" (a manual docker compose restart worker cleared it instantly).
  • The fix lives in the existing 15 s status-reporter loop (which already does Redis I/O for the Workers page): each r.set now tracks consecutive failures, and after 5 in a row (~75 s of broken Redis) the worker calls os._exit(1) instead of swallowing the exception, so Docker restarts the container, the worker re-resolves DNS and the queue drains. It uses os._exit (not sys.exit) because the BullMQ client is hosed on the same broken connection, and asyncio cleanup would block indefinitely waiting on it.
  • The worker logs the consecutive-fail counter at WARN each tick and ERRORs on the trip, so the cause is visible in docker logs post-mortem instead of silent.

0.32.7

Features
  • Per-version download on the platform-admin Mobile Releases card (PlatformSettings → Mobile tab): each release row gets a "Download" button that anchors to /api/mobile/download/:id, so platform admins can grab any historical APK without going through MinIO. The hub already exposed GET /api/mobile/download/:id (public, streams the APK with attachment Content-Disposition), but nothing in the UI linked to it.
  • "Get mobile app" button in the Satellites page header for everyone (any tenant member): on mount it fetches /api/mobile/latest (already public) and renders a Button-as-anchor to the latest APK, hidden when no release exists. It lets a tenant admin onboard a new phone satellite without needing platform-admin access: they install the APK, log in, register, done.
  • Both download anchors use the download attribute with a derived filename, so the browser saves the APK rather than navigating.

0.32.6

Upgrade notes
  • No behavior change for the web UI: it ignores the query param and relies on the cookie.
Fixes
  • /api/auth/login/mfa/{totp,backup} now honors ?returnToken=true for non-cookie clients, symmetric with /login?returnToken=true. The MFA second-factor endpoints set the session JWT as an httpOnly cookie but didn't return it in the body, so the mobile app (which uses Bearer auth, not cookies, and can't read httpOnly cookies anyway) had no way to obtain the post-MFA session token. Pairs with phone 1.4.1's TOTP / backup-code prompt.

0.32.5

Upgrade notes
  • Migration 072: api_keys.satellite_id UUID NULL REFERENCES satellites(id) ON DELETE CASCADE + partial index. Existing tenant-wide keys keep satellite_id = NULL and behave unchanged.
  • Backwards-compatible: old JWT-based auth still works (the auth resolver tries JWT first, then API key), the new endpoint is additive and existing api_keys rows are untouched.
Features
  • New POST /api/satellites/:id/rotate-api-key (preHandler requireRole("member")) generates a fresh key and expires any prior satellite-scoped keys for this satellite. It is used by phone 1.4.0 on first launch to auto-exchange the existing JWT, and as a manual rotation handle.
Fixes
  • Satellites get their own long-lived API key: no more silent 401s every 7 days. Before migration 072 the mobile satellite stored the registering user's session JWT as hubToken and used it as the Bearer for every authenticated HTTP call, and JWTs expire after 7 days, after which every authenticated mobile call silently 401s.
  • POST /api/satellites (all three paths: existing-by-device-id, existing-by-name, new) now mints a satellite-scoped api_key at registration time (role=member, never expires) and returns it as apiKey in the response. The raw value is returned once; only the SHA-256 hash is persisted.
Security
  • Audit action admin.satellite_api_key_rotated added.
Chores
  • Auth resolver: the api_keys lookup now returns satellite_id too, and request.auth.satelliteId is populated for satellite-scoped keys. Not enforced yet (tenant scope still gates routes); it is recorded so future routes can scope to "this satellite only" without re-querying.

0.32.4

Upgrade notes
  • Backwards-compatible: nothing breaks if no client subscribes, and older vapk and vsat clients ignore the new topic.
Features
  • New /detection MQTT topic so satellites can show a live detection feed without polling (pairs with phone 1.3.0's new on-phone DetectionsFeed card). A new DetectionPublisher service in dispatcher + full modes subscribes to the in-process eventBus and mirrors each DetectionEvent onto birdnet/{tenant}/{satellite}/detection MQTT (QoS 1). Single dispatcher path: alert engine, webhook dispatcher and now MQTT publishing all read off the same bus.
  • Confidence in the /detection message is raw model output: the watcher emits before the calibration curve is bulk-loaded, and re-fetching the curve per detection in the publisher would be wasteful. Subscribers needing calibrated confidence call GET /api/detections/:id or the list endpoint, which already decorates each row.
Chores
  • New shared DetectionMessage interface; the mqttTopic channel union is extended with "detection".
  • MqttIngester gains a generic publish(topic, payload, qos) so services can reuse the ingester's connection instead of opening another.

0.32.3

Upgrade notes
  • On older satellites that don't report diskBytes, the outbox usage card falls back gracefully to tracked-only bytes.
Fixes
  • The Satellite detail page's outbox usage bar now actually shows current usage: at 14 MB used out of a 5 GB hard cap the colored fill width was sub-pixel (0.28%), so the only visible mark on the bar was the orange soft-cap tick at ~60%, which users naturally read as "current usage" (off by three orders of magnitude). The bar gains an always-visible 2px current-usage tick at the current-usage position, coloured by threshold (success / warning / danger).
  • "Utilisé" (used) only reflected tracked outbox bytes, not actual on-disk usage: a Pi that had leaked 3.5 GB of orphan WAVs still showed "14 MB used" because outboxStats.totalBytes sums size_bytes from outbox rows only. Used is now max(diskBytes, totalBytes) when the satellite reports diskBytes Pi 1.0.2, so the card is ready to display disk truth.
  • When diskBytes > totalBytes, the delta surfaces as a separate "Untracked (pending orphan sweep)" row in warning colour plus a warning-coloured segment on the bar, making orphan accumulation visible before the next 5-min sweep reconciles it.
Chores
  • The shared OutboxStats type gets an optional diskBytes field.

0.32.2

Upgrade notes
  • Live tenants already missing rules are fixed by re-running the same 4 idempotent INSERTs (see backfill SQL in the [PR description / commit body]).
Fixes
  • System alert rules are now seeded on tenant creation, not only at migration time. The 4 system rules (rare_species, first_of_day, first_of_season, satellite_offline) were originally seeded by migration 038, which walks the tenants table once at hub startup, so tenants created after that migration ran never got the seed: their Alerts → Rules / Channels tabs rendered an empty list and no system-driven alerts ever fired.
  • New seedSystemAlertRules(db, tenantId) helper in services/alert-rules-engine.ts, idempotent: INSERT … WHERE NOT EXISTS guards on (tenant_id, system_key).
  • POST /api/tenants now calls seedSystemAlertRules right after creating the tenant + tenant_settings, so every newly created tenant gets its 4 rules.
Chores
  • Migration 038 is unchanged: the helper replicates its shape so the two paths stay in sync.

0.32.1

Features
  • Wilson 95% CI on the per-species precision dashboard (Compare Models page), a polish after the v0.32 effort-aware rarity ship: each precision percentage now reads 78% (62–88), the bracketed range being the Wilson score 95% confidence interval, the right choice for binomial proportions at small n. A tooltip on the cell shows the full CI + n.
  • On the precision dashboard, rows with ≥40-percentage-point CI bands grey out and a footer note explains it; the previous binary <10 verified asterisk overstated the threshold.
  • recalibrate-historical.ts one-shot script (pnpm --filter @birdnet-ng/hub recalibrate:historical [--days=N]) re-walks every historical detection in pages of 1000 and recomputes trust_score = compute_trust_score(calibrated, neighbor_support), making the v0.31 behavior retroactive. Pre-v0.31, trust score for detections older than 10 minutes was computed from raw confidence, and the v0.31 watcher only handles the last-10-minutes window.
  • The recalibration script recomputes neighbor support fresh per row so late-arriving neighbors flow in too; --days=N limits scope, and the script is idempotent.

0.32.0

Upgrade notes
  • 1 new migration (071 rarity_confidence).
Features
  • Effort-aware rarity flag, inspired by Johnston 2021 (Diversity & Distributions 27:1265): absence-from-eBird-recent-obs is much weaker evidence at low-effort locations than at hotspots, since a remote rural site sees few checklists and "no nearby observations" might just mean "nobody looked". The rare-flag heuristic stays, but its confidence is downgraded based on nearby sampling effort.
  • New detections.rarity_confidence column (migration 071) carries the strength of the flag: high (≥50 nearby checklists in past 30d), medium (10–49), low (3–9), or no flag at all below 3.
  • EBirdService.getEffortSummary(lat, lng) counts distinct subId (checklists) + locId (locations) from the same /data/obs/geo/recent payload already fetched, so no extra API calls.
  • isRareDetection returns { isRare, reason, confidence } and the watcher writes rarity_confidence alongside is_rare = true.
  • Rare badges in Verification, SpeciesProfile and SharedDetection render with confidence-adjusted styling: high = filled, medium = light, low = outline / "?" suffix. Tooltips on every surface explain the underlying checklist count, and the share-page route includes the new field so public share pages reflect confidence.
  • Watchlist / first-sighting / few-sighting flags stay at high confidence: those are grounded in our own data, with no sampling-effort dimension.

0.31

0.31.0

Upgrade notes
  • Behavior change: a "0.85 raw" call from a known over-confident model (calibrated to e.g. 0.55) now contributes proportionally less to the trust logit instead of compounding raw bias.
  • No new migrations.
Features
  • Trust score on calibrated confidence: both the inline scoreTrust (at-creation) and the periodic rescoreRecentTrust (5-min sweep) now look up the per-(tenant, model) calibration curve and apply it to the row's raw confidence before passing to compute_trust_score, falling back to raw when no curve has been fit. The rescore bulk-loads curves for the batch's distinct (tenant, model) pairs so it doesn't round-trip per detection.
  • Annotation overlays on Verification page spectrograms (a v0.30 follow-up polish): both the standalone Spectrogram and the SpectrogramPlayer accept an annotations prop and render colored rectangles per annotation (green confirm / red reject / amber unsure). Verification fetches annotations for the currently-shown detection on each card and overlays them, so the voter sees where prior reviewers pinpointed before voting.
  • Calibration curve viewer on the Models-tab calibration card (a v0.30 follow-up polish): coverage rows are click-to-expand, and expanded rows render an inline SVG step plot of the fitted bucket curve vs a dashed y=x reference line, with bucket dots sized by sample_count (more verified samples = bigger dot). It lets admins eyeball whether the calibration is sensible.
  • Cost hint when species_extra_images_count > 10 (a v0.30 follow-up polish): a yellow warning under the input projects "≈ N MB per species" using the live image cache average bytes/file as the estimate, plus existing cache size as a sanity reference. It makes the storage cost of the now-uncapped knob visible before admins schedule 50 extras × thousands of species.

0.30

0.30.0

Upgrade notes
  • 2 new migrations (069 confidence_calibration, 070 detection_annotations).
Features
  • Per-(tenant, model) confidence calibration via isotonic regression, a Cornell-inspired pipeline upgrade: BirdNET's raw confidence is a softmax-style score, not a real probability, and Cornell's Johnston 2021 paper documents the same pattern for any classifier and proposes monotonic re-alignment with observed frequencies. The equivalent here is Pool Adjacent Violators (textbook isotonic regression), fit on accumulated confirmed/rejected verification votes. It is per-tenant because acoustic environments differ, and per-model because different BirdNET versions have different score distributions.
  • Calibration is stored in confidence_calibration (migration 069); the detection list bulk-loads the curve and decorates each row with calibrated_confidence, and the UI shows Conf 78% (62%) inline.
  • New "Confidence calibration" card on the Models tab triggers calibration refreshes; coverage is at GET /api/calibration/coverage.
  • Trust score still uses raw confidence: promoting it onto calibrated is a deferred behavior change.
  • Time-boxed verification annotations (the second pipeline upgrade), inspired by Cornell's Merlin training-data methodology: the Verification page gains an optional "Pinpoint the call on the spectrogram" button to drag a rectangle on the SpectrogramPlayer canvas marking exactly when + at what frequency you hear the species, then cast Yes/No/Unsure with optional notes in one shot.
  • Annotations are stored in detection_annotations (migration 070) with time_start_ms / time_end_ms / freq_low_hz? / freq_high_hz? / vote / notes and mirror a verification_votes row, so the existing consensus-promotion logic still fires. This builds a labeled bounding-box dataset opportunistically as users verify, with no extra workflow.

0.29

0.29.0

Upgrade notes
  • pnpm --filter @birdnet-ng/hub backfill:licenses re-queries Wikimedia for pre-068 rows that came in license-blank.
  • If protected audio alone exceeds the hard cap, capacity stays exceeded and admins must lower keep-best per species or unpin to reclaim space.
Features
  • iNaturalist as a second image source: an opt-in fallback for gallery extras (inaturalist_enabled platform setting). Wikipedia is always the primary source; iNat fills only what Wikipedia is missing.
  • iNaturalist extras are license-gated (default = commercial-safe cc0 / cc-by / cc-by-sa) and an admin can widen the filter to include cc-by-nc variants. Photos with license_code: null (= all rights reserved on iNat) are always rejected.
  • iNaturalist requests are self-throttled to 80 req/min for headroom against iNat's 100 req/min limit.
  • Per-provider exhaustion markers: extras_exhausted_at for Wikipedia, inaturalist_exhausted_at for iNat (migration 068).
  • License code captured + displayed: species_image_files.license_code and species_images.license_code are populated from extmetadata, and the lightbox attribution pill shows the license as a small uppercase badge.
  • Platform Settings → 5 hash-deep-linked tabs: Access · Images · Storage · Models · Mobile. The active tab persists in location.hash so deep links and bookmarks survive.
  • Spotlight gains one entry per Platform Settings tab with keywords[], so "Wikimedia", "Retention", "APK", "iNaturalist", etc. jump straight to the right tab.
  • Storage tab three-card split: Storage overview (total bar with soft + hard cap markers, a check-mark / warning-sign state hint, cap inputs), Audio (protected/unprotected split bar, 4 chunk stats, retention rules, schedule + run-now) and Other caches (Images / Models / APKs proportional bar, gallery extras + APK keep-N). Each card has its own scoped Save action.
  • Spotlight species index: every detected species in the active tenant becomes a Spotlight result (capped at 1000, sorted by detection count).
  • Multilingual matching in Spotlight: each species action's keywords[] carries EN common name + scientific name + species_code + every translation already loaded for the user's primary + secondary languages, so "Pinson" / "Buchfink" / "Fringilla coelebs" / "frincoe1" all resolve.
  • Cross-tenant platform-admin views: Members and Alerts inbox accept an optional tenantId from platform admins (the route 400s for tenant admins as before).
  • When a platform admin is on "Toutes les organisations", the Alerts inbox and Members tables span every tenant with an "Organisation" column, and the Members invite form gains a tenant Select picker.
  • System page → Image provider rate limits panel: Wikimedia (10 000 req/h with auth, 500 req/h without) and iNaturalist (self-throttled 80 req/min) shown side-by-side with usage bars and paused-until indicators.
  • Image cache: a new "By source" panel shows Wikimedia / iNaturalist primary + extras side by side.
  • Cache stat cards show échouées as warning amber when > 0 and introuvables as danger red when > 0.
  • 20-cap removed from species_extra_images_count: admins choose how many gallery extras per species to keep.
Fixes
  • Protected audio guarantee tightened (retention correctness pass): the hard-cap purge pass now also excludes _retention_protected (keep-best + pinned), so the backend invariant matches the UI claim. Both passes are eviction-by-age over unprotected only; soft / hard differ only in target threshold.
  • APK bytes now factor into the cap math: previously the dashboard counted them but the purge logic didn't, so stale APK builds could push the bar over without triggering a sweep.
  • Cross-tenant fix: the detections list 500'd on platform-admin "all tenants" because pg-pool reads params lazily and params.push(limit, offset) mutated the bind for the count query.
  • Cross-tenant fix: the alerts inbox showed 0 while the bell badge showed 50 because the inbox fell back to tenants[0] instead of undefined.
  • The Wikimedia counter is now Redis-backed (sorted set) so it survives dispatcher restarts; previously the in-memory ring zeroed on every redeploy.
  • recordRequest() no longer counts CDN fetches (upload.wikimedia.org) or iNat photo downloads, only Action API hits, which is what the 10K/hr budget actually applies to.
  • The Image cache top counter sums all cached images (primary + extras across both providers) instead of only primary species rows.
  • Light-mode Alert palette fix at theme level: Alerts now read as soft pastel banners on light pages instead of saturated dark stripes (matching the Badge pattern).
  • i18n big sweep: 176 missing user-facing strings across 10 prefixes pulled from inline fallbacks into en.json + fr.json.
Chores
  • FR-only stale i18n keys pruned; en + fr now symmetric.
  • 1 new migration (068): license_code + source + inaturalist_exhausted_at columns.

0.28

0.28.0

Upgrade notes
  • Initial values when per-stream versioning lands: hub 0.28.0, satellite 1.0.0, mobile 1.0.0.
  • Pi birdnet-update.sh now checks out the latest vsat-* tag instead of origin/main; hub-triggered updates work unchanged.
  • Pre-decoupling heartbeats without stream fall back to the hub-version comparison.
Features
  • Per-stream versioning lands: hub, satellite and mobile now have independent SemVer streams, so satellite + mobile move at their own cadence.
  • New GET /api/system/versions exposes { hub, satellite, mobile }, the latest target per stream, computed from the bundled package.json files (mobile additionally consults mobile_releases.MAX(version)).
Fixes
  • Satellites + SatelliteDetail pages now flag "outdated" against the matching stream's target instead of always comparing to the hub version, so a hub-only patch no longer pings every satellite with a phantom "update available" badge.
Chores
  • pnpm version:bump --scope <hub|satellite|mobile> replaces the all-in-one bump. Each scope tags its own commit (vhub-X.Y.Z / vsat-X.Y.Z / vapk-X.Y.Z) and refuses to bump if nothing in scope has changed since the last scoped tag (--force to override).
  • Heartbeat carries a new stream field ("satellite" | "mobile"); the hub records it in satellites.stream (migration 067) and uses it to pick the right comparison target.
  • Mobile build-apk.sh derives versionCode deterministically from the SemVer (major*1_000_000 + minor*1_000 + patch); the +b<timestamp> suffix on uploaded APK versions is gone, so every APK release is now a real version bump.

0.27, all components

0.27.0

Upgrade notes
  • Set MFA_ENCRYPTION_KEY (openssl rand -hex 32) before running 0.27.0: MFA features fail-closed without it.
  • New env vars: MFA_ENCRYPTION_KEY (32-byte hex, required), WEBAUTHN_RP_ID / WEBAUTHN_RP_NAME / WEBAUTHN_ORIGIN (auto-derived from BNG_APP_FQDN).
  • Migrations 063–066 run automatically on hub startup.
  • The split compose profile is recommended (docker compose --profile split up -d) so api + dispatcher run as separate containers.
Features
  • Mantine v9 UI migration: every page migrated from custom CSS to Mantine v9 primitives.
  • Light mode with [data-mantine-color-scheme] auto-flip; the gray ramp is un-aliased from surface so light-mode borders work, and theme.white = "#fff" keeps cards crisp.
  • Confidence colour CSS vars (--conf-high/mid/low) cascade through Dashboard, Detections, Verification, Species, SpeciesProfile and re-shade themselves per scheme.
  • Account page split into a 2×2 layout (Profile + Password / Bird-languages + Date-time) with per-card save buttons, plus a Two-factor card and a Danger-zone alert. The Account page was reorganised before the rest of the work landed.
  • Dashboard activity-today bars track the theme.
  • The Dashboard recent-detections row count is now a Select (10 / 20 / … / 100, persisted in localStorage).
  • New /design palette + primitives reference page.
  • Wikimedia image originals: every primary + extra is now downloaded as both the 800px display thumb and the full-resolution original in a single pass.
  • ?original=1 flag on /api/species/image/proxy and /by-key returns the high-res copy.
  • Unified lightbox: components/Lightbox.tsx (was three separate inline implementations: SpeciesProfile, SharedDetection, SpeciesName), with attribution pill + optional caption slot. It always serves originals when present and supports click-to-zoom + drag-pan + swipe nav.
Fixes
  • Bug fix: a stale require("node:crypto") in the worker's sha256() was throwing ReferenceError: require is not defined inside fetchExtraImages every call since the originals rollout, silently breaking every extras download for hours. Replaced with a top-level import.
  • Bug fix: backfill loop ordering. The query picked the oldest species with count(extras) < target and re-fetched it forever (Wikipedia returned the same dedup-filtered list each tick); the new ordering is (extras_count ASC, fetched_at ASC) so species with zero extras are processed first.
  • A new extras_exhausted_at column (migration 066) marks species whose Wikipedia image list is exhausted, with a 7-day cooldown before re-checking.
  • Storage cap + PlatformSettings storage bar now sum file_size + original_file_size for both image tables (was missing the originals).
  • /api/stats/activity?period=today filters to the current server day for the Dashboard "Activity today" card. It was returning lifetime hour-of-day counts, so bars showed implausibly high totals (e.g. 9 208 detections at 11:00).
Security
  • Multi-factor authentication: optional second factor for any user, mandatory for platform admins; five phases shipped together.
  • TOTP (RFC 6238): QR-code enrollment via otplib + qrcode, AES-256-GCM at-rest encryption of the secret with MFA_ENCRYPTION_KEY, 5-minute mfaToken JWT bridging password → second factor on /login, ±30 s tolerance.
  • WebAuthn passkeys as alternative second factor: Touch ID / Windows Hello / YubiKey, registered against an authenticated session, used at the login challenge step. Backed by @simplewebauthn/server + /browser.
  • Backup codes: 10 single-use Crockford-alphabet codes shown once at TOTP enrollment, bcrypt-hashed at rest, popped on consume. Regeneratable from /account (re-enter password).
  • Forced enrollment for platform admins: full-page gate before AppShell renders if mfa.enrollmentRequired.
  • CLI emergency MFA reset at pnpm --filter @birdnet-ng/hub exec mfa:reset <email> for the lost-device-and-codes case.
  • Remember this device (30 days): opt-in cookie, sha256-hashed at rest in mfa_remembered_devices, with user_agent + ip_address for the listing UI on /account. Logout does NOT clear the cookie (matches Google / GitHub / Microsoft); users revoke individual devices explicitly or wait for expiry.
  • Rate limit hardening: a per-mfaToken attempt counter (5 wrong codes burns the token, forces password re-entry) sits alongside the existing per-user 5-strike lockout.
  • Audit log covers every MFA action: auth.mfa_enabled, auth.mfa_disabled_self, auth.mfa_reset_by_admin, auth.mfa_backup_code_used, auth.mfa_backup_codes_regenerated, auth.mfa_passkey_added, auth.mfa_passkey_removed, auth.mfa_enrollment_failed, user.mfa_challenge_issued, user.mfa_login_failed.
  • PlatformUsers shows a green shield next to MFA-enabled users with a "Reset MFA" warning button.
Performance
  • /api/detections accepts withCount=false to skip a multi-second COUNT(*) over the visibility-filtered table when the caller doesn't paginate. The Dashboard "recent detections" widget paid that cost on every 10-second polling tick: now ~5–10 ms instead of 2.6 s. The Verification queue also opts out.
  • The Detections page (paginated) keeps the count and now runs count + select in parallel via Promise.all.
Chores
  • ~70 new MFA i18n keys in en.json and fr.json.
  • Migrations 064 (users MFA columns + user_passkeys table), 065 (mfa_remembered_devices table).
  • Custom theme tokens in web/src/theme.ts: leaves / wood / surface / success / warning / danger / info / rare / neutral / gray, with component-level vars callbacks (Badge, Button, etc.) for per-scheme overrides.
  • New PageShell + SectionTitle primitives are the layout root for every page.
  • original_storage_key / original_content_type / original_file_size columns on species_images and species_image_files (migration 063).
  • gitea.owndesign.net Gitea hosting: PR #1 ui-rework → main merged ~33 commits.
  • 4 new migrations (063–066): image originals, MFA, remembered devices, extras exhaustion.
  • Total: 60 files changed, +10642 / -5172 over the ui-rework branch plus 4 follow-up commits on main.

0.26, all components

0.26.11

Fixes
  • Cosmetic: silence arecord format banner from satellite logs. arecord prints its capture parameters ("Signed 16 bit Little Endian,", "Rate 48000 Hz,", "Mono") to stderr at startup, and our wrapper logged everything from arecord stderr at warn level, so the banner surfaced as warnings every time a chunk recorded (clutter that masked real problems). -q on the arecord invocation now suppresses the banner.
  • The satellite keeps the arecord stderr listener, with a regex that downgrades known-harmless prefixes (Signed/Unsigned/Rate/Mono/Stereo/Hardware PCM) to debug; real errors (underruns, device-not-found, etc.) still log at warn.
  • The transfer modal description text is updated to reflect the v0.26.10 auto-push behavior.

0.26.10

Features
  • Tenant transfer auto-applies on the Pi, no SSH needed (v0.26.9 still required a manual sed + systemctl restart on the Pi after a transfer): the hub publishes a tenant-rewrite message on the source tenant's /update MQTT topic before committing the DB transaction.
  • The satellite receives the tenant-rewrite message (it's still subscribed to the source tenant's topic), runs sed -i on TENANT_ID in its .env and systemctl restart in a transient systemd-run unit, and reconnects with the new tenant_id, all in ~30s.
  • UpdateMessage carries an optional newTenantId; satellite handleUpdate branches on it, validates it as UUID before touching sed, and skips the code-update path.
  • The transfer modal shows a green "Reconfiguration pushed" banner when the satellite was online and falls back to the manual snippet when offline.

0.26.9

Features
  • Platform admin can transfer a satellite to another tenant. Until now, moving a Pi between tenants required hand-edits across four places (the satellites row, its detections / audio_chunks / alerts, MQTT credentials in dynsec, and the Pi's .env). Looking at the MQTT topic ACL revealed it wasn't actually four: the satellite role uses birdnet/+/%u/<channel> patterns (with + as the tenant_id wildcard), so credentials already cover any tenant.
  • New POST /api/satellites/:id/transfer does the DB move atomically.
  • New TransferSatellite action on the SatelliteDetail page (platform-admin only) drives it from the UI and prints a copy-pasteable two-line shell snippet for updating TENANT_ID in the Pi's .env and restarting the service.
  • Audit action: admin.satellite_transferred with source/destination tenant IDs + names + per-table move counts.
  • The transfer validates that the destination exists and differs from the source, and that the satellite exists.
  • All four DB tables (satellites, detections, audio_chunks, alerts) move in one transaction; rollback on any error.

0.26.8

Fixes
  • Fix: satellite update silently exited when install user has no nvm. v0.26.7's PATH-build code at top of birdnet-update.sh did NVM_NODE_DIR="$(ls "$INSTALL_HOME/.nvm/versions/node/" 2>/dev/null | tail -1)" to find the nvm-managed node version; on Pis where the install user uses a system node (Pi OS Bookworm has node in /usr/bin), the .nvm dir doesn't exist, ls exits 2, pipefail propagates, and bash 5.x triggers set -e on the failing command substitution inside the assignment. The script silently exited before any log line, before the trap is set, with no visible error (same symptom shape as v0.26.6's export PATH=$() bug, different specific cause).
  • birdnet-update.sh now guards with a -d test on the nvm dir, falls back to a plain PATH ($HOME/.local/share/pnpm + /usr/local/bin + /usr/bin + /bin) when no nvm is found, and adds || true on the inner pipeline as belt-and-suspenders.

0.26.7

Upgrade notes
  • For Pis with existing damage, the chown at start fixes them automatically on the next --force run; or do it manually with sudo chown -R "$USER:$USER" ~/birdnet.
Fixes
  • Fix: satellite update crashed with EACCES on second invocation. Past hub-triggered runs (systemd-run --uid=root) created root-owned files in ~/birdnet/node_modules/, yamnet-venv/, and .git/, and a subsequent run by the install user (or vice versa) crashed when pnpm install tried to recreate node_modules/.bin it didn't own.
  • The update script now identifies the repo owner via stat and routes every pnpm/git/python/venv invocation through an as_user wrapper (no-op when already that user, sudo -u otherwise).
  • The update script one-time-chowns prior root-owned damage on node_modules, dist/, yamnet-venv/, and .git/ at start.
  • PATH is forced to the install user's pnpm + nvm-managed node so the tooling is findable across both invocation modes.
  • DEPLOY_TMP is also created via as_user so pnpm deploy can write to it.

0.26.6

Fixes
  • Fix: --force now also skips the up-to-date short-circuit. Recovery flow snag: an install user who manually git pull'd their satellite checkout to fetch the v0.26.1 safe-directory fix, then ran birdnet-update.sh --force, hit "Already up to date" and exited, leaving /opt/birdnet-satellite/ still pinned at the old version because the build + redeploy steps were never reached.
  • With --force the update now proceeds regardless of whether the working tree already matches origin, so a manual pull + force-run does the full rebuild + redeploy. Cron / hub-poll callers that don't pass --force keep the cheap "no commits" early exit.

0.26.5

Upgrade notes
  • Existing legacy orphans on Pis can be removed with sudo rm -rf ~/tmp/tmp.*.
Fixes
  • Fix: Pi satellite update was leaking pnpm-deploy staging dirs to $HOME/tmp. Each hub-triggered update created a tmp.XXXXX/ for pnpm deploy --prod to stage into; birdnet-update.sh cleaned it up at the bottom of the if-block, but set -euo pipefail aborted the script before that line if anything failed (the v0.24.x safe-directory error in particular). On Pis where TMPDIR was set to $HOME/tmp (some setup guides recommend it for SD-card-friendliness), the leak landed root-owned dirs in the install user's home.
  • Defensive change: mktemp -d -p /tmp pins staging under /tmp regardless of the caller's TMPDIR (same in install.sh).
  • Defensive change: trap 'rm -rf "$DEPLOY_TMP"' EXIT (the same pattern install.sh already had), so cleanup now runs on aborts too.

0.26.4

Fixes
  • Cosmetic: silence harmless MQTT 'write after end' on satellite shutdown. Stopping the satellite cleanly (systemctl stop birdnet-satellite) often coincided with an inbound QoS 1 PUBLISH still being processed by the mqtt library: its customHandleAcks queues a PUBACK reply onto the underlying stream while endAsync() is tearing it down, tripping ERR_STREAM_WRITE_AFTER_END. The broker's QoS replay covers any unacknowledged delivery on the next connect, so no data loss, but it surfaced as level=50 in the journal on every clean stop.
  • The uploader now sets a disposing flag at disconnect entry and the error handler downgrades post-disconnect MQTT errors to debug; pre-disconnect errors still log at error severity.

0.26.3

Fixes
  • Cosmetic: drop spurious git error from satellite startup banner. The startup logger captures a short SHA via execSync("git rev-parse --short HEAD") for in-place dev installs, but /opt deployments don't have a .git (pnpm deploy copies only runtime files) and execSync inherited stderr by default, so fatal: not a git repository leaked to the journal on every restart, before the JS catch fired.
  • The startup logger now pipes stderr to ignore so the failure stays quiet; the SHA is still captured for dev checkouts.

0.26.2

Upgrade notes
  • All satellite-side logs in one query: journalctl -u 'birdnet-*' -f; or just the latest update attempt: journalctl -u birdnet-update -n 100.
Features
  • All Pi satellite logs in journalctl, single pane. birdnet-update.sh previously redirected stdout/stderr to ~/birdnet/update.log when running detached, splitting satellite-side observability across the journal (main service) and a file on disk (updates), which is exactly what hid the safe-directory regression for so long. The redirect is dropped: systemd-run --unit=birdnet-update already captures stdout/stderr to the journal under that unit name.
  • The YAMNet sidecar's stderr was already piped through the satellite logger, so it lands in the journal too.

0.26.1

Upgrade notes
  • All Pis on v0.24.x or later have been quietly stuck and need a one-time manual recovery to fetch the fixed script: on the Pi, as the install user, run cd ~/birdnet && git pull then ~/birdnet/packages/satellite/scripts/birdnet-update.sh --force. After that, hub-triggered updates resume working for all future releases.
Fixes
  • Fix: hub-triggered satellite updates were silently failing on every release since v0.24.x. birdnet-update.sh runs as root via sudo systemd-run --uid=root while the satellite repo lives under the install user's home (e.g. /home/deurk/birdnet), and Git's safe-directory check (CVE-2022-24765) refuses every operation when the user invoking git differs from the directory owner: fatal: detected dubious ownership in repository at '/home/deurk/birdnet' on every fetch attempt exhausted the 3-attempt retry loop and aborted before any pull or build. No telemetry made it back to the hub since the failure was inside the detached transient unit; the only trace was in ~/birdnet/update.log on each Pi.
  • The script now declares the repo path safe at start via git config --global --add safe.directory $PROJECT_ROOT (idempotent; persists in root's .gitconfig).

0.26.0

Features
  • Tentative retention sweep (Migration 061), one of three follow-ups to v0.25.4's Merlin-inspired temporal aggregation: the retention service grows a 6th pass that purges tentative rows older than each tenant's tentative_retention_hours (default 24, configurable 1–168 in Tenant Settings). Tentative detections that never recurred would otherwise accumulate in the detections table forever (only their visibility was gated, not their lifetime).
  • Audio chunks are reclaimed by the existing audio-retention passes; the tentative sweep just clears the row metadata.
  • Two-tier confidence chip on share pages: public share URLs previously rendered raw numeric confidence (e.g. "63%"), which is precise but not useful for casual recipients; they now show a binary "Likely / Possible" chip, borrowed from how Merlin presents single results. Power users still see the numeric value in the species profile detail rows.
  • The share-page chip is based on a composite of confidence + verification + neighbor support: Likely = confidence ≥ 0.85 OR human-confirmed OR (trust_score ≥ 0.7 AND neighbor_count ≥ 3); Possible otherwise.
  • Out-of-range high-confidence override (Migration 062): the eBird meta-model used to silently drop detections of species not on the regional allow-list, even when BirdNET was very sure, yet real birds occasionally show up where eBird's frequency table doesn't expect them (vagrants, recently-colonized populations, cryptic residents). The inference worker now sets return_all_detections=True, keeps any detection at confidence ≥ confidence_high (0.85) regardless of the range filter, and tags it is_out_of_range = true.
  • The UI surfaces an amber "Out of range / Hors zone" badge on the species profile and share pages, so the user can see and verify rather than miss.
Chores
  • EN + FR translations for all new keys.

0.25, all components

0.25.6

Fixes
  • The inference compare-mode load hint in PlatformSettings over-counted models: it counted all enabled models (default classifier + Perch v2 embedding + non-default classifiers), so a registry with v2.4 FP32 (default) + v2.4 FP16 (enabled) + Perch v2 (always-on embedding) read "3 models · 3.00× load" at 100% sample rate. Only enabled non-default classifiers add load in compare mode (the default classifier already runs on every chunk in single mode and Perch always runs unconditionally), so the hint now counts those only and prefixes them with "+" so the meaning is unambiguous.
  • The compare-mode load-multiplier formula is adjusted to 1 + extraCount × rate.

0.25.5

Fixes
  • v0.25.4 wired the temporal-aggregation toggle and badge to a dead page: the "Show tentative" filter checkbox and grey "Tentative" badge went onto Detections.tsx, which is no longer routed. The live /detections route renders DetectionsOverview (species summary) and individual detection rows live on SpeciesProfile, so the toggle moved into the SpeciesProfile filter row and the badge into its DetectionRow.
  • The missing detections.tentative / detections.include_tentative (+ tooltips) i18n keys are added in EN and FR; without them the toggle would have rendered the raw fallback text instead of localized strings.

0.25.4

Upgrade notes
  • Migration 060 bumps the confidence_high default from 0.7 to 0.85, with existing tenants below 0.85 migrated up; confidence_high now serves a second role as the single-shot promotion bypass threshold.
Features
  • Merlin-style temporal aggregation: a detection enters the system as tentative and is hidden from the UI / share pages / exports / alerts, and when a second detection of the same species on the same satellite arrives within a rolling 30-min window, both cascade-promote together. A per-chunk BirdNET hit at 0.4 confidence and a recurring activity pattern are very different signals, which were treated identically until now.
  • High-confidence single-shots (≥ confidence_high, default 0.85) bypass the temporal-aggregation gate immediately.
  • Why this matters: a single BirdNET hit at 0.4 confidence on a Cardinal in France was previously visible (passing the range filter when not under 0.01 threshold) and could trigger an alert; now it sits tentative, and if no second hit arrives in 30 min it stays hidden. Real birds repeat; false positives mostly don't.
  • Watcher promotion logic: gating runs before rarity / first-of-day / first-of-season / alerts and cascade-promotes prior tentatives in the window.
  • The is_first_of_* badges anchor on the earliest just-promoted detection, so they sit on the actual first vocalization, not on the cascade trigger 25 min later.
  • Filtered queries: /api/detections (with ?includeTentative=true opt-in), /api/detections/summary, share pages, scheduled exports, and alert engine trend rules all default to promoted-only; tentative detections never trigger notifications.
  • Verification voting still works on tentative detections; a consensus-confirmed vote auto-promotes (the user's manual "this is real" overrides the gate).
  • TenantSettings UI: new Temporal Aggregation section with enable toggle + Required hits slider (2–5) + Aggregation window slider (5–120 min); the confidence_high help text now explains its bypass role.
  • Detections page: "Show tentative" filter checkbox; tentative rows render with a grey badge.
  • EN + FR translations for all new keys.
Chores
  • Migration 060 adds detections.is_promoted BOOLEAN DEFAULT TRUE (preserves visibility for existing data) and tenant_settings.temporal_aggregation_{enabled,count,window_minutes} (true / 2 / 30).

0.25.3

Features
  • Per-tenant range-filter threshold (default 0.01): the worker reads it per-job via get_tenant_thresholds() and pre-empts birdnetlib's auto-applied filter, applying the meta-model manually for both bundled and custom classifiers so the threshold is honored uniformly. Setting it to 0 disables the range filter entirely.
  • A range-filter slider is exposed in TenantSettings (range 0–0.05, step 0.001) with a description that explains the trade-off and the 0.01 / 0.03 reference points, with EN + FR translations.
Fixes
  • The range filter's default threshold drops to 0.01 from birdnetlib's hardcoded 0.03, which v0.25.2 used when it restored the eBird range filter for custom classifiers. Investigating false negatives turned up a structural problem with 0.03: the meta-model's training data is biased toward eBird checklist activity, so resident species under-reported on eBird (Tawny Owl, Stock Dove, Scops Owl, Hooded Crow) get squeezed out of the allow-list as soon as the dawn chorus dominates eBird checklists in late spring.
  • At (43.566, 3.905) week 16, range-filter threshold 0.03 yields 160 species and excludes Tawny Owl despite it being a year-round vocal resident; threshold 0.01 yields 216 species and brings the four false negatives back, still excluding cross-continental hits (Cardinal, Pardalote, …).
Chores
  • Migration 059: tenant_settings.range_filter_threshold REAL NOT NULL DEFAULT 0.01.

0.25.2

Fixes
  • Custom BirdNET classifiers now apply the eBird range filter: promoting a catalog-installed BirdNET v2.4 / v2.3 / v2.2 / v2.1 (or any admin-uploaded .tflite) as default classifier silently doubled species/day with global out-of-range hits, because birdnetlib auto-runs its meta-model only when classifier_model_path is None (analyzer.py:332).
  • The worker now runs the meta-model manually at threshold 0.03 (matching the bundled-path default) and stashes the predicted species as custom_species_list before analyzing, with a per-process LRU cache keyed by (lat, lon, week_48); catalog models share the bundled label space, so the meta-model is valid for them too. The bundled-model code path is unchanged.
  • Verified end-to-end: with custom v2.4 promoted at (43.566, 3.905, week 16) the meta-model returns 160 France-plausible species, and out-of-range labels (House Finch, Northern Cardinal, Striated Pardalote…) no longer surface in detections.
  • nginx now resolves the api upstream per request: the web container's nginx was caching the api container's IP at startup, so rebuilding api alone (without web) stranded nginx on a stale upstream until the web container was restarted, returning 502 to the browser. It now uses a set $api_upstream …; proxy_pass $api_upstream; pattern with a resolver 127.0.0.11 valid=10s ipv6=off; directive so Docker's embedded DNS is consulted on every request.

0.25.1

Fixes
  • BirdNET v2.4 mammal labels were rendered as bird detections: the label file ships five mammal binomials (Canis lupus, Canis latrans, Sciurus carolinensis, Tamias striatus, Odocoileus virginianus), and migration 054's species_category() SQL function only matched the decoy classes (Engine, Power tools, Dog, ...) and fell through to 'bird' for proper Latin binomials, so a Gray Wolf classification slipped past show_other_animals=false. Migration 058 patches the function to handle these explicitly.
  • The mammal-label bug was found while investigating a doubled species count after a custom-uploaded BirdNET v2.4 was promoted as default classifier; the bigger issue (custom classifiers losing birdnetlib's auto eBird range filter) is tracked separately and will get its own release.

0.25.0

Features
  • The storage bar renders five segments (Models / Images / APKs / Protected audio / Unprotected audio) with hues spread across the wheel so adjacent segments read distinctly, ordered with small curated buckets on the left and audio bulk filling the rest. Dark inset separators are removed (no longer needed once hues are far apart, and they were creating an optical illusion that segments had different heights).
  • Species gallery size knob: species_extra_images_count (existing backend setting consumed by the Wikimedia worker, default 5, range 0–20) is now exposed in the PlatformSettings retention card so admins can tune gallery depth without touching SQL.
Fixes
  • The PlatformSettings storage overview now accounts for mobile APKs: the storage bar tracked audio + species images + BirdNET models but missed the mobile_releases bucket entirely, meaning every ./build-apk.sh run silently grew MinIO with no cap and no visibility.
  • StorageStats carries apkBytes / apkCount, and getStorageStats() reads mobile_releases.file_size directly (no MinIO listing needed).
  • APKs now roll on a keep-last-N retention: a new retention pass keeps the N most-recent rows in mobile_releases and drops the rest from MinIO + DB (default 5, tunable via the new apk_keep_last_n platform setting). The retention sweep's last-run summary sums the APK pass into the totals.
Chores
  • Migration 057 seeds apk_keep_last_n into platform_settings.

0.24, all components

0.24.2

Upgrade notes
  • Patch release for Pi deployment consolidation, with no code-side feature changes: the data dir moves from /var/lib/birdnet-satellite into /opt/birdnet-satellite/data so the entire self-contained /opt/birdnet-satellite/ deployment is one path.
  • The .env stops being copied into /opt; systemd's EnvironmentFile= points at ~/birdnet/packages/satellite/.env in the user's checkout, which is the single source of truth.
  • install.sh migrates legacy /var/lib/... data and /opt/.../.env into the new layout on first run, then deletes the legacy paths.
Features
  • install.sh now also systemctl restarts the service at the end so re-runs pick up the freshly-deployed binary with no manual follow-up.
Fixes
  • Outbox path self-heal: when install.sh moved /var/lib/.../audio into /opt/birdnet-satellite/data/audio, the WAVs migrated but outbox.db kept the old absolute paths, so the uploader hit ENOENT on every drain. The outbox now rewrites file_path row prefixes at startup to match the current dataDir, so this and any future data-dir relocation self-heals on the next service restart.
  • install.sh path-derived vars are now authoritative: a pre-existing .env carrying a stale DATA_DIR=/var/lib/birdnet-satellite was being sourced and then re-written, defeating the migration. install.sh now resets path-derived vars (DATA_DIR) after sourcing so the new layout always wins.
  • Hub-triggered updates work on /opt installs: install.sh writes BIRDNET_REPO_DIR=$REPO_ROOT to .env and the uploader reads it to locate birdnet-update.sh in the actual git checkout (where git pull works). Without this, the satellite was looking for the script under /opt/birdnet-satellite/scripts/ where it doesn't live.
Docs
  • docs/deployment/satellite-rpi.md gains a Filesystem layout table: every path the satellite reads or writes, with owner and purpose, in one grep-able spot.

0.24.1

Upgrade notes
  • Patch release (bug fixes, install-flow ergonomics, doc sync) with no breaking changes; safe to upgrade in place.
Features
  • Pi 3B install-flow rework: end-to-end Pi setup is now clone + drop .env + sudo install.sh.
  • install.sh self-bootstraps pnpm via corepack and uses pnpm deploy to resolve workspace deps properly.
  • birdnet-update.sh redeploys to /opt after rebuild when an /opt install exists.
  • CI=true is exported during the Pi install flow to silence pnpm's "modules dir will be reinstalled" prompt.
  • install.sh also reads .env from its script directory in addition to /opt/birdnet-satellite/.env, with per-var prompts for anything still unset.
  • The mobile LogViewer moves from the main recording screen into Settings → Diagnostics.
  • New mobile StorageCard on the main screen mirrors the hub's stacked bar, including the amber soft-cap reference line for visual parity.
Fixes
  • install.sh always builds the workspace: the previous "skip if dist/ exists" guard caused stale deploys.
  • Satellite audio device auto-detect at install AND runtime: USB sound card numbers shift across reboots, so a baked AUDIO_DEVICE=plughw:1,0 could fail with audio open error: No such file or directory once the mic re-enumerated. install.sh now resolves to a concrete plughw:CARD,DEV at install time, and the satellite re-detects at startup if the configured card is missing from arecord -l.
  • Outbox storage display: the Pi outbox had ~6000 zombie rows (status=acked + size_bytes=0), since pre-v0.21 code deleted the WAV on ack but kept the row and v0.21's column addition gave them size_bytes=0 forever. They contributed nothing but inflated the "Unsent: ... (6171 chunks)" line because the UI used total rowCount; backfill now drops zombies, and OutboxStats exposes unackedCount separately so the chunk count matches the bytes.
  • The satellite-detail storage bar reads the effective hard/soft caps via stitched resolveConfig() output, not the raw satellites row, so per-satellite overrides reflect immediately.
  • The override editor renders the boolean default placeholder as Activé / Désactivé instead of the JS literal.
  • Mobile outbox respects pushed caps: the hardcoded pendingChunks > 100 count cap is gone, and mobile now applies the hub-pushed outbox_hard_size_mb (byte cap, drops oldest on overflow) and outbox_max_age_hours.
  • Detection visibility filter on the species summary view: the DetectionsOverview page (cards with per-species counts) hit /api/detections/summary, which had no visibility filter, so Engine and other non-bird categories slipped through even with show_anthropogenic=false. Migration 056 adds detection_visible_for_tenant(), which consults each detection's own tenant settings inline; the filter is applied unconditionally on /detections and /detections/summary regardless of whether the caller passed a tenantId.
Docs
  • Docs sync: 13 markdown files updated to v0.24 reality, with the migration count corrected to 56 across all references, RETENTION_HOURS removed, the "adaptive noise floor + spectral peak SNR" filter description replaced by the YAMNet VAD pipeline everywhere, the configuration table swapped for the v0.24 schema, and API/MQTT references updated.
Chores
  • Migration 056: detection_visible_for_tenant(scientific TEXT, tid UUID) SQL function for tenant-scoped visibility filtering on cross-tenant queries.

0.24.0

Features
  • Split visibility (UI) from satellite drop (YAMNet) into independent knobs: until v0.23 the show_* toggles did double duty, hiding a category in the UI and telling the satellite to drop it pre-upload; migration 055 separates them.
  • tenant_settings.show_*: UI hide only, tenant-wide, default false.
  • tenant_settings.drop_*_at_satellite: YAMNet pre-upload drop, default true (preserves v0.23 behavior).
  • satellite_config_overrides.drop_*_at_satellite: per-satellite override (NULL = inherit tenant).
  • resolveConfig() now derives ConfigMessage.excludedYamnetCategories from drop_* (with override coalesce), independent of show_*.
  • Combination now possible, Hide from UI, keep in DB (show=false, drop=false): frog data lands in detections, hidden from list, available for later analysis.
  • Combination now possible, Drop from this Pi only: a per-satellite override sets drop_anthropogenic_at_satellite = true for the road-side Pi while leaving the rural Pi inherit false.
  • For the nonsensical show=true, drop=true combination (always invisible because data never reaches), the UI tooltip warns.
  • TenantSettings gains a "Pre-upload drop on satellite" section alongside the existing "Detection visibility".
  • SatelliteDetail's per-satellite override editor adds 5 boolean rows (with inherit/true/false).
  • Mobile DeviceConfigEditor exposes the same 5 toggles for device-side config-request MQTT messages.
  • Full FR translations for the new settings.
Chores
  • Migration 055 adds 5 drop_*_at_satellite BOOLEAN columns to tenant_settings (default true) and satellite_config_overrides (nullable).

0.23, all components

0.23.0

Features
  • Visibility propagates to the satellite: the hub's show_* toggles now flow through ConfigMessage.excludedYamnetCategories down to the satellite's YAMNet gate, so chunks dominated by an excluded category get dropped before upload, saving bandwidth and storage on noisy or remote installs instead of merely being hidden in the UI.
  • The sidecar (Python) and the mobile native plugin (Java) both compute per-category sums across the same window grid, over these AudioSet indices: bird 67, 103, 106, 107; amphibian 127 (Frog); insect 121, 122 (Insect, Cricket); anthropogenic 44 indices (vehicles, engines, alarms, fireworks, tools); human_voice 17 indices (speech, laughter, breathing, crowd, ...); other_animal 10 indices (dog, bark, cat, meow, cattle, ...).
  • A chunk passes when bird >= threshold AND no excluded-category score exceeds bird; the gate fails open if the runtime is missing.
  • Per-category drop counters reach the heartbeat's filterStats.

0.22, all components

0.22.0

Features
  • Detection visibility by category: migration 054 introduces a species_category(scientific_name) SQL function that classifies every BirdNET detection at query time into one of bird (default), amphibian (frogs/toads, ~16 genera), insect (crickets/katydids/cicadas, ~15 genera), anthropogenic (Engine, Power tools, Fireworks, Gun, Siren), human_voice (speech/vocal/whistle) or other_animal (Dog, Animal, Wild animals).
  • Five show_* toggles on tenant_settings all default false, and the user-facing detection list filters by the tenant-allowed categories; birds are always shown.
  • The previous ingest-time decoy drop in the inference worker is removed: the data flows into detections tagged appropriately and is hidden at display time, reversible per tenant, with nothing lost.
  • The settings UI gains a Detection visibility section with the 5 toggles + FR translations.
  • Per-satellite outbox usage display: the heartbeat now carries outboxStats ({ totalBytes, ackedBytes, unackedBytes, rowCount }); the Pi reports it from outbox.stats(), mobile estimates it from pendingChunks, and the hub persists it in satellites.outbox_stats JSONB.
  • The SatelliteDetail page renders a stacked outbox bar per satellite: green segment for unsent (critical), purple for acked (forensic), with the soft cap as an amber line and the hard cap as the bar's right edge. Bytes are formatted human-readable.
Chores
  • Migration 054: add show_* booleans, species_category() function + index, satellites.outbox_stats JSONB.

0.21, all components

0.21.0

Upgrade notes
  • Satellite + mobile no longer delete on ack: the purge policy decides when to drop.
Features
  • Satellite outbox: three-policy capacity-bounded retention, with new columns on tenant_settings + satellite_config_overrides, replaces the single retention_hours knob (acked-only TTL, cosmetic since the hub already has the data) with a proper three-axis model that mirrors the hub-side audio retention design. It properly bounds remote / disconnected installations: a satellite running offline for days no longer risks OOM, and unsent (critical) data gets longer right-of-way than acked (forensic) data under pressure.
  • outbox_soft_size_mb (default 5000): below this, keep everything; above, evict oldest ACKED first (preserves unsent data while there's pressure).
  • outbox_hard_size_mb (default 8000): hard ceiling, evict oldest of ANYTHING (incl. unsent) so the satellite can never fill its disk.
  • outbox_max_age_hours (default 720): final safety valve, drop chunks older than this regardless of size.
  • The Pi outbox sweep tightened from 30 min to 5 min so caps react faster under heavy ingest.
  • Outbox UI: tenant-wide defaults in TenantSettings; per-satellite overrides in SatelliteDetail; mobile editor + read-only Storage section in App.tsx; full FR translations.
Fixes
  • Satellites bootstrap config from hub on every MQTT connect: both Pi and mobile satellites now publish an empty-overrides ConfigRequest immediately after connecting, which the hub treats as a no-op write that still triggers a push of the merged effective config back. The satellite gets the source-of-truth values even after a fresh APK install / process restart, which would otherwise leave it stuck on client-side defaults until an admin happened to change a setting.
  • The Pi now applies retentionHours-style configs at runtime instead of capturing them once at startup.
  • Mobile in-app updater: the APK build script now stamps a unique MOBILE_BUILD_ID (epoch seconds) into __APP_VERSION__ as SemVer build metadata (e.g. 0.20.0+b1730131234), so the hub's version !== currentVersion check naturally distinguishes builds and the in-app updater fires on every new APK without manual force_update toggling.
  • Mobile URL-encodes the version when querying /api/mobile/latest (the literal + was being parsed as a space in the query string).
Chores
  • New size_bytes column on the local SQLite outbox, idempotently backfilled.
  • Migration 053: drop retention_hours; add outbox_soft_size_mb / outbox_hard_size_mb / outbox_max_age_hours.

0.20, all components

0.20.0

Upgrade notes
  • The APK grew from ~4 MB to ~15 MB (TFLite native libs + model).
Features
  • Mobile YAMNet VAD on Android: a new native Capacitor plugin (YamnetVadPlugin.java) backed by org.tensorflow:tensorflow-lite runs the same on-device bird-likelihood gate the Pi sidecar already uses, with the same model file (yamnet.tflite, ~4 MB) bundled as an Android asset and the same threshold (yamnet_min_bird_prob, default 0.05) the hub already pushes.
  • Empirically, 87% of indoor chunks were rejected on a Motorola test, mirroring the Pi's behavior outdoors; the phone gate fails open if the plugin/model isn't loaded (web preview, missing asset).
  • Mobile heartbeat reports filterStats: mobile satellites now populate filterStats (totalProcessed / totalSent / rejectedSilence / rejectedYamnet / rejectedOther), so the hub UI's Audio Filter card shows the same per-gate counts for phones that the Pi already exposes.
  • Per-row percentage on the satellite detail Audio Filter card: Silence and YAMNet rows now show count + % of total processed (e.g. 49 (34%)), matching what the Sent row already did.

0.19, all components

0.19.1

Features
  • The spectral peak / noise-floor / bird-band-check heuristics are dropped in favour of YAMNet: the on-device gate is now a cheap RMS silence pre-check followed by YAMNet, one configurable axis instead of five, with no per-site tuning required.
Fixes
  • Tenant settings sliders: scalar values stop being rendered as "0%". Auto-percent display is now gated on max ≤ 1, step ≥ 0.01, and no unit, so confidence sliders still read as "85%" while RMS / YAMNet sliders show "0.003" / "0.05", their actual numeric value.
Chores
  • Migration 052 drops the spectral filter columns filter_peak_snr, filter_bird_band_check and filter_noise_floor_alpha from tenant_settings + satellite_config_overrides, and adds yamnet_min_bird_prob (default 0.05) on both.
  • Satellite audio-filter.ts shrunk from ~230 lines (including a hand-rolled radix-2 FFT) to ~70.
  • Pi config push simplified: filterPeakSnr / filterBirdBandCheck / filterNoiseFloorAlpha removed from MQTT ConfigMessage; yamnetMinBirdProb added.
  • Heartbeat filterStats pruned to totalProcessed / totalSent / rejectedSilence / rejectedYamnet / rejectedOther.

0.19.0

Features
  • Pi-side YAMNet VAD as a final gate after the existing RMS/spectral filter: the sidecar runs YAMNet TFLite via tflite-runtime and sums AudioSet bird-like classes (empirically clear birds score 3.4–3.9 vs 0.0 for silence/noise), with a conservative default threshold of 0.05 (60× safety margin). It fails open when the model or runtime is missing.
  • The heartbeat reports a new rejectedYamnet count.
  • Drop BirdNET non-bird decoy classes at ingest: Dog, Engine, Fireworks, Gun, Human non-vocal/vocal/whistle, Noise, Power tools, Siren are filtered out in the Python inference worker before reaching the detections or shadow_detections tables.
  • Audio retention silent-chunk pass: the new silent_chunk_retention_hours platform setting (default 6h) purges unanalyzed/empty chunks.
Fixes
  • The audio retention last-run breakdown now sums all four passes (capacity / hard cap / silent / time).
  • In the audio retention UI, bar chart segment colors are disambiguated: the Models segment moved from amber to fuchsia, with 1.5px segment separators.
Chores
  • The YAMNet model is bundled at packages/satellite/models/yamnet.tflite (~4 MB); install.sh and birdnet-update.sh auto-bootstrap a Python venv with tflite-runtime + numpy<2.
  • Mobile APK build/upload one-liner: packages/mobile/build-apk.sh auto-loads the repo .env and derives HUB_URL from BNG_APP_FQDN + HUB_API_KEY from HUB_INTERNAL_API_KEY, so a plain ./build-apk.sh builds and uploads to the running hub in one shot.
  • Migration 051: silent_chunk_retention_hours platform setting.

0.12, all components

0.12.0

No notes for this release.

0.11, all components

0.11.4

No notes for this release.

0.11.3

No notes for this release.

0.11.2

No notes for this release.

0.11.1

No notes for this release.

0.11.0

No notes for this release.

0.10, all components

0.10.12

No notes for this release.

0.10.11

No notes for this release.

0.10.10

No notes for this release.

0.10.9

No notes for this release.

0.10.8

No notes for this release.

0.10.7

No notes for this release.

0.10.6

No notes for this release.

0.10.5

No notes for this release.

0.10.4

No notes for this release.

0.10.3

No notes for this release.

0.10.2

No notes for this release.

0.10.1

No notes for this release.

0.10.0

No notes for this release.

0.9, all components

0.9.0

Features
  • v0.9.0 completes Analytics & Visualization: biodiversity indices, migration patterns, weather correlation, enhanced map, dashboard, location comparison.

0.8, all components

0.8.8

Features
  • Location comparison: side-by-side satellite stats, species overlap with Jaccard index, activity comparison.

0.8.7

Features
  • Weather correlation: Open-Meteo integration, temperature + precipitation + detections overlay on Analytics.

0.8.6

Features
  • Migration patterns: month×species heatmap on Analytics, seasonal presence on species profile.

0.8.5

Features
  • Detection map: per-detection clustered markers, heatmap layer, layer toggles, time range selector.

0.8.4

Features
  • Enhanced dashboard: 7-day trend, hourly activity, biodiversity snapshot, fleet status.

0.8.3

Features
  • Biodiversity indices on satellite detail panel.

0.8.2

Features
  • Biodiversity indices: Shannon, Simpson, evenness per satellite on Analytics page.

0.8.1

Features
  • Detection card: share and comment icons in pill line.

0.8.0

Features
  • v0.8.0: Smart alerts engine (6 channel types, detection/absence/trend rules).
  • Scheduled exports.
  • Platform health banner.

0.7, all components

0.7.7

Features
  • Satellite update system: Pi update script, remote MQTT update trigger, web UI update button, mobile version check banner.

0.7.6

Fixes
  • Page-scroll bottom padding is fixed for consistent footer spacing.

0.7.5

Features
  • Expected species: translated names, alphabetical sort, foldable list with stats summary.

0.7.4

Features
  • Expected species per location (eBird integration), completing the v0.7.x roadmap.

0.7.3

Features
  • Shareable detection links.
  • Detection comments.
Chores
  • Species images get a dedicated MinIO bucket.

0.7.2

Features
  • Activity patterns: hour×day heatmaps, peak hours, Analytics heatmap section.
  • Satellites page: expandable rows replacing the side panel.

0.7.1

Features
  • Species profile pages: per-species stats, trend charts, hourly activity, confidence distribution, clickable species names.

0.7.0

Features
  • v0.7.0: Per-satellite config overrides with lock.
  • MQTT config-request channel.
  • Web UI override editor.
  • Mobile app editable settings.

0.6, all components

0.6.4

Features
  • Page layout system.
  • Server-side sorting/filtering/pagination across all data pages.
  • Timeline chunk hover preview.
  • SpectrogramPlayer placeholder.

0.6.3

Features
  • Detections page: inline confidence, voting mode toggle, load more pagination.
  • Time format user preference.
  • SpectrogramPlayer compact toolbar.

0.6.2

Features
  • Timeline page redesign: full-width horizontal layout, expand/collapse sessions, species filter pills, dimmed detection colors, hide empty sessions toggle.

0.6.1

No notes for this release.

0.6.0

No notes for this release.

0.5, all components

0.5.4

No notes for this release.

0.5.3

No notes for this release.

0.5.2

No notes for this release.

0.5.1

No notes for this release.

0.5.0

No notes for this release.

0.4, all components

0.4.8

No notes for this release.

0.4.7

No notes for this release.

0.4.6

No notes for this release.

0.4.5

No notes for this release.

0.4.4

No notes for this release.

0.4.3

No notes for this release.

0.4.2

No notes for this release.

0.4.1

No notes for this release.

0.4.0

No notes for this release.

0.3, all components

0.3.0

Features
  • Account page: self-service profile editing, password change, account deletion.
  • Audit log: tracks logins, password changes, role changes, deletions (platform admin view).
  • Tenant ownership transfer.
  • User search filters: by role, tenant, status.
  • Unified design system: CSS classes for buttons and badges, consistent across all pages.
  • Platform admin auto-persist from PLATFORM_ADMIN_EMAILS env var.
  • Version displayed in web UI sidebar and mobile app.
Fixes
  • Platform admin status was lost after an email change; it is now persisted as a DB flag.
  • Account/Sign out buttons had inconsistent sizing in the sidebar.
  • Version dropdown in docs: reliable navigation across all versions.
Security
  • Login security: rate limiting (5/15min per IP, 10 per email), account lockout after 5 failed attempts.
Docs
  • Documentation site: replaced VitePress with Express + markdown-it (MPA, no SPA routing issues).
  • Version switching in docs: plain links, same-page navigation, archived banner.
  • Docs sidebar hides pages that don't exist in archived versions.
Chores
  • Telemetry columns (cpu_temp, storage_free_bytes) now nullable for mobile compatibility.

0.2, all components

0.2.0

Upgrade notes
  • The stack now has 4 FQDNs (added docs site) and 8 containers (added docs).
Features
  • Species catalog page with sortable cards and rich per-species stats.
  • Detection map page with Leaflet.js, OpenStreetMap, satellite markers.
  • Species image download queue with MinIO storage (no more Wikipedia proxying).
  • Wikimedia API compliance: proper User-Agent, Retry-After, OAuth 2.0 support.
  • Smart rate limiting: sliding window counter with auto-throttle.
  • MQTT heartbeat for reliable satellite online status.
  • Sunrise/sunset scheduling for recording profiles (NOAA solar algorithm).
  • Verification review queue with spectrogram, audio, keyboard shortcuts.
  • Responsive web UI with mobile hamburger menu.
  • Keep screen on toggle for mobile app.
  • GPS auto-update toggle with configurable interval.
  • GPS gate: requires location before recording.
  • Satellite renaming from mobile app and web UI.
  • Species thumbnails with lightbox and Wikipedia link.
  • Secondary language names everywhere (inline, lightbox, toast).
  • Confidence bar redesigned with percentage inside colored fill.
  • Night migration profile uses sunset-relative times.
  • Mobile "MQTT" label renamed to "Hub connection".
Fixes
  • Phone satellite telemetry: nullable cpu_temp/storage columns.
  • Duplicate satellite prevention on tenant add.
  • Species name formatting: primary bold, secondary parenthesized, Latin italic.
Docs
  • Documentation site with version switching.
Chores
  • Unified versioning across all packages (version-bump script).
  • Telemetry upgraded from MQTT QoS 0 to QoS 1.

0.1, all components

0.1.0

Features
  • Initial release: core distributed bird identification system.
  • Satellite audio capture (ALSA, simulate, replay modes).
  • MQTT transport with auto-reconnect and outbox queue.
  • Hub audio ingestion (MinIO storage, BullMQ job queue).
  • BirdNET TFLite inference worker (Python 3.11).
  • PostgreSQL multi-tenant data store with row-level isolation.
  • Web UI dashboard with satellite fleet status, recent detections, alerts.
  • Web UI detection browser with filtering, audio playback, spectrogram.
  • Verification review queue with keyboard shortcuts.
  • Analytics (trends, top species, hourly activity).
  • Satellite fleet management with recording profile push.
  • Multi-language species names (38 languages, per-user preferences).
  • Species thumbnails from Wikipedia (background download queue, MinIO cache).
  • Responsive web UI layout with mobile hamburger menu.
  • Platform admin: user management, tenant management, settings.
  • Android phone satellite (Capacitor + native AudioRecord plugin).
  • Mobile app: in-app registration, GPS, background recording.
  • Mobile app settings panel: rename satellite, GPS auto-update toggle, keep screen on.
  • Mobile app: unregister with confirmation.
  • Geo-aware inference (GPS + species range filtering).
  • Rare species alerts (frequency-based + watchlist).
  • Enhanced re-processing (extended window, frequency isolation, cross-chunk correlation).
  • On-device audio filtering (RMS silence + bird-band frequency).
  • Sunrise/sunset scheduling for recording profiles.
  • Docker Compose 7-container stack (postgres, redis, mosquitto, minio, hub, web, worker).
  • Traefik integration (HTTPS + MQTTS + WSS).
  • Invite system with configurable expiry.
  • MQTT heartbeat for reliable satellite online status.
  • Webhook support with HMAC signatures.
Security
  • JWT cookie + Bearer token authentication.
  • 5-role permission model (viewer, member, admin, owner, platform admin).