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 +

Saezuri diff --git a/site/src/components/Install.astro b/site/src/components/Install.astro index 7e1442a..1b8a7de 100644 --- a/site/src/components/Install.astro +++ b/site/src/components/Install.astro @@ -1,12 +1,46 @@

Install

-

Up in one command

+

Two ways to run it

- 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. +

+
+ +
https://github.com/vrwrts/saezuri
+
+

+ Add it to Home Assistant +

+

+ 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.

-
- -
https://github.com/vrwrts/saezuri
-
-

+

Add it to Home Assistant -

+
+ +
https://github.com/vrwrts/saezuri
+
+

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;