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
- 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.
- 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.
share-links.test.tscovers making, expiring and forging links and the grace period for undated ones;record-retention.test.tscovers each retention period.
0.78
0.78.2
OTAVI_TLS_CERTRESOLVERis 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_FQDNis no longer read: remove it from.env, and its DNS record.
docker-compose.previous-names.ymlkeeps 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 byCOMPOSE_FILEandOTAVI_PREVIOUS_APP_FQDN/_MQTT_FQDN/_DOCS_FQDNin.env, and removed the same way once the move is over.
- 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.
- 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).
ops-check.shchecks 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
- 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%.
- 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.
- 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/shareanswers 409NOT_SHAREABLEfor 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.
- The features page, the API reference and the security notes describe what alerts and share links leave out.
hidden-categories.test.tspins the alert rules against hidden categories, the rule conditions as the editor saves them, and the share links.
0.78.0
- 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.
- 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.
- The features page and the API reference state that every page counts the same detections.
- One filter,
shownDetections()inservices/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.tschecks each counting endpoint against a detection of each kind.
0.77
0.77.3
- 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.
- 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.
- The API reference lists
/detections/summaryand/detections/species-timelinewith their tentative counts, and the list'sstart,endandincludeTentativeparameters; the features page describes tentative detections and today's Timeline.
- The detection watcher and the detection pages read a team's aggregation rule through one helper,
services/temporal-aggregation.ts;tentative-detections.test.tscovers the three endpoints.
0.77.2
- 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.0on, 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.
changelog-format.test.tsfails on a release note written as prose, an unknown heading or one out of order.
0.77.1
- 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.
- 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
- 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
- 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.
- 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
- 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.
- 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).
- The broker no longer recreates the retired shared
satelliterole at start; its last 14 logins, all for deleted satellites, were removed from the live broker.
0.75
0.75.1
- 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
- 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.
satellite_settings_effectiveis a view generated from the registry liketenant_settings_effective, compared with the resolver on random values and locks.
0.74
0.74.4
- 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
tarat 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_HOPSproxies, 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.
- A test checks that each visitor keeps their own address instead of nginx's.
0.74.3
- 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
- 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
- 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
- The hub-level language setting says it is also everyone's default for species names.
- 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).
- 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
- 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.
- 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.
- Preference values are checked like every other setting: the old preferences route stored whatever it was sent.
- The settings reference lists the new settings.
0.72
0.72.0
- 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.
- 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.
- The audit action list of the API reference is generated.
- 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
- 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
- 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.
- 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
- A role change also records the previous role.
- 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
- 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).
- 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.
- 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
- 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).
- The permissions tables of the settings and permissions reference are generated from the declarations.
- 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
- 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
- The satellite detail answers 404 for a satellite out of reach; it said "not found" with a 200.
- 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
- A comparison sample rate of 0 now compares no chunk; the worker read it as all of them.
- 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.
- 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_locksholds 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
pnpm ops:checknow fails when the last backup is older than 26 hours, since a running sidecar said nothing about its schedule.
- 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
- 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
- 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
- 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.
- 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
- 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.
- 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
- 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.
- 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.
- Configuration history no longer counts a single-precision copy of a number as a change.
0.66
0.66.1
- 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
- Settings registry, step 3: every setting satellites receive is written through one path,
PATCH /api/settings/effectiveat 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.
- 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.
- 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
- API callers:
GET /satellites/:id/configno longer returnsinheritedanddeviceInference, which the new routeGET /api/settings/effective?satelliteId=carries.
- 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.
- The registry test compares what the page shows with the 0.64.3 route on 150 random combinations.
0.64
0.64.5
- 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
- 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 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
- 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.
- The settings and permissions page defines this vocabulary.
0.64.1
- 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.
- The documentation gains a page listing every setting and permission with the levels where it lives.
0.64.0
- 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.
- The node and nginx images are pinned to their minor versions.
0.63
0.63.0
- The phone asks the replacement question from 1.26.0, so a reinstall no longer starts a new entry on team defaults.
- 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
- 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
maxBytesfor that.
0.62.4
- 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
- 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
- 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.
- The deployment guide says which storage folders to create, and for whom, before the first start.
0.62.1
- This release replaces 0.62.0, which was tagged but never deployed.
- 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.
- 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
- The hub refuses to start with placeholder or short secrets and names them (OTAVI_HUB_ALLOW_WEAK_SECRETS=1 keeps a development hub going).
- 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
- 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.
- See docs/reference/metrics.md for a scrape config and alerting ideas.
0.60
0.60.2
- The Pi registration screen prints the OTAVI_SAT_* names that Pi 1.6.0 reads.
- The P4.53 roadmap entry finally carries its done mark.
0.60.1
- 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
- A new
pnpm --filter @birdnet-ng/hub audio:orphansreports the audio files that earlier deletions left behind;--deleteremoves them.
- 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.
- Deleting a device now removes its audio files from the bucket, which it never did before.
0.59
0.59.1
- 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
- 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.
- 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
- 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
- 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
- Point OTAVI_BACKUP_REMOTE at a private bucket or an rclone crypt remote, since the backup tree now holds secrets.
- New
pnpm ops:checksmoke 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.
- Postgres and Redis are pinned by digest like MinIO and the broker.
0.56
0.56.0
- 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.
- 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
- 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
- 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.
- 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
- 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
- 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.
- 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.
- 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
- 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.
- 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.
- 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
- 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.
- The no-team welcome screen no longer offers Create a new team on hubs that disallow it.
- 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
- Devices registered before this release belong to the team and stay admin-managed, so nothing changes for existing setups.
- 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.
- Saving team settings works for a team whose settings page had never been opened (it silently saved nothing before).
- 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
- 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_NAMEorSMTP_FROMin.envmay want to update those two display strings.
- 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.
- 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(--checkverifies them).
0.49
0.49.1
- 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.
- 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
- /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
- 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
- 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
- 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.
- The docs-site walkthrough now points to the Getting Started page as the shareable link.
0.47
0.47.0
- 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
- The create-team welcome screen is translated en/fr.
- Tenant creation seeds the system alert rules (the removed /auth/create-tenant duplicate silently skipped seeding).
- 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).
- New "Your First Detections" onboarding guide on the docs site.
0.45
0.45.0
- 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/highlightsendpoint. - 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/dashboardaccepts the clienttz.- APK release uploads announce on the per-satellite MQTT
updatechannel so running phones surface the update banner within seconds.
/api/mobile/latestcompares SemVer so devices ahead of the latest release are not nagged, withforce_updateas the explicit rollback path.
/stats/dashboardcomputes all counters in a single scan.
0.44
0.44.2
- 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
- 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
- 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.
- 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
- 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
pdfas 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
- 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
- One time selector to rule them all: Timeline and Map share a new
TimeRangeControlwith 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.
- 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
- 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/detectionsaccept explicitstart/endISO params.- The species-timeline endpoint picks its bucket width adaptively (15 min ≤2 days → 1 week beyond 400 days) and returns
bucket_secondsfor 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).
- 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/longitudeon 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.
- 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
- Satellite config gains
inferenceRangeFilterThreshold(fromtenant_settings.range_filter_threshold) inresolveConfigand theConfigMessage, 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
- Satellite config now carries
inferenceMinConfidence(fromtenant_settings.min_confidence) andprimaryLanguage(fromtenant_settings.primary_language) inresolveConfigand theConfigMessage. 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
- The device-inference toggle pairs with the Pi support in Pi 1.2.0.
- On-device inference, Phase 4 (UI): the per-satellite
device_inferencetoggle 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
inferenceModeto the satellite over MQTT, which makes device mode reachable without manual SQL.
0.39.0
- 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 throughresolveConfigasConfigMessage.inferenceMode(hub/device) plusinferenceModelId(the active default classifier the device should run). - New
inference-resultMQTT 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-resultchunk (no inference job) and inserts detections flaggedwatcher_processed=false; the DetectionWatcher's newtriggerScan()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.
- Broker dynsec grant for the new
inference-resultchannel. - Four regression tests (hub suite now 50).
0.38
0.38.3
- The backup sidecar is now opt-in via
BACKUP_ENABLED(defaultfalse). - When
BACKUP_ENABLEDis off, the entrypoint exits 0 and the container staysExited(no idle container, no restart loop, viarestart: on-failure); when on, it runs the scheduler in the foreground. - Manual
docker compose run --rm backup backup.sh/restore.shwork regardless of the toggle.
0.38.2
- Off-site DR requires two operator-supplied values: an rclone remote in
config/backup/rclone.conf+BACKUP_REMOTE, andBNG_BACKUP_VOLUMESpointed at a separate disk.
- Backup and disaster recovery: a new
backupsidecar (pinned Alpine + postgresql17-client + rclone + crond) takes nightly backups: a rotatedpg_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.
- A runbook (
deployment/backup-restore.md) covers in-place restore, fresh-host recovery, and a non-destructive verify procedure.
0.38.1
- 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.
- GDPR follow-up: the data-export audit-log slice is capped at the 10,000 most recent entries with an
auditLogTruncatedflag 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
- GDPR data export: new
GET /api/auth/me/exportreturns 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.
- GDPR erasure:
DELETE /api/auth/mealready 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, whereON DELETE SET NULLonly clearsuser_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.
- Four regression tests added (hub suite now 31).
0.37
0.37.7
- 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.
- 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
- 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, butseedSystemAlertRulesnever setfire_mode, so every tenant created since 041 got thecooldowndefault back. The seed now setsfire_modeper 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
- Detections are timestamped by when the bird sang, not when the worker ran:
detected_atwas 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 writesdetected_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
- 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.
- Idempotent audio re-ingest: the
audio_chunksinsert gainsON CONFLICT (id) DO NOTHING, and a duplicate chunkId is acked without re-queueing inference. This also covers plain QoS 1 redelivery.
0.37.3
- 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.
usePollinggains 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).
- 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.
- 23 silent
.catch(() => {})on user-visible loads (comments, field notes, page lists, filter dropdowns) log a console warning instead of vanishing.
0.37.2
- Legacy tenant webhooks are removed in favor of alert channels: gone are the Tenant Settings > Webhooks tab, the
/api/webhooksroutes, 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 updated accordingly (api.md route table, webhooks.md legacy note dropped).
0.37.1
- 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.
- Webhook secrets encrypted at rest: a new
secret-boxservice (AES-256-GCM, sameMFA_ENCRYPTION_KEYas the TOTP store,enc:v1:prefix) seals webhook signing secrets in both stores, the legacywebhooks.secretcolumn andalert_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_secretinstead 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.satelliteIdwas 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/:idroutes 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/reference/webhooks.mdis rewritten around alert channels with Python + Node verification examples and the correct header name (it documentedX-Webhook-Signature, which nothing ever sent).
- Removed the
WebhookDispatcherservice, 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
- Deep health checks:
/api/healthstays a cheap liveness stub (satellites poll it for the version), and a new/api/health/readyactually 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.
- 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).
- 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.
- Docker now probes
/api/health/readyfor 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:
localhostresolves to::1in the alpine containers while Fastify listens IPv4-only, so the probe targets127.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
- A one-shot
pnpm --filter @birdnet-ng/hub queue:cleanscript trims pre-existing Redis backlogs (ran in prod: 680 MB to 13 MB). - Worker compose default is now
replicas: 2to match how the stack is actually operated, so a plain redeploy no longer collapses the fleet to one.
- 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) andremoveOnFail(7 d / 5000) defaults.
- First automated test suite:
pnpm --filter @birdnet-ng/hub test(vitest). The global setup spins an ephemeralpostgres:17-alpinecontainer (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
- Tenant-isolation hardening across every UUID-addressed route (three fixes, one model): routes like
GET /api/audio/:chunkIdorGET /api/detections/:idcarry no tenant in the URL, andrequireRolevalidates 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
canAccessTenanthelper: platform admins and the matching resolved tenant pass; session users get atenant_membersfallback so multi-tenant users aren't locked out of their own data on URLs without?tenantId=(audiosrcattributes, 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
hasRoleInTenanthelper for create routes whose target tenant comes from the body (POST /api/satellites, alert rules/channels, scheduled exports, field notes, andPOST /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.
- The dead
GET /api/audio/live/:chunkIdroute is removed: nothing ever populated its cache (live audio flows over the WebSocket payload).
0.36.3
- 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
- Devices can now change their own recording profile:
config-requestaccepts arecording_profileoverride (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
- 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
- Historical audit rows are not backfilled, so admin actions appear in the Activity feed from this deploy forward while alerts cover the full history.
- 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
/activitypage (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.
- 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.
- Invite creation and revocation, which never audit-logged at all, now do.
- Migration 074 adds
audit_log.tenant_id(nullable; platform-level actions legitimately have none) as the enabler of the Activity feed.AuditService.logaccepts an optionaltenantId, 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
- 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.
- 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
- 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
- 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
- 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
#workersdeep-links and Spotlight keep working. The old/platform/workersroute 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
- 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.
- The
Verificationpage 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
- 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/invitesaccepts an optionalemail. When the field is empty the existing copy-paste path is unchanged. - The invite response carries an
emailedboolean so the UI shows the right toast. - The mailer fails open as before: if
SMTP_HOSTis unset the invite still generates and the admin can copy the link; the response reportsemailed: falseand the UI surfaces a "SMTP not configured, email skipped" toast so the operator notices.
0.34.0
- Operators should verify SMTP is configured before relying on the password reset path: the mailer fails open as before, so without
SMTP_HOSTthe request still records the audit entry but the email is silently skipped.
- 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-passwordaccepts{email}, generates a 32-byte hex token, stores the SHA-256 hash with a 60-minute expiry in the newpassword_reset_tokenstable (migration 073), and mails a reset link.POST /api/auth/reset-passwordaccepts{token, password}, verifies the token is unused and unexpired, updates the password hash, marks the token used, and clearsfailed_login_attempts+locked_untilso a locked-out user is unblocked by the reset.- Web UI adds
/forgot-passwordand/reset-password?token=...routes and a "Forgot password?" link on the login screen, with new i18n keys in both English and French.
POST /api/auth/forgot-passwordreturns 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_attemptsledger 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
- Pin
minio/miniotoRELEASE.2025-09-07T16-13-09Zso upgrades are intentional. The previous shape (minio/miniobare, defaulting to:latest) was a CLAUDE.md violation and could silently roll the storage tier on anydocker compose pull.
0.33.1
- 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'sHUB_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 touchingdocker-compose.yml.
.env.examplegains commented entries for every new override so the surface is discoverable;BNG_APP_VOLUMES(previously required with no compose default) andSMTP_*(hub 0.33.0's mailer) are now present and documented.
docker-compose.ymlgains anx-hub-env: &hub-envYAML anchor that holds every connection var shared byapi,dispatcher, andworker(PG, Redis, MinIO, MQTT, plusLOG_LEVEL,MINIO_USE_SSL,MINIO_SPECIES_BUCKET,DOCS_INTERNAL_URL,WEB_HOST). Each service merges with<<: *hub-envand 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
- 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_HOSTlogs 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 … RETURNINGthat incrementsfailed_login_attempts(predicatefailed_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_photostables (migration 026) and the seven/api/field-notes/*routes have existed since v0.14, but the page that reads them was missing. Newpages/FieldNotes.tsxis 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 search: the Express + markdown-it docs site gains a topnav search input. The server walks every latest
.mdat boot, extracts title + h2/h3 + plain-text body, and exposes the flat array asGET /search.json(~150 KB). - Docs search client-side JS in
/static/search.jsfetches once on first focus and filters by substring across title (score 100) / headings (score 30) / body (score 10). Keyboard:Ctrl+K/Cmd+Kfocuses,Escclears, 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.
- New
services/mailer.tsfactors the SMTP transport from the two existing call sites (alert-channels.ts,export-scheduler.ts) into a sharedMailerclass, which readsSMTP_HOST/SMTP_PORT/SMTP_SECURE/SMTP_USER/SMTP_PASS/SMTP_FROMfrom env. - The existing
getFieldNotesAPI method gained asatelliteIdfilter. - The inline-typed list response was replaced by a
FieldNoteinterface so the per-detection field-note panel inDetections.tsxand the new Field notes page share a single shape.
0.32
0.32.10
- 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).
- Live detection feeds now honor the
show_*visibility toggles.GET /api/detectionsalready hid categories the tenant has set toshow_* = false(detection_visible_for_tenant()SQL function, migration 056), but the new MQTT/detectiontopic hub 0.32.4 and the WebSocket/ws/eventschannel bypassed it, so a tenant withshow_other_animals = falsecould still see "Dog" land in the mobile DetectionsFeed and as a web-UI live toast. DetectionEventgains acategoryVisiblefield, resolved once inDetectionWatchervia the same SQL function;DetectionPublisher(MQTT) and the WebSocket consumer skip when it's false.WebhookDispatcherandAlertRulesEngineignorecategoryVisibleon 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
- 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"]andlast_progress_at, and if the counter hasn't moved for 5 minutes ANDLLEN bull:inference:wait > 0, callsos._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
- 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 resolutionand got stuck in an internal reconnect loop; the process never exited, sorestart: unless-stoppedhad 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 manualdocker compose restart workercleared 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.setnow tracks consecutive failures, and after 5 in a row (~75 s of broken Redis) the worker callsos._exit(1)instead of swallowing the exception, so Docker restarts the container, the worker re-resolves DNS and the queue drains. It usesos._exit(notsys.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 logspost-mortem instead of silent.
0.32.7
- 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 exposedGET /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
downloadattribute with a derived filename, so the browser saves the APK rather than navigating.
0.32.6
- No behavior change for the web UI: it ignores the query param and relies on the cookie.
/api/auth/login/mfa/{totp,backup}now honors?returnToken=truefor 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
- Migration 072:
api_keys.satellite_id UUID NULL REFERENCES satellites(id) ON DELETE CASCADE+ partial index. Existing tenant-wide keys keepsatellite_id = NULLand 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.
- New
POST /api/satellites/:id/rotate-api-key(preHandlerrequireRole("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.
- 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
hubTokenand 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 asapiKeyin the response. The raw value is returned once; only the SHA-256 hash is persisted.
- Audit action
admin.satellite_api_key_rotatedadded.
- Auth resolver: the api_keys lookup now returns
satellite_idtoo, andrequest.auth.satelliteIdis 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
- Backwards-compatible: nothing breaks if no client subscribes, and older
vapkandvsatclients ignore the new topic.
- New
/detectionMQTT topic so satellites can show a live detection feed without polling (pairs with phone 1.3.0's new on-phone DetectionsFeed card). A newDetectionPublisherservice indispatcher+fullmodes subscribes to the in-processeventBusand mirrors eachDetectionEventontobirdnet/{tenant}/{satellite}/detectionMQTT (QoS 1). Single dispatcher path: alert engine, webhook dispatcher and now MQTT publishing all read off the same bus. - Confidence in the
/detectionmessage 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 callGET /api/detections/:idor the list endpoint, which already decorates each row.
- New shared
DetectionMessageinterface; themqttTopicchannel union is extended with"detection". MqttIngestergains a genericpublish(topic, payload, qos)so services can reuse the ingester's connection instead of opening another.
0.32.3
- On older satellites that don't report
diskBytes, the outbox usage card falls back gracefully to tracked-only bytes.
- 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.totalBytessumssize_bytesfrom outbox rows only. Used is nowmax(diskBytes, totalBytes)when the satellite reportsdiskBytesPi 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.
- The shared
OutboxStatstype gets an optionaldiskBytesfield.
0.32.2
- Live tenants already missing rules are fixed by re-running the same 4 idempotent INSERTs (see backfill SQL in the [PR description / commit body]).
- 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 inservices/alert-rules-engine.ts, idempotent:INSERT … WHERE NOT EXISTSguards on(tenant_id, system_key). POST /api/tenantsnow callsseedSystemAlertRulesright after creating the tenant + tenant_settings, so every newly created tenant gets its 4 rules.
- Migration 038 is unchanged: the helper replicates its shape so the two paths stay in sync.
0.32.1
- 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 verifiedasterisk overstated the threshold. recalibrate-historical.tsone-shot script (pnpm --filter @birdnet-ng/hub recalibrate:historical [--days=N]) re-walks every historical detection in pages of 1000 and recomputestrust_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=Nlimits scope, and the script is idempotent.
0.32.0
- 1 new migration (071 rarity_confidence).
- 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_confidencecolumn (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 distinctsubId(checklists) +locId(locations) from the same/data/obs/geo/recentpayload already fetched, so no extra API calls.isRareDetectionreturns{ isRare, reason, confidence }and the watcher writesrarity_confidencealongsideis_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
- 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.
- Trust score on calibrated confidence: both the inline
scoreTrust(at-creation) and the periodicrescoreRecentTrust(5-min sweep) now look up the per-(tenant, model) calibration curve and apply it to the row's raw confidence before passing tocompute_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
Spectrogramand theSpectrogramPlayeraccept anannotationsprop 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
- 2 new migrations (069 confidence_calibration, 070 detection_annotations).
- 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 withcalibrated_confidence, and the UI showsConf 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) withtime_start_ms / time_end_ms / freq_low_hz? / freq_high_hz? / vote / notesand mirror averification_votesrow, 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
pnpm --filter @birdnet-ng/hub backfill:licensesre-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.
- iNaturalist as a second image source: an opt-in fallback for gallery extras (
inaturalist_enabledplatform 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 includecc-by-ncvariants. Photos withlicense_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_atfor Wikipedia,inaturalist_exhausted_atfor iNat (migration 068). - License code captured + displayed:
species_image_files.license_codeandspecies_images.license_codeare 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.hashso 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
tenantIdfrom 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.
- 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
paramslazily andparams.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 ofundefined. - 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.
- FR-only stale i18n keys pruned; en + fr now symmetric.
- 1 new migration (068):
license_code+source+inaturalist_exhausted_atcolumns.
0.28
0.28.0
- Initial values when per-stream versioning lands: hub
0.28.0, satellite1.0.0, mobile1.0.0. - Pi
birdnet-update.shnow checks out the latestvsat-*tag instead oforigin/main; hub-triggered updates work unchanged. - Pre-decoupling heartbeats without
streamfall back to the hub-version comparison.
- 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/versionsexposes{ hub, satellite, mobile }, the latest target per stream, computed from the bundledpackage.jsonfiles (mobile additionally consultsmobile_releases.MAX(version)).
- 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.
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 (--forceto override).- Heartbeat carries a new
streamfield ("satellite"|"mobile"); the hub records it insatellites.stream(migration 067) and uses it to pick the right comparison target. - Mobile
build-apk.shderivesversionCodedeterministically 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
- 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 fromBNG_APP_FQDN). - Migrations 063–066 run automatically on hub startup.
- The
splitcompose profile is recommended (docker compose --profile split up -d) so api + dispatcher run as separate containers.
- 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, andtheme.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
/designpalette + 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=1flag on/api/species/image/proxyand/by-keyreturns 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.
- Bug fix: a stale
require("node:crypto")in the worker'ssha256()was throwingReferenceError: require is not definedinsidefetchExtraImagesevery 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) < targetand 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_atcolumn (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_sizefor both image tables (was missing the originals). /api/stats/activity?period=todayfilters 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).
- 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 withMFA_ENCRYPTION_KEY, 5-minutemfaTokenJWT 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-
mfaTokenattempt 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.
/api/detectionsacceptswithCount=falseto skip a multi-secondCOUNT(*)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.
- ~70 new MFA i18n keys in
en.jsonandfr.json. - Migrations 064 (users MFA columns +
user_passkeystable), 065 (mfa_remembered_devicestable). - Custom theme tokens in
web/src/theme.ts:leaves/wood/surface/success/warning/danger/info/rare/neutral/gray, with component-levelvarscallbacks (Badge, Button, etc.) for per-scheme overrides. - New
PageShell+SectionTitleprimitives are the layout root for every page. original_storage_key/original_content_type/original_file_sizecolumns onspecies_imagesandspecies_image_files(migration 063).gitea.owndesign.netGitea hosting: PR #1ui-rework → mainmerged ~33 commits.- 4 new migrations (063–066): image originals, MFA, remembered devices, extras exhaustion.
- Total: 60 files changed, +10642 / -5172 over the
ui-reworkbranch plus 4 follow-up commits on main.
0.26, all components
0.26.11
- Cosmetic: silence arecord format banner from satellite logs.
arecordprints 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).-qon 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
- Tenant transfer auto-applies on the Pi, no SSH needed (v0.26.9 still required a manual
sed+systemctl restarton the Pi after a transfer): the hub publishes a tenant-rewrite message on the source tenant's/updateMQTT 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 -ionTENANT_IDin its.envandsystemctl restartin a transientsystemd-rununit, and reconnects with the new tenant_id, all in ~30s. - UpdateMessage carries an optional
newTenantId; satellitehandleUpdatebranches on it, validates it as UUID before touchingsed, 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
- Platform admin can transfer a satellite to another tenant. Until now, moving a Pi between tenants required hand-edits across four places (the
satellitesrow, itsdetections/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 usesbirdnet/+/%u/<channel>patterns (with+as the tenant_id wildcard), so credentials already cover any tenant. - New
POST /api/satellites/:id/transferdoes the DB move atomically. - New
TransferSatelliteaction on the SatelliteDetail page (platform-admin only) drives it from the UI and prints a copy-pasteable two-line shell snippet for updatingTENANT_IDin the Pi's.envand restarting the service. - Audit action:
admin.satellite_transferredwith 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
- Fix: satellite update silently exited when install user has no nvm. v0.26.7's PATH-build code at top of
birdnet-update.shdidNVM_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.nvmdir doesn't exist,lsexits 2,pipefailpropagates, and bash 5.x triggersset -eon 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'sexport PATH=$()bug, different specific cause). birdnet-update.shnow guards with a-dtest 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|| trueon the inner pipeline as belt-and-suspenders.
0.26.7
- For Pis with existing damage, the chown at start fixes them automatically on the next
--forcerun; or do it manually withsudo chown -R "$USER:$USER" ~/birdnet.
- 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 whenpnpm installtried to recreatenode_modules/.binit didn't own. - The update script now identifies the repo owner via
statand routes every pnpm/git/python/venv invocation through anas_userwrapper (no-op when already that user,sudo -uotherwise). - 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_TMPis also created viaas_userso pnpm deploy can write to it.
0.26.6
- Fix:
--forcenow also skips the up-to-date short-circuit. Recovery flow snag: an install user who manuallygit pull'd their satellite checkout to fetch the v0.26.1 safe-directory fix, then ranbirdnet-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
--forcethe 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--forcekeep the cheap "no commits" early exit.
0.26.5
- Existing legacy orphans on Pis can be removed with
sudo rm -rf ~/tmp/tmp.*.
- Fix: Pi satellite update was leaking pnpm-deploy staging dirs to
$HOME/tmp. Each hub-triggered update created atmp.XXXXX/forpnpm deploy --prodto stage into;birdnet-update.shcleaned it up at the bottom of the if-block, butset -euo pipefailaborted the script before that line if anything failed (the v0.24.x safe-directory error in particular). On Pis whereTMPDIRwas 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 /tmppins staging under/tmpregardless of the caller'sTMPDIR(same ininstall.sh). - Defensive change:
trap 'rm -rf "$DEPLOY_TMP"' EXIT(the same patterninstall.shalready had), so cleanup now runs on aborts too.
0.26.4
- 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: itscustomHandleAcksqueues a PUBACK reply onto the underlying stream whileendAsync()is tearing it down, trippingERR_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
disposingflag 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
- 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/optdeployments don't have a.git(pnpm deploycopies only runtime files) andexecSyncinherited stderr by default, sofatal: not a git repositoryleaked 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
- All satellite-side logs in one query:
journalctl -u 'birdnet-*' -f; or just the latest update attempt:journalctl -u birdnet-update -n 100.
- All Pi satellite logs in journalctl, single pane.
birdnet-update.shpreviously redirected stdout/stderr to~/birdnet/update.logwhen 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-updatealready 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
- 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 pullthen~/birdnet/packages/satellite/scripts/birdnet-update.sh --force. After that, hub-triggered updates resume working for all future releases.
- Fix: hub-triggered satellite updates were silently failing on every release since v0.24.x.
birdnet-update.shruns as root viasudo systemd-run --uid=rootwhile 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.logon 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
- 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 thedetectionstable 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.85OR human-confirmed OR (trust_score ≥ 0.7ANDneighbor_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 atconfidence ≥ confidence_high(0.85) regardless of the range filter, and tags itis_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.
- EN + FR translations for all new keys.
0.25, all components
0.25.6
- 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
- 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/detectionsroute rendersDetectionsOverview(species summary) and individual detection rows live onSpeciesProfile, so the toggle moved into the SpeciesProfile filter row and the badge into itsDetectionRow. - 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
- Migration 060 bumps the
confidence_highdefault from 0.7 to 0.85, with existing tenants below 0.85 migrated up;confidence_highnow serves a second role as the single-shot promotion bypass threshold.
- 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=trueopt-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_highhelp 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.
- Migration 060 adds
detections.is_promoted BOOLEAN DEFAULT TRUE(preserves visibility for existing data) andtenant_settings.temporal_aggregation_{enabled,count,window_minutes}(true / 2 / 30).
0.25.3
- 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.
- 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, …).
- Migration 059:
tenant_settings.range_filter_threshold REAL NOT NULL DEFAULT 0.01.
0.25.2
- 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 whenclassifier_model_pathis 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_listbefore 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
apiupstream 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 aset $api_upstream …; proxy_pass $api_upstream;pattern with aresolver 127.0.0.11 valid=10s ipv6=off;directive so Docker's embedded DNS is consulted on every request.
0.25.1
- 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 pastshow_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
- 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.
- The PlatformSettings storage overview now accounts for mobile APKs: the storage bar tracked audio + species images + BirdNET models but missed the
mobile_releasesbucket entirely, meaning every./build-apk.shrun silently grew MinIO with no cap and no visibility. StorageStatscarriesapkBytes/apkCount, andgetStorageStats()readsmobile_releases.file_sizedirectly (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_releasesand drops the rest from MinIO + DB (default 5, tunable via the newapk_keep_last_nplatform setting). The retention sweep's last-run summary sums the APK pass into the totals.
- Migration 057 seeds
apk_keep_last_nintoplatform_settings.
0.24, all components
0.24.2
- Patch release for Pi deployment consolidation, with no code-side feature changes: the data dir moves from
/var/lib/birdnet-satelliteinto/opt/birdnet-satellite/dataso the entire self-contained/opt/birdnet-satellite/deployment is one path. - The
.envstops being copied into/opt; systemd'sEnvironmentFile=points at~/birdnet/packages/satellite/.envin the user's checkout, which is the single source of truth. - install.sh migrates legacy
/var/lib/...data and/opt/.../.envinto the new layout on first run, then deletes the legacy paths.
- 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.
- Outbox path self-heal: when install.sh moved
/var/lib/.../audiointo/opt/birdnet-satellite/data/audio, the WAVs migrated butoutbox.dbkept the old absolute paths, so the uploader hitENOENTon every drain. The outbox now rewritesfile_pathrow prefixes at startup to match the currentdataDir, 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
.envcarrying a staleDATA_DIR=/var/lib/birdnet-satellitewas 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
/optinstalls: install.sh writesBIRDNET_REPO_DIR=$REPO_ROOTto.envand the uploader reads it to locatebirdnet-update.shin the actual git checkout (wheregit pullworks). Without this, the satellite was looking for the script under/opt/birdnet-satellite/scripts/where it doesn't live.
docs/deployment/satellite-rpi.mdgains a Filesystem layout table: every path the satellite reads or writes, with owner and purpose, in one grep-able spot.
0.24.1
- Patch release (bug fixes, install-flow ergonomics, doc sync) with no breaking changes; safe to upgrade in place.
- 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 deployto resolve workspace deps properly. birdnet-update.shredeploys to/optafter rebuild when an/optinstall exists.CI=trueis exported during the Pi install flow to silence pnpm's "modules dir will be reinstalled" prompt.- install.sh also reads
.envfrom 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
StorageCardon the main screen mirrors the hub's stacked bar, including the amber soft-cap reference line for visual parity.
- 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,0could fail withaudio open error: No such file or directoryonce the mic re-enumerated. install.sh now resolves to a concreteplughw:CARD,DEVat install time, and the satellite re-detects at startup if the configured card is missing fromarecord -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, andOutboxStatsexposesunackedCountseparately so the chunk count matches the bytes. - The satellite-detail storage bar reads the effective hard/soft caps via stitched
resolveConfig()output, not the rawsatellitesrow, 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 > 100count cap is gone, and mobile now applies the hub-pushedoutbox_hard_size_mb(byte cap, drops oldest on overflow) andoutbox_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 withshow_anthropogenic=false. Migration 056 addsdetection_visible_for_tenant(), which consults each detection's own tenant settings inline; the filter is applied unconditionally on/detectionsand/detections/summaryregardless of whether the caller passed atenantId.
- Docs sync: 13 markdown files updated to v0.24 reality, with the migration count corrected to 56 across all references,
RETENTION_HOURSremoved, 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.
- Migration 056:
detection_visible_for_tenant(scientific TEXT, tid UUID)SQL function for tenant-scoped visibility filtering on cross-tenant queries.
0.24.0
- 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 derivesConfigMessage.excludedYamnetCategoriesfromdrop_*(with override coalesce), independent ofshow_*.- Combination now possible, Hide from UI, keep in DB (
show=false, drop=false): frog data lands indetections, hidden from list, available for later analysis. - Combination now possible, Drop from this Pi only: a per-satellite override sets
drop_anthropogenic_at_satellite = truefor the road-side Pi while leaving the rural Pi inheritfalse. - 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-requestMQTT messages. - Full FR translations for the new settings.
- Migration 055 adds 5
drop_*_at_satelliteBOOLEAN columns totenant_settings(default true) andsatellite_config_overrides(nullable).
0.23, all components
0.23.0
- Visibility propagates to the satellite: the hub's
show_*toggles now flow throughConfigMessage.excludedYamnetCategoriesdown 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:
bird67, 103, 106, 107;amphibian127 (Frog);insect121, 122 (Insect, Cricket);anthropogenic44 indices (vehicles, engines, alarms, fireworks, tools);human_voice17 indices (speech, laughter, breathing, crowd, ...);other_animal10 indices (dog, bark, cat, meow, cattle, ...). - A chunk passes when
bird >= thresholdAND 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
- Detection visibility by category: migration 054 introduces a
species_category(scientific_name)SQL function that classifies every BirdNET detection at query time into one ofbird(default),amphibian(frogs/toads, ~16 genera),insect(crickets/katydids/cicadas, ~15 genera),anthropogenic(Engine, Power tools, Fireworks, Gun, Siren),human_voice(speech/vocal/whistle) orother_animal(Dog, Animal, Wild animals). - Five
show_*toggles ontenant_settingsall 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
detectionstagged 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 fromoutbox.stats(), mobile estimates it frompendingChunks, and the hub persists it insatellites.outbox_statsJSONB. - 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.
- Migration 054: add
show_*booleans,species_category()function + index,satellites.outbox_statsJSONB.
0.21, all components
0.21.0
- Satellite + mobile no longer delete on ack: the purge policy decides when to drop.
- Satellite outbox: three-policy capacity-bounded retention, with new columns on
tenant_settings+satellite_config_overrides, replaces the singleretention_hoursknob (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.
- 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'sversion !== currentVersioncheck naturally distinguishes builds and the in-app updater fires on every new APK without manualforce_updatetoggling. - Mobile URL-encodes the version when querying
/api/mobile/latest(the literal+was being parsed as a space in the query string).
- New
size_bytescolumn on the local SQLite outbox, idempotently backfilled. - Migration 053: drop
retention_hours; addoutbox_soft_size_mb/outbox_hard_size_mb/outbox_max_age_hours.
0.20, all components
0.20.0
- The APK grew from ~4 MB to ~15 MB (TFLite native libs + model).
- Mobile YAMNet VAD on Android: a new native Capacitor plugin (
YamnetVadPlugin.java) backed byorg.tensorflow:tensorflow-literuns 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
- 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.
- 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.
- 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
- 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.
- 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.
- 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
- v0.9.0 completes Analytics & Visualization: biodiversity indices, migration patterns, weather correlation, enhanced map, dashboard, location comparison.
0.8, all components
0.8.8
- Location comparison: side-by-side satellite stats, species overlap with Jaccard index, activity comparison.
0.8.7
- Weather correlation: Open-Meteo integration, temperature + precipitation + detections overlay on Analytics.
0.8.6
- Migration patterns: month×species heatmap on Analytics, seasonal presence on species profile.
0.8.5
- Detection map: per-detection clustered markers, heatmap layer, layer toggles, time range selector.
0.8.4
- Enhanced dashboard: 7-day trend, hourly activity, biodiversity snapshot, fleet status.
0.8.3
- Biodiversity indices on satellite detail panel.
0.8.2
- Biodiversity indices: Shannon, Simpson, evenness per satellite on Analytics page.
0.8.1
- Detection card: share and comment icons in pill line.
0.8.0
- 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
- Satellite update system: Pi update script, remote MQTT update trigger, web UI update button, mobile version check banner.
0.7.6
- Page-scroll bottom padding is fixed for consistent footer spacing.
0.7.5
- Expected species: translated names, alphabetical sort, foldable list with stats summary.
0.7.4
- Expected species per location (eBird integration), completing the v0.7.x roadmap.
0.7.3
- Shareable detection links.
- Detection comments.
- Species images get a dedicated MinIO bucket.
0.7.2
- Activity patterns: hour×day heatmaps, peak hours, Analytics heatmap section.
- Satellites page: expandable rows replacing the side panel.
0.7.1
- Species profile pages: per-species stats, trend charts, hourly activity, confidence distribution, clickable species names.
0.7.0
- 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
- Page layout system.
- Server-side sorting/filtering/pagination across all data pages.
- Timeline chunk hover preview.
- SpectrogramPlayer placeholder.
0.6.3
- Detections page: inline confidence, voting mode toggle, load more pagination.
- Time format user preference.
- SpectrogramPlayer compact toolbar.
0.6.2
- 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
- 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.
- 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.
- Login security: rate limiting (5/15min per IP, 10 per email), account lockout after 5 failed attempts.
- 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.
- Telemetry columns (cpu_temp, storage_free_bytes) now nullable for mobile compatibility.
0.2, all components
0.2.0
- The stack now has 4 FQDNs (added docs site) and 8 containers (added docs).
- 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".
- Phone satellite telemetry: nullable cpu_temp/storage columns.
- Duplicate satellite prevention on tenant add.
- Species name formatting: primary bold, secondary parenthesized, Latin italic.
- Documentation site with version switching.
- Unified versioning across all packages (version-bump script).
- Telemetry upgraded from MQTT QoS 0 to QoS 1.
0.1, all components
0.1.0
- 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.
- JWT cookie + Bearer token authentication.
- 5-role permission model (viewer, member, admin, owner, platform admin).