From e71ee99ab5d1a5d4395e0e43f649798880a89e4f Mon Sep 17 00:00:00 2001
From: Niels Bik
Date: Fri, 21 Aug 2026 20:14:33 +0200
Subject: [PATCH 1/8] fix: make /data the real persistence root
---
Dockerfile | 49 +++++++++++++++++++++++++++++----------------
README.md | 16 +++++++++++----
nginx/entrypoint.sh | 8 ++++++++
3 files changed, 52 insertions(+), 21 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index e806a33..68560a3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -62,25 +62,40 @@ RUN cd /opt/saezuri/server && node -e "require('@napi-rs/canvas')"
# Static bundle.
COPY --from=build /app/dist /usr/share/nginx/html
-# Short, typeable mount paths for the two persistent stores. The real directories
-# stay under the html root, where nginx already serves them and where the refresh
-# service already writes, so a volume mounted the old way keeps working
-# untouched; these symlinks only spare operators a 45-character -v target.
-# Docker resolves symlinks in a mount destination, so a volume mounted on
-# /data/illustrations lands on the real directory.
-RUN mkdir -p /usr/share/nginx/html/assets/illustrations \
- /usr/share/nginx/html/assets/calls \
- /data \
- && ln -s /usr/share/nginx/html/assets/illustrations /data/illustrations \
- && ln -s /usr/share/nginx/html/assets/calls /data/calls
+# /data is the real persistence root for the two stores; the html root reaches
+# them through symlinks. This direction, not the reverse, because the Home
+# Assistant Supervisor mounts its persistent volume at /data (alongside the
+# options.json it writes there) — symlinks under /data would be mounted over, and
+# downloaded art would silently land in the ephemeral container layer instead.
+# Docker resolves symlinks in a mount destination, so a volume mounted the long
+# way (/usr/share/nginx/html/assets/illustrations) still lands on /data: both
+# spellings keep working, and an existing deployment keeps its data because that
+# data lives in the volume, not at a path.
+#
+# The bundled art the build just shipped (the generic fallback silhouette) has to
+# move out of the way first — `ln -s` onto an existing directory silently nests
+# the link inside it. It cannot simply move *into* /data either: under the
+# Supervisor /data is a bind mount, which masks image content rather than seeding
+# it the way a named volume does. So it is parked here and re-seeded at start by
+# nginx/entrypoint.sh. `rm -rf` for calls, which never has bundled content.
+RUN mkdir -p /opt/saezuri/bundled \
+ && mv /usr/share/nginx/html/assets/illustrations /opt/saezuri/bundled/illustrations \
+ && rm -rf /usr/share/nginx/html/assets/calls \
+ && mkdir -p /data/illustrations /data/calls \
+ && ln -s /data/illustrations /usr/share/nginx/html/assets/illustrations \
+ && ln -s /data/calls /usr/share/nginx/html/assets/calls
-# Config template (installed at start) + entrypoint hooks. The template lives
-# OUTSIDE /etc/nginx/templates so the image's built-in envsubst step doesn't
-# clobber nginx's own $variables — our hook just copies it verbatim now that
-# nothing is substituted. 40 installs the static-serving config; 50 launches the
-# refresh service (fetches per-species art + dictionaries, publishes the
-# snapshot). Both run before nginx, in that order.
+# Config template (installed at start) + the location blocks it includes +
+# entrypoint hooks. The template lives OUTSIDE /etc/nginx/templates so the
+# image's built-in envsubst step doesn't clobber nginx's own $variables — our
+# hook just copies it verbatim now that nothing is substituted. The locations
+# file is included from a server block, so it ships unconditionally: the Home
+# Assistant add-on wrapper adds a second server block that includes the same
+# file. 40 installs the static-serving config; 50 launches the refresh service
+# (fetches per-species art + dictionaries, publishes the snapshot). Both run
+# before nginx, in that order.
COPY nginx/default.conf.template /etc/nginx/saezuri.conf.template
+COPY nginx/saezuri-locations.conf /etc/nginx/saezuri-locations.conf
COPY nginx/entrypoint.sh /docker-entrypoint.d/40-saezuri.sh
COPY nginx/generator.sh /docker-entrypoint.d/50-generator.sh
RUN chmod +x /docker-entrypoint.d/40-saezuri.sh \
diff --git a/README.md b/README.md
index 3ea0167..4e79d1b 100644
--- a/README.md
+++ b/README.md
@@ -139,10 +139,10 @@ well as an x86 host. The two volumes keep the illustrations
and reference recordings it collects, so replacing the container doesn't start it over —
both sections below explain what lands in them.
-`/data/illustrations` and `/data/calls` are symlinks to where the files actually live,
-`/usr/share/nginx/html/assets/illustrations` and `.../assets/calls`. Docker resolves a symlinked
-mount destination, so both spellings mount the same directory: the short one is just less to
-type, and an existing deployment mounted on the long path keeps working unchanged.
+`/data/illustrations` and `/data/calls` are where the files actually live;
+`/usr/share/nginx/html/assets/illustrations` and `.../assets/calls` are symlinks to them. Docker
+resolves a symlinked mount destination, so both spellings mount the same directory: the short one
+is just less to type, and an existing deployment mounted on the long path keeps working unchanged.
## On-demand generation (optional)
@@ -280,6 +280,14 @@ cp .env.example .env # set BIRDNETGO_URL (and BIRDNETGO_TOKEN if
docker compose up --build
```
+## Home Assistant
+
+Saezuri also ships as a Home Assistant add-on: add
+`https://github.com/vrwrts/saezuri` as an add-on repository and it installs from the
+store, appears in the sidebar through ingress, and finds a BirdNET-Go add-on on the
+same machine by itself. Everything add-on specific lives in [`addon/`](addon/); the
+page users read inside Home Assistant is [`addon/DOCS.md`](addon/DOCS.md).
+
## Landing page
A static one-pager (Astro) lives in [`site/`](site/) and shares the app's design tokens
diff --git a/nginx/entrypoint.sh b/nginx/entrypoint.sh
index 173644e..d7ae7e8 100644
--- a/nginx/entrypoint.sh
+++ b/nginx/entrypoint.sh
@@ -13,4 +13,12 @@ set -eu
# $host/$uri/... must survive, which a plain copy trivially guarantees.
cp /etc/nginx/saezuri.conf.template /etc/nginx/conf.d/default.conf
+# The illustrations and calls directories live in /data now (see the Dockerfile),
+# and a mount there masks whatever the image shipped instead of seeding it — a
+# bind mount always, and that is how the Home Assistant Supervisor mounts /data.
+# So put the bundled fallback silhouette back. `-n` so art the refresh service has
+# already downloaded is never overwritten.
+mkdir -p /usr/share/nginx/html/assets/illustrations /usr/share/nginx/html/assets/calls
+cp -rn /opt/saezuri/bundled/illustrations/. /usr/share/nginx/html/assets/illustrations/
+
echo "saezuri: serving static bundle; BirdNET-Go is backend-only (no /api proxy)"
From 59affcc26193253e531876490426a56ec19ce6e5 Mon Sep 17 00:00:00 2001
From: Niels Bik
Date: Fri, 21 Aug 2026 20:14:50 +0200
Subject: [PATCH 2/8] refactor: share the location blocks between server blocks
---
nginx/default.conf.template | 59 +--------------------------
nginx/saezuri-locations.conf | 79 ++++++++++++++++++++++++++++++++++++
2 files changed, 80 insertions(+), 58 deletions(-)
create mode 100644 nginx/saezuri-locations.conf
diff --git a/nginx/default.conf.template b/nginx/default.conf.template
index 44f92b9..1c76b4a 100644
--- a/nginx/default.conf.template
+++ b/nginx/default.conf.template
@@ -12,62 +12,5 @@ server {
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
- # Hashed build assets + cutouts: cache hard.
- location /assets/ {
- expires 1y;
- add_header Cache-Control "public, immutable";
- try_files $uri =404;
- }
-
- # Mutable, service-published artifacts. `no-cache` = always revalidate
- # (cheap 304s via nginx's ETag/Last-Modified), so a regenerated file is never
- # served stale — the fix for viewers seeing new art minutes apart. A missing
- # file 404s cleanly (the app falls back to its built-in manifest / an empty
- # snapshot) rather than falling through to the SPA index.html.
- location = /layout-manifest.json {
- add_header Cache-Control "no-cache";
- try_files $uri =404;
- }
- location = /snapshot.json {
- add_header Cache-Control "no-cache";
- try_files $uri =404;
- }
- # Cached reference calls are described here; the audio itself lives under
- # /assets/calls/ and is covered by the immutable block above (its URLs carry
- # a ?v= content hash). Without this exact-match block a deployment that has
- # acquired nothing yet would fall through to the SPA index.html instead of
- # 404ing, and the browser would parse HTML as JSON.
- location = /calls-manifest.json {
- add_header Cache-Control "no-cache";
- try_files $uri =404;
- }
-
- # Published species-name dictionaries (index.json + one .json each). The
- # browser reads these to localize display names. `no-cache` = revalidate (cheap
- # 304s), so a republished dictionary is never served stale; a missing file 404s
- # cleanly (the app falls back to the station's own names).
- location /species-dict/ {
- add_header Cache-Control "no-cache";
- try_files $uri =404;
- }
-
- # Rendered e-ink frames (written by the refresh service). Regex so it beats
- # the SPA fallback below; a missing frame 404s instead of returning
- # index.html, and `/7d` (no .png) still routes to the app.
- location ~ ^/(1h|12h|24h|7d|all)\.png$ {
- add_header Cache-Control "no-cache";
- try_files $uri =404;
- }
-
- # NOTE: Saezuri does NOT proxy the BirdNET-Go API to the browser. All
- # BirdNET-Go access is backend-only (the Node refresh service holds the SSE
- # stream and publishes the static files above); the browser only ever reads
- # this origin's static files. Proxying /api/ here would expose the entire
- # BirdNET-Go API (writes included) to anyone who can reach Saezuri — a real
- # risk when Saezuri is on the internet but BirdNET-Go is not.
-
- # SPA fallback for client-side routes.
- location / {
- try_files $uri $uri/ /index.html;
- }
+ include /etc/nginx/saezuri-locations.conf;
}
diff --git a/nginx/saezuri-locations.conf b/nginx/saezuri-locations.conf
new file mode 100644
index 0000000..0cf06dd
--- /dev/null
+++ b/nginx/saezuri-locations.conf
@@ -0,0 +1,79 @@
+# Everything Saezuri serves, shared verbatim by every server block: the
+# standalone port-80 listener (nginx/default.conf.template) and the Home
+# Assistant ingress listener (addon/nginx/ingress.conf). Shared rather than
+# duplicated because the caching and 404-vs-SPA-fallback policy below is subtle,
+# and a block missing from one copy silently hands the browser index.html where
+# it expects JSON.
+
+# Hashed build assets + cutouts: cache hard.
+location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ try_files $uri =404;
+}
+
+# Mutable, service-published artifacts. `no-cache` = always revalidate
+# (cheap 304s via nginx's ETag/Last-Modified), so a regenerated file is never
+# served stale — the fix for viewers seeing new art minutes apart. A missing
+# file 404s cleanly (the app falls back to its built-in manifest / an empty
+# snapshot) rather than falling through to the SPA index.html.
+location = /layout-manifest.json {
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+}
+location = /snapshot.json {
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+}
+# Cached reference calls are described here; the audio itself lives under
+# /assets/calls/ and is covered by the immutable block above (its URLs carry
+# a ?v= content hash). Without this exact-match block a deployment that has
+# acquired nothing yet would fall through to the SPA index.html instead of
+# 404ing, and the browser would parse HTML as JSON.
+location = /calls-manifest.json {
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+}
+
+# Published species-name dictionaries (index.json + one .json each). The
+# browser reads these to localize display names. `no-cache` = revalidate (cheap
+# 304s), so a republished dictionary is never served stale; a missing file 404s
+# cleanly (the app falls back to the station's own names).
+location /species-dict/ {
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+}
+
+# Rendered e-ink frames (written by the refresh service). Regex so it beats
+# the SPA fallback below; a missing frame 404s instead of returning
+# index.html, and `/7d` (no .png) still routes to the app.
+location ~ ^/(1h|12h|24h|7d|all)\.png$ {
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+}
+
+# Drop the trailing slash on a window route. The bundle's asset URLs are
+# relative (Vite `base: './'`, so the app tolerates being served from an
+# unknown path prefix under Home Assistant ingress), and relative resolution
+# from `/24h/` would look for `/24h/assets/…`. $http_x_ingress_path is empty
+# unless we are behind ingress, where it carries the per-session prefix that
+# the redirect target has to keep.
+location ~ ^/(1h|12h|24h|7d|all)/$ {
+ # Relative Location: nginx would otherwise build an absolute URL from the
+ # port it listens on, which is wrong behind a published Docker port and
+ # wrong behind ingress. The browser resolves it against the current origin.
+ absolute_redirect off;
+ return 301 $http_x_ingress_path/$1;
+}
+
+# NOTE: Saezuri does NOT proxy the BirdNET-Go API to the browser. All
+# BirdNET-Go access is backend-only (the Node refresh service holds the SSE
+# stream and publishes the static files above); the browser only ever reads
+# this origin's static files. Proxying /api/ here would expose the entire
+# BirdNET-Go API (writes included) to anyone who can reach Saezuri — a real
+# risk when Saezuri is on the internet but BirdNET-Go is not.
+
+# SPA fallback for client-side routes.
+location / {
+ try_files $uri $uri/ /index.html;
+}
From a1f644dd07404d570bf8031a1711b33d932bda9a Mon Sep 17 00:00:00 2001
From: Niels Bik
Date: Fri, 21 Aug 2026 20:15:06 +0200
Subject: [PATCH 3/8] feat: tolerate a runtime base path
---
index.html | 2 +-
src/components/EmptyState.tsx | 3 +-
src/domain/asset.ts | 3 +-
src/domain/calls.ts | 3 +-
src/hooks/useCallManifest.ts | 3 +-
src/hooks/useDictionaryIndex.ts | 3 +-
src/hooks/useLayoutManifest.ts | 3 +-
src/hooks/useRecentSpecies.ts | 3 +-
src/hooks/useSpeciesDictionary.ts | 17 +++++++----
src/lib/basePath.test.ts | 47 +++++++++++++++++++++++++++++++
src/lib/basePath.ts | 34 ++++++++++++++++++++++
src/main.tsx | 3 +-
vite.config.ts | 6 ++++
13 files changed, 115 insertions(+), 15 deletions(-)
create mode 100644 src/lib/basePath.test.ts
create mode 100644 src/lib/basePath.ts
diff --git a/index.html b/index.html
index 51ccfc8..4a3b50d 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
-
+
Saezuri
';
+
+ # sub_filter cannot rewrite a compressed body, so no gzip here. And the
+ # injected token is per-session: a cached index.html would hand a later
+ # session a dead prefix, hence no-store on the document.
+ gzip off;
+ add_header Cache-Control "no-store" always;
+
+ include /etc/nginx/saezuri-locations.conf;
+}
diff --git a/addon/run.sh b/addon/run.sh
new file mode 100755
index 0000000..b2bf6bf
--- /dev/null
+++ b/addon/run.sh
@@ -0,0 +1,191 @@
+#!/bin/sh
+# Home Assistant add-on entrypoint. Translates the Supervisor's options.json into
+# the environment variables the application already understands, then hands off to
+# the image's own entrypoint. Nothing downstream of here knows it is running under
+# Home Assistant.
+set -eu
+
+OPTIONS=/data/options.json
+
+# Hostnames probed for a BirdNET-Go add-on when birdnetgo_url is left empty, in
+# the order they are tried. The Supervisor names an add-on -,
+# where the prefix is `local` for a locally built add-on and the first 8 hex chars
+# of sha1(repository-url) for one from a store. db21ed7f is
+# github.com/alexbelgium/hassio-addons, which is where the BirdNET-Go add-on
+# actually lives; a0d7b954 is the community add-ons store, should it land there
+# too. No `core-` entry: BirdNET-Go is not a built-in add-on. Users with an
+# unusual slug extend this through the birdnetgo_extra_hosts option rather than
+# needing a code change.
+BIRDNETGO_HOSTS="db21ed7f-birdnet-go local-birdnet-go a0d7b954-birdnet-go"
+BIRDNETGO_PROBE_PORT=8080
+# Short enough that a missing neighbour costs no noticeable startup time.
+BIRDNETGO_PROBE_TIMEOUT=2
+
+log() { echo "saezuri-addon: $*"; }
+
+# Absent or null means "not configured", and the application's own default must
+# apply — so those are never exported. An empty string is a different answer:
+# ILLUSTRATIONS_REPO and CALL_PROVIDERS read it as "off".
+opt_present() {
+ jq -e --arg k "$1" 'has($k) and .[$k] != null' "$OPTIONS" >/dev/null 2>&1
+}
+
+opt_value() {
+ jq -r --arg k "$1" '.[$k] | tostring' "$OPTIONS"
+}
+
+export_opt() {
+ if opt_present "$2"; then
+ export "$1=$(opt_value "$2")"
+ fi
+}
+
+# FRAME_SHADOW is read as "anything but 0", so a YAML bool has to become 1 or 0;
+# exporting the string "false" would silently enable the shadow.
+export_bool_opt() {
+ if opt_present "$2"; then
+ if [ "$(opt_value "$2")" = "true" ]; then
+ export "$1=1"
+ else
+ export "$1=0"
+ fi
+ fi
+}
+
+# --- Configuration -----------------------------------------------------------
+
+export_opt BIRDNETGO_URL birdnetgo_url
+export_opt BIRDNETGO_TOKEN birdnetgo_token
+export_opt ILLUSTRATIONS_REPO illustrations_repo
+export_opt ILLUSTRATIONS_REF illustrations_ref
+export_opt ILLUSTRATIONS_BASE_URL illustrations_base_url
+export_opt GEMINI_API_KEY gemini_api_key
+export_opt GENERATE_MAX_PER_CYCLE generate_max_per_cycle
+export_opt GENERATE_SLEEP generate_sleep
+export_opt CALL_PROVIDERS call_providers
+export_opt CALLS_MAX_PER_CYCLE calls_max_per_cycle
+export_opt FRAME_WIDTH frame_width
+export_opt FRAME_HEIGHT frame_height
+export_opt FRAME_BG frame_bg
+export_bool_opt FRAME_SHADOW frame_shadow
+export_opt FRAME_WINDOWS frame_windows
+export_opt SPECIES_DICT_LOCALES species_dict_locales
+export_opt PUBLISH_DEBOUNCE_MS publish_debounce_ms
+export_opt AGING_INTERVAL_MS aging_interval_ms
+export_opt SUMMARY_INTERVAL_MS summary_interval_ms
+
+# Not options: the standalone defaults are wrong here. /data is the Supervisor's
+# persistent volume, so the cache belongs there rather than in the container layer
+# that an add-on update throws away. The html root is where nginx serves from.
+export FRAME_HTML_DIR=/usr/share/nginx/html
+export CACHE_DIR=/data/cache
+
+# The image already points the html root's asset directories at /data, so mounting
+# the persistent volume is the whole of it: no symlink surgery, no moving files.
+mkdir -p /data/illustrations /data/calls /data/cache
+
+# --- BirdNET-Go detection ----------------------------------------------------
+
+# Confirms a candidate is really BirdNET-Go rather than just something with an
+# open socket. /api/v2/app/config stays public even when BirdNET-Go runs in
+# PrivateMode, and reports whether a token will be needed; /api/v2/health is the
+# fallback for builds predating it, where a 401 is itself proof of an instance.
+# Echoes "ok" or "auth" on a hit, nothing at all otherwise.
+probe_birdnetgo() {
+ _base="http://$1:${BIRDNETGO_PROBE_PORT}"
+ _body=$(mktemp)
+
+ _code=$(curl -s -o "$_body" -w '%{http_code}' \
+ --max-time "$BIRDNETGO_PROBE_TIMEOUT" \
+ -H 'Accept: application/json' \
+ "$_base/api/v2/app/config" 2>/dev/null || echo 000)
+ if [ "$_code" = "200" ] && \
+ jq -e 'has("csrfToken") and has("projectLinks")' "$_body" >/dev/null 2>&1; then
+ if jq -e '.security.privateMode == true' "$_body" >/dev/null 2>&1; then
+ echo auth
+ else
+ echo ok
+ fi
+ rm -f "$_body"
+ return 0
+ fi
+
+ _code=$(curl -s -o "$_body" -w '%{http_code}' \
+ --max-time "$BIRDNETGO_PROBE_TIMEOUT" \
+ -H 'Accept: application/json' \
+ "$_base/api/v2/health" 2>/dev/null || echo 000)
+ if [ "$_code" = "200" ] && \
+ jq -e 'has("status") and has("database_status")' "$_body" >/dev/null 2>&1; then
+ echo ok
+ elif [ "$_code" = "401" ]; then
+ echo auth
+ fi
+ rm -f "$_body"
+}
+
+detect_birdnetgo() {
+ _candidates="$BIRDNETGO_HOSTS"
+ if opt_present birdnetgo_extra_hosts; then
+ # Tried first, so a hand-configured hostname beats the built-in guesses.
+ _candidates="$(opt_value birdnetgo_extra_hosts | tr ',' ' ') $_candidates"
+ fi
+
+ _chosen=""
+ for _host in $_candidates; do
+ [ -n "$_host" ] || continue
+ _result=$(probe_birdnetgo "$_host")
+ [ -n "$_result" ] || continue
+
+ if [ -z "$_chosen" ]; then
+ _chosen="$_host"
+ log "detected BirdNET-Go at $_host:${BIRDNETGO_PROBE_PORT}"
+ if [ "$_result" = "auth" ]; then
+ log "that instance requires authentication; set the birdnetgo_token option"
+ fi
+ else
+ # Logged rather than silently discarded, so a user with two instances
+ # can see which one was picked and override it.
+ log "also responding: $_host:${BIRDNETGO_PROBE_PORT} (not used)"
+ fi
+ done
+
+ [ -n "$_chosen" ] || return 1
+ export "BIRDNETGO_URL=http://$_chosen:${BIRDNETGO_PROBE_PORT}"
+}
+
+# An explicitly configured URL always wins and is never second-guessed.
+if [ -z "${BIRDNETGO_URL:-}" ]; then
+ log "no birdnetgo_url configured; looking for a BirdNET-Go add-on"
+ if ! detect_birdnetgo; then
+ log "no BirdNET-Go add-on found on the Supervisor network."
+ log "Set the birdnetgo_url option to your instance, for example"
+ log " http://192.168.1.10:8080"
+ log "If it is an add-on with an unusual slug, add its hostname to"
+ log "birdnetgo_extra_hosts instead."
+ exit 1
+ fi
+fi
+
+# --- Startup -----------------------------------------------------------------
+
+redacted() { [ -n "${1:-}" ] && echo '' || echo ''; }
+
+log "BIRDNETGO_URL=${BIRDNETGO_URL}"
+log "BIRDNETGO_TOKEN=$(redacted "${BIRDNETGO_TOKEN:-}")"
+log "GEMINI_API_KEY=$(redacted "${GEMINI_API_KEY:-}")"
+for _name in ILLUSTRATIONS_REPO ILLUSTRATIONS_REF ILLUSTRATIONS_BASE_URL \
+ GENERATE_MAX_PER_CYCLE GENERATE_SLEEP CALL_PROVIDERS CALLS_MAX_PER_CYCLE \
+ FRAME_WIDTH FRAME_HEIGHT FRAME_BG FRAME_SHADOW FRAME_WINDOWS \
+ SPECIES_DICT_LOCALES PUBLISH_DEBOUNCE_MS AGING_INTERVAL_MS \
+ SUMMARY_INTERVAL_MS FRAME_HTML_DIR CACHE_DIR; do
+ eval "_set=\${$_name+set}"
+ [ "${_set:-}" = set ] || continue
+ eval "_value=\$$_name"
+ log "$_name=$_value"
+done
+
+cp /opt/saezuri/addon/ingress.conf /etc/nginx/conf.d/ingress.conf
+
+# The image's own entrypoint runs its /docker-entrypoint.d hooks (install the
+# port-80 config, launch the refresh service) with the environment now populated.
+exec /docker-entrypoint.sh nginx -g "daemon off;"
diff --git a/addon/translations/en.yaml b/addon/translations/en.yaml
new file mode 100644
index 0000000..41ba7fe
--- /dev/null
+++ b/addon/translations/en.yaml
@@ -0,0 +1,84 @@
+configuration:
+ birdnetgo_url:
+ name: BirdNET-Go URL
+ description: >-
+ Base URL of your BirdNET-Go instance, for example http://192.168.1.10:8080.
+ Leave empty to detect a BirdNET-Go add-on running on this machine.
+ birdnetgo_token:
+ name: BirdNET-Go token
+ description: >-
+ Bearer token, only needed when BirdNET-Go runs in PrivateMode.
+ birdnetgo_extra_hosts:
+ name: Extra hostnames to probe
+ description: >-
+ Comma-separated hostnames to try before the built-in guesses when
+ detecting BirdNET-Go. Only needed for an add-on with an unusual slug.
+ illustrations_repo:
+ name: Illustrations repository
+ description: >-
+ GitHub repository the ready-made cutouts are downloaded from. Empty
+ disables downloading.
+ illustrations_ref:
+ name: Illustrations branch
+ description: Branch or tag to download illustrations from.
+ illustrations_base_url:
+ name: Illustrations base URL
+ description: >-
+ Overrides the repository and branch above with a URL to download cutouts
+ from directly.
+ gemini_api_key:
+ name: Gemini API key
+ description: >-
+ Optional. Set it to also generate art for species nobody has contributed
+ an illustration for yet.
+ generate_max_per_cycle:
+ name: Generated illustrations per cycle
+ description: How many illustrations to generate at a time.
+ generate_sleep:
+ name: Pause between generations
+ description: Seconds to wait between generated illustrations.
+ call_providers:
+ name: Recording archives
+ description: >-
+ Comma-separated archives to look up reference recordings in. Empty
+ disables reference recordings.
+ calls_max_per_cycle:
+ name: Recordings per cycle
+ description: How many reference recordings to look up at a time.
+ frame_width:
+ name: E-ink frame width
+ description: Width in pixels of the rendered e-ink frames.
+ frame_height:
+ name: E-ink frame height
+ description: Height in pixels of the rendered e-ink frames.
+ frame_bg:
+ name: E-ink frame background
+ description: Background colour of the rendered frames, as a hex colour.
+ frame_shadow:
+ name: E-ink frame shadows
+ description: Draw soft shadows under the birds in the rendered frames.
+ frame_windows:
+ name: E-ink frames to render
+ description: >-
+ Comma-separated time windows to render frames for, from 1h, 12h, 24h, 7d
+ and all.
+ species_dict_locales:
+ name: Display languages
+ description: >-
+ Comma-separated languages to publish species-name dictionaries for. The
+ browser picks the closest match to its own language.
+ publish_debounce_ms:
+ name: Publish debounce
+ description: >-
+ Milliseconds to wait after a detection before republishing, so a burst
+ becomes one update.
+ aging_interval_ms:
+ name: Ageing interval
+ description: >-
+ Milliseconds between recomputes that drop detections out of their time
+ window.
+ summary_interval_ms:
+ name: Summary interval
+ description: Milliseconds between full recounts from BirdNET-Go.
+network:
+ "80/tcp": Direct web access, only needed for an e-ink panel fetching /24h.png
diff --git a/repository.yaml b/repository.yaml
new file mode 100644
index 0000000..24d6562
--- /dev/null
+++ b/repository.yaml
@@ -0,0 +1,3 @@
+name: Saezuri
+url: https://github.com/vrwrts/saezuri
+maintainer: Niels Bik
From a605876809aaace1001e9d321bf1f10d76486884 Mon Sep 17 00:00:00 2001
From: Niels Bik
Date: Fri, 21 Aug 2026 20:15:41 +0200
Subject: [PATCH 5/8] ci: build and publish the add-on image
---
.github/workflows/release.yml | 49 +++++++++++++++++++++++++++++++++++
.releaserc.json | 19 ++++++++++++++
2 files changed, 68 insertions(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 419ed8c..a1f98fd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -40,6 +40,17 @@ jobs:
fetch-depth: 0 # semantic-release needs full history + tags
- uses: cycjimmy/semantic-release-action@v4
id: semantic
+ with:
+ # The add-on's pinned version has to be bumped in the release commit:
+ # the add-on store reads addon/config.yaml from main, and a version with
+ # no matching published image tag makes the add-on uninstallable.
+ # changelog writes addon/CHANGELOG.md, exec rewrites the two pinned
+ # versions, git commits them (with [skip ci], so the push back to main
+ # does not re-trigger this workflow).
+ extra_plugins: |
+ @semantic-release/changelog
+ @semantic-release/exec
+ @semantic-release/git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -79,3 +90,41 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+
+ publish-addon-image:
+ # The Home Assistant add-on is a thin wrapper around the image above, so it
+ # can only be built once that image exists at this exact version.
+ needs: [release, publish-image]
+ if: needs.release.outputs.published == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ # The triggering SHA, i.e. before semantic-release's version-bump commit.
+ # Fine: the wrapper's version comes from the job output below, and neither
+ # config.yaml nor build.yaml is baked into the image.
+ - uses: actions/checkout@v4
+ - uses: docker/setup-qemu-action@v3
+ - uses: docker/setup-buildx-action@v3
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: addon
+ platforms: linux/amd64,linux/arm64
+ push: true
+ # Pinned to the release just published, never `latest`: an add-on
+ # updated by the Supervisor must get the application code it claims to.
+ build-args: |
+ BUILD_FROM=ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}
+ tags: |
+ ghcr.io/${{ github.repository }}-addon:${{ needs.release.outputs.version }}
+ ghcr.io/${{ github.repository }}-addon:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.releaserc.json b/.releaserc.json
index fea7fae..9d4cce1 100644
--- a/.releaserc.json
+++ b/.releaserc.json
@@ -4,6 +4,25 @@
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
+ [
+ "@semantic-release/changelog",
+ {
+ "changelogFile": "addon/CHANGELOG.md"
+ }
+ ],
+ [
+ "@semantic-release/exec",
+ {
+ "prepareCmd": "./addon/bump-version.sh ${nextRelease.version}"
+ }
+ ],
+ [
+ "@semantic-release/git",
+ {
+ "assets": ["addon/config.yaml", "addon/build.yaml", "addon/CHANGELOG.md"],
+ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
+ }
+ ],
"@semantic-release/github"
]
}
From cefdde45668012dd08861959070c80de722ebbbd Mon Sep 17 00:00:00 2001
From: Niels Bik
Date: Fri, 21 Aug 2026 20:41:24 +0200
Subject: [PATCH 6/8] feat: lead the install section with the Home Assistant
app
---
site/src/components/Hero.astro | 4 +++
site/src/components/Install.astro | 46 ++++++++++++++++++++++++++---
site/src/styles/site.css | 49 +++++++++++++++++++++++++++++++
3 files changed, 95 insertions(+), 4 deletions(-)
diff --git a/site/src/components/Hero.astro b/site/src/components/Hero.astro
index e899022..53fdf2e 100644
--- a/site/src/components/Hero.astro
+++ b/site/src/components/Hero.astro
@@ -3,6 +3,10 @@ const repo = 'https://github.com/vrwrts/saezuri'
---
+
+
+ New · Home Assistant app
+
- You’ll need a running
+ Either way you’ll need a running
BirdNET-Go instance on your network.
- Point Saezuri at it with a single environment variable.
+
+
Home Assistant
+
+ Saezuri is available as a Home Assistant App (previously Add-on). Add this
+ repository to your app store, install Saezuri, and it appears in the sidebar.
+
+ Saezuri as a Home Assistant App is best paired with the community-built
+ BirdNET-Go Home Assistant app.
+ Saezuri looks for a BirdNET-Go App on startup and will work with it without
+ any configuration needed from you. Alternatively, set BirdNET-Go URL
+ to your instance and leave everything else alone.
+
+
+ Every setting outlined below is available as an option, under the same name in lower case. The
+ full list, including what to do when detection picks the wrong instance, is in the
+ app docs.
+
+
+
+
+
Docker
+
Point Saezuri at BirdNET-Go with a single environment variable.
+
Run the container (use your instance’s address):
@@ -62,10 +96,14 @@ volumes:
+
+
All settings
- Everything else has a working default. Set only what you want to change.
+ Everything else has a working default. Set only what you want to change. Under Home
+ Assistant these are options with the same names in lower case, for example
+ frame_width.
Illustrations
diff --git a/site/src/styles/site.css b/site/src/styles/site.css
index 9bcfce0..3748e38 100644
--- a/site/src/styles/site.css
+++ b/site/src/styles/site.css
@@ -76,6 +76,34 @@ a {
(Using the `padding` shorthand here would reset .wrap's side padding to 0.) */
padding-block: 92px 8px;
}
+/* "New" badge above the wordmark. --live is the same muted vermilion the app uses
+ for its live dot, so the two read as the same signal. */
+.pill-new {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 22px;
+ padding: 6px 14px 6px 12px;
+ border-radius: 999px;
+ background: var(--paper-2);
+ box-shadow: var(--raised);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-2);
+ text-decoration: none;
+}
+.pill-new:hover {
+ color: var(--ink);
+}
+.pill-new .dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--live);
+}
+
.hero h1 {
/* fit-content + auto margins: centered, but only as wide as the wordmark,
so the hover/focus reveal is triggered by the text itself, not the whole row. */
@@ -196,6 +224,22 @@ a {
color: var(--ink);
}
+/* ---- Install paths (Home Assistant / Docker) ---- */
+.path {
+ margin-top: 34px;
+}
+.path h3 {
+ font-size: 20px;
+ margin: 0 0 10px;
+}
+.path p {
+ margin: 0 0 14px;
+ color: var(--ink-2);
+}
+.path-cta {
+ margin-bottom: 18px;
+}
+
/* ---- Hero product shot (theme-swapped) ---- */
.shot {
max-width: 720px;
@@ -291,6 +335,11 @@ a {
color: var(--ink);
white-space: pre;
}
+/* A one-line block has nothing to push the copy button clear of, so reserve the
+ room the multi-line blocks get for free from their longer first line. */
+.code-inline {
+ padding-right: 78px;
+}
.copy-btn {
position: absolute;
top: 10px;
From 7ac37b9c56da65518345be64d8f2663170e1d7a7 Mon Sep 17 00:00:00 2001
From: Niels Bik
Date: Fri, 21 Aug 2026 20:41:40 +0200
Subject: [PATCH 7/8] docs: call it a Home Assistant app, not an add-on
---
.github/workflows/release.yml | 10 +++++-----
Dockerfile | 4 ++--
README.md | 11 ++++++-----
addon/CHANGELOG.md | 6 +++---
addon/DOCS.md | 28 ++++++++++++++--------------
addon/build.yaml | 2 +-
addon/bump-version.sh | 6 +++---
addon/config.yaml | 4 ++--
addon/nginx/ingress.conf | 2 +-
addon/run.sh | 26 +++++++++++++-------------
addon/translations/en.yaml | 4 ++--
11 files changed, 52 insertions(+), 51 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a1f98fd..8e3a58a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -41,9 +41,9 @@ jobs:
- uses: cycjimmy/semantic-release-action@v4
id: semantic
with:
- # The add-on's pinned version has to be bumped in the release commit:
- # the add-on store reads addon/config.yaml from main, and a version with
- # no matching published image tag makes the add-on uninstallable.
+ # The app's pinned version has to be bumped in the release commit:
+ # the app store reads addon/config.yaml from main, and a version with
+ # no matching published image tag makes the app uninstallable.
# changelog writes addon/CHANGELOG.md, exec rewrites the two pinned
# versions, git commits them (with [skip ci], so the push back to main
# does not re-trigger this workflow).
@@ -92,7 +92,7 @@ jobs:
cache-to: type=gha,mode=max
publish-addon-image:
- # The Home Assistant add-on is a thin wrapper around the image above, so it
+ # The Home Assistant app is a thin wrapper around the image above, so it
# can only be built once that image exists at this exact version.
needs: [release, publish-image]
if: needs.release.outputs.published == 'true'
@@ -119,7 +119,7 @@ jobs:
context: addon
platforms: linux/amd64,linux/arm64
push: true
- # Pinned to the release just published, never `latest`: an add-on
+ # Pinned to the release just published, never `latest`: an app
# updated by the Supervisor must get the application code it claims to.
build-args: |
BUILD_FROM=ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}
diff --git a/Dockerfile b/Dockerfile
index 68560a3..616c17c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -90,8 +90,8 @@ RUN mkdir -p /opt/saezuri/bundled \
# image's built-in envsubst step doesn't clobber nginx's own $variables — our
# hook just copies it verbatim now that nothing is substituted. The locations
# file is included from a server block, so it ships unconditionally: the Home
-# Assistant add-on wrapper adds a second server block that includes the same
-# file. 40 installs the static-serving config; 50 launches the refresh service
+# Assistant app wrapper (addon/) adds a second server block that includes the
+# same file. 40 installs the static-serving config; 50 launches the refresh service
# (fetches per-species art + dictionaries, publishes the snapshot). Both run
# before nginx, in that order.
COPY nginx/default.conf.template /etc/nginx/saezuri.conf.template
diff --git a/README.md b/README.md
index 4e79d1b..d529011 100644
--- a/README.md
+++ b/README.md
@@ -282,11 +282,12 @@ docker compose up --build
## Home Assistant
-Saezuri also ships as a Home Assistant add-on: add
-`https://github.com/vrwrts/saezuri` as an add-on repository and it installs from the
-store, appears in the sidebar through ingress, and finds a BirdNET-Go add-on on the
-same machine by itself. Everything add-on specific lives in [`addon/`](addon/); the
-page users read inside Home Assistant is [`addon/DOCS.md`](addon/DOCS.md).
+Saezuri also ships as a Home Assistant app, what Home Assistant called an add-on until
+recently: add `https://github.com/vrwrts/saezuri` as a repository and it installs from
+the app store, appears in the sidebar through ingress, and finds a BirdNET-Go app on the
+same machine by itself. Everything Home Assistant specific lives in [`addon/`](addon/),
+named for the Supervisor's own `/addons` layout; the page users read inside Home Assistant
+is [`addon/DOCS.md`](addon/DOCS.md).
## Landing page
diff --git a/addon/CHANGELOG.md b/addon/CHANGELOG.md
index 59c76d3..1195f58 100644
--- a/addon/CHANGELOG.md
+++ b/addon/CHANGELOG.md
@@ -2,12 +2,12 @@
## 0.13.0
-First release of the Home Assistant add-on. Wraps the existing Saezuri image with
+First release of the Home Assistant app. Wraps the existing Saezuri image with
ingress, so the collage appears in the sidebar, and with the Supervisor's
configuration and persistence.
-- BirdNET-Go is detected automatically when it runs as an add-on on the same
+- BirdNET-Go is detected automatically when it runs as an app on the same
machine, so **BirdNET-Go URL** can usually be left empty.
- Illustrations and cached reference recordings persist in `/data`, so they
- survive an add-on update.
+ survive an app update.
- Port 80 is available but off by default, for an e-ink panel fetching `/24h.png`.
diff --git a/addon/DOCS.md b/addon/DOCS.md
index 2073b3a..de609c6 100644
--- a/addon/DOCS.md
+++ b/addon/DOCS.md
@@ -10,39 +10,39 @@ talks to Saezuri itself.
## Installation
-1. In Home Assistant, go to **Settings** → **Add-ons** → **Add-on store**, open the
+1. In Home Assistant, go to **Settings** → **Apps** → **App store**, open the
three-dot menu and choose **Repositories**.
2. Add `https://github.com/vrwrts/saezuri`.
3. Find **Saezuri** in the store and click **Install**.
4. Start it. It appears in the sidebar as **Saezuri**.
-If BirdNET-Go runs as an add-on on the same machine you can start Saezuri without
+If BirdNET-Go runs as an app on the same machine you can start Saezuri without
configuring anything. Otherwise set **BirdNET-Go URL** first.
## Finding BirdNET-Go
-Leave **BirdNET-Go URL** empty and the add-on looks for a BirdNET-Go add-on on the
+Leave **BirdNET-Go URL** empty and Saezuri looks for a BirdNET-Go app on the
Supervisor network at startup. It tries these hostnames on port 8080, in order, and
confirms each hit really is BirdNET-Go before using it:
-| Hostname | Where that add-on came from |
+| Hostname | Where that app came from |
| --- | --- |
-| `db21ed7f-birdnet-go` | the [alexbelgium add-ons](https://github.com/alexbelgium/hassio-addons) store |
+| `db21ed7f-birdnet-go` | the [alexbelgium add-ons](https://github.com/alexbelgium/hassio-addons) repository |
| `local-birdnet-go` | a copy you built yourself under `/addons` |
-| `a0d7b954-birdnet-go` | the [community add-ons](https://github.com/hassio-addons/repository) store |
+| `a0d7b954-birdnet-go` | the [Home Assistant Community Add-ons](https://github.com/hassio-addons/repository) repository |
-The add-on log says which one it picked. If several respond, the first in that order
+The app log says which one it picked. If several respond, the first in that order
wins and the others are logged so you can see what was skipped.
-**If nothing is found**, the add-on stops with a message saying so. Set
+**If nothing is found**, the app stops with a message saying so. Set
**BirdNET-Go URL** to your instance, for example `http://192.168.1.10:8080`. That
-also covers a BirdNET-Go that is not an add-on at all, running in Docker or on
+also covers a BirdNET-Go that is not an app at all, running in Docker or on
another machine.
**If it picks the wrong instance**, set **BirdNET-Go URL** explicitly. A configured
URL always wins and is never second-guessed.
-**If your add-on has an unusual slug**, put its hostname in **Extra hostnames to
+**If your BirdNET-Go app has an unusual slug**, put its hostname in **Extra hostnames to
probe** rather than waiting on a code change. Entries there are tried first.
**If the log says authentication is required**, your BirdNET-Go runs in PrivateMode.
@@ -126,23 +126,23 @@ An e-ink panel fetches a rendered frame such as `/24h.png` directly. Ingress can
serve it, because ingress requires Home Assistant authentication and a panel has no
way to log in. So open the direct port instead:
-1. Open the add-on's **Configuration** tab and switch to **Network**.
+1. Open the app's **Configuration** tab and switch to **Network**.
2. Give **80/tcp** a host port, for example `8090`.
-3. Restart the add-on.
+3. Restart the app.
Your panel then fetches `http://:8090/24h.png`. That port serves
the whole collage with no authentication, so only open it on a network you trust.
## Storage
-Downloaded illustrations, cached recordings and the working cache live in the add-on's
+Downloaded illustrations, cached recordings and the working cache live in the app's
`/data`, which the Supervisor keeps across restarts and updates. They are included in
a Home Assistant backup, so a large illustration set makes for larger backups.
## Licensing
The illustrations and the tooling that makes them inherit **CC-BY-NC-SA-4.0** from the
-BirdNET-Pi lineage, so this add-on and the art it downloads are **for non-commercial
+BirdNET-Pi lineage, so this app and the art it downloads are **for non-commercial
use only**. Personal use in your own home is fine. Publishing the images, or a
repository derived from them, carries obligations worth reading first: see
[Credits and licensing](https://github.com/vrwrts/saezuri#credits-and-licensing).
diff --git a/addon/build.yaml b/addon/build.yaml
index bb9e317..cbb6878 100644
--- a/addon/build.yaml
+++ b/addon/build.yaml
@@ -1,4 +1,4 @@
-# Only used when the Supervisor builds the add-on locally (a copy under /addons);
+# Only used when the Supervisor builds the app locally (a copy under /addons);
# an install from the repository pulls `image:` from config.yaml instead. Both
# entries name the same tag on purpose: the application image is a multi-arch
# manifest, so the platform resolves per architecture. `aarch64` is Home
diff --git a/addon/bump-version.sh b/addon/bump-version.sh
index 076ecf7..4bda500 100755
--- a/addon/bump-version.sh
+++ b/addon/bump-version.sh
@@ -1,8 +1,8 @@
#!/bin/sh
-# Rewrites the add-on's pinned version in place. Run by semantic-release (see
-# .releaserc.json) so the bump lands in the release commit: the add-on store reads
+# Rewrites the app's pinned version in place. Run by semantic-release (see
+# .releaserc.json) so the bump lands in the release commit: the app store reads
# config.yaml from the default branch, and a version with no matching published
-# image tag makes the add-on uninstallable, so this can never be a manual step.
+# image tag makes the app uninstallable, so this can never be a manual step.
set -eu
VERSION="${1:?usage: bump-version.sh X.Y.Z}"
diff --git a/addon/config.yaml b/addon/config.yaml
index 747ee72..04ce19c 100644
--- a/addon/config.yaml
+++ b/addon/config.yaml
@@ -31,8 +31,8 @@ options:
aging_interval_ms: 120000
summary_interval_ms: 1800000
schema:
- # Optional: left empty, the add-on probes the Supervisor network for a
- # BirdNET-Go add-on at startup. See DOCS.md.
+ # Optional: left empty, Saezuri probes the Supervisor network for a
+ # BirdNET-Go app at startup. See DOCS.md.
birdnetgo_url: url?
birdnetgo_token: password?
birdnetgo_extra_hosts: str?
diff --git a/addon/nginx/ingress.conf b/addon/nginx/ingress.conf
index 2566096..5e89725 100644
--- a/addon/nginx/ingress.conf
+++ b/addon/nginx/ingress.conf
@@ -10,7 +10,7 @@ server {
index index.html;
# Ingress is the Supervisor's own reverse proxy and the only thing that may
- # reach this port. The add-on has no authentication of its own by design.
+ # reach this port. The app has no authentication of its own by design.
allow 172.30.32.0/23;
deny all;
diff --git a/addon/run.sh b/addon/run.sh
index b2bf6bf..c1bd535 100755
--- a/addon/run.sh
+++ b/addon/run.sh
@@ -1,5 +1,5 @@
#!/bin/sh
-# Home Assistant add-on entrypoint. Translates the Supervisor's options.json into
+# Home Assistant app entrypoint. Translates the Supervisor's options.json into
# the environment variables the application already understands, then hands off to
# the image's own entrypoint. Nothing downstream of here knows it is running under
# Home Assistant.
@@ -7,15 +7,15 @@ set -eu
OPTIONS=/data/options.json
-# Hostnames probed for a BirdNET-Go add-on when birdnetgo_url is left empty, in
-# the order they are tried. The Supervisor names an add-on -,
-# where the prefix is `local` for a locally built add-on and the first 8 hex chars
+# Hostnames probed for a BirdNET-Go app when birdnetgo_url is left empty, in
+# the order they are tried. The Supervisor names an app -,
+# where the prefix is `local` for a locally built app and the first 8 hex chars
# of sha1(repository-url) for one from a store. db21ed7f is
-# github.com/alexbelgium/hassio-addons, which is where the BirdNET-Go add-on
-# actually lives; a0d7b954 is the community add-ons store, should it land there
-# too. No `core-` entry: BirdNET-Go is not a built-in add-on. Users with an
-# unusual slug extend this through the birdnetgo_extra_hosts option rather than
-# needing a code change.
+# github.com/alexbelgium/hassio-addons, which is where the BirdNET-Go app
+# actually lives; a0d7b954 is the Home Assistant Community Add-ons repository,
+# should it land there too. No `core-` entry: BirdNET-Go is not a built-in app.
+# Users with an unusual slug extend this through the birdnetgo_extra_hosts option
+# rather than needing a code change.
BIRDNETGO_HOSTS="db21ed7f-birdnet-go local-birdnet-go a0d7b954-birdnet-go"
BIRDNETGO_PROBE_PORT=8080
# Short enough that a missing neighbour costs no noticeable startup time.
@@ -76,7 +76,7 @@ export_opt SUMMARY_INTERVAL_MS summary_interval_ms
# Not options: the standalone defaults are wrong here. /data is the Supervisor's
# persistent volume, so the cache belongs there rather than in the container layer
-# that an add-on update throws away. The html root is where nginx serves from.
+# that an app update throws away. The html root is where nginx serves from.
export FRAME_HTML_DIR=/usr/share/nginx/html
export CACHE_DIR=/data/cache
@@ -155,12 +155,12 @@ detect_birdnetgo() {
# An explicitly configured URL always wins and is never second-guessed.
if [ -z "${BIRDNETGO_URL:-}" ]; then
- log "no birdnetgo_url configured; looking for a BirdNET-Go add-on"
+ log "no birdnetgo_url configured; looking for a BirdNET-Go app"
if ! detect_birdnetgo; then
- log "no BirdNET-Go add-on found on the Supervisor network."
+ log "no BirdNET-Go app found on the Supervisor network."
log "Set the birdnetgo_url option to your instance, for example"
log " http://192.168.1.10:8080"
- log "If it is an add-on with an unusual slug, add its hostname to"
+ log "If your BirdNET-Go app has an unusual slug, add its hostname to"
log "birdnetgo_extra_hosts instead."
exit 1
fi
diff --git a/addon/translations/en.yaml b/addon/translations/en.yaml
index 41ba7fe..3658a75 100644
--- a/addon/translations/en.yaml
+++ b/addon/translations/en.yaml
@@ -3,7 +3,7 @@ configuration:
name: BirdNET-Go URL
description: >-
Base URL of your BirdNET-Go instance, for example http://192.168.1.10:8080.
- Leave empty to detect a BirdNET-Go add-on running on this machine.
+ Leave empty to detect a BirdNET-Go app running on this machine.
birdnetgo_token:
name: BirdNET-Go token
description: >-
@@ -12,7 +12,7 @@ configuration:
name: Extra hostnames to probe
description: >-
Comma-separated hostnames to try before the built-in guesses when
- detecting BirdNET-Go. Only needed for an add-on with an unusual slug.
+ detecting BirdNET-Go. Only needed for a BirdNET-Go app with an unusual slug.
illustrations_repo:
name: Illustrations repository
description: >-
From de8fe8149fdb0167e97e65f8424e0a1505a95e7e Mon Sep 17 00:00:00 2001
From: Niels Bik
Date: Fri, 21 Aug 2026 20:50:47 +0200
Subject: [PATCH 8/8] fix: put the repository URL and the Home Assistant button
on one row
---
site/src/components/Install.astro | 14 ++++++------
site/src/styles/site.css | 38 ++++++++++++++++++++++++++++---
2 files changed, 42 insertions(+), 10 deletions(-)
diff --git a/site/src/components/Install.astro b/site/src/components/Install.astro
index 1b8a7de..8b5e98f 100644
--- a/site/src/components/Install.astro
+++ b/site/src/components/Install.astro
@@ -12,17 +12,17 @@
Saezuri is available as a Home Assistant App (previously Add-on). Add this
repository to your app store, install Saezuri, and it appears in the sidebar.
Saezuri as a Home Assistant App is best paired with the community-built
BirdNET-Go Home Assistant app.
diff --git a/site/src/styles/site.css b/site/src/styles/site.css
index 3748e38..aff1d80 100644
--- a/site/src/styles/site.css
+++ b/site/src/styles/site.css
@@ -236,8 +236,28 @@ a {
margin: 0 0 14px;
color: var(--ink-2);
}
-.path-cta {
- margin-bottom: 18px;
+/* One action, two ways to take it: the one-click link for anyone browsing from
+ the same network as their instance, and the URL to paste for everyone else.
+ Side by side so they read as alternatives, not as two separate steps. */
+.repo-row {
+ display: flex;
+ align-items: stretch;
+ gap: 14px;
+ flex-wrap: wrap;
+ margin: 10px 0 26px;
+}
+.repo-row .btn {
+ display: flex;
+ align-items: center;
+ flex: none;
+}
+/* Wide enough a basis that the row breaks onto two lines before the URL field
+ gets too narrow to show the whole URL beside its copy button. */
+.repo-row .code {
+ flex: 1 1 366px;
+ margin: 0;
+ display: flex;
+ align-items: center;
}
/* ---- Hero product shot (theme-swapped) ---- */
@@ -336,10 +356,22 @@ a {
white-space: pre;
}
/* A one-line block has nothing to push the copy button clear of, so reserve the
- room the multi-line blocks get for free from their longer first line. */
+ room the multi-line blocks get for free from their longer first line. The
+ button also centres here rather than sitting at the top, since there is only
+ the one line for it to sit beside. */
.code-inline {
padding-right: 78px;
}
+.code-inline .copy-btn {
+ top: 50%;
+ transform: translateY(-50%);
+}
+/* On a screen too narrow for either, the URL wraps rather than scrolling out of
+ sight: a hidden tail in a field you are meant to copy is worse than two lines. */
+.code-inline pre {
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
.copy-btn {
position: absolute;
top: 10px;