The Rézoverlay infrastructure
  • Python 95.2%
  • Shell 4.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-04 08:47:21 +01:00
deploy Add karalyr lyrics 2026-11-04 08:47:21 +01:00
player Optimized animations for better performance 2026-10-28 21:33:29 +01:00
server Add karalyr lyrics 2026-11-04 08:47:21 +01:00
.gitignore Add auto-update + HTTPS system 2026-10-27 02:52:50 +01:00
README.md Add karalyr lyrics 2026-11-04 08:47:21 +01:00

Rezoverlay

Multi-room audio for Spotify Connect, AirPlay and Jellyfin. A Debian server receives the music and streams it in sync to Raspberry Pi 3Bs using Snapcast. Each Pi shows a fullscreen "now playing" screen on its HDMI display. No browser and no X11 are involved: the screen is drawn with pygame straight to KMS/DRM.

 Phone ──Spotify Connect──► go-librespot ──PCM──► spotify.fifo ─┐
 Phone ──AirPlay─────────► shairport-sync ─PCM──► airplay.fifo ─┼─► snapserver :1704 ─────► snapclient   (each Pi)
 App ──cast─► Jellyfin ◄── jellyfin-mpv-shim ─PCM─► jellyfin.fifo┘    meta stream "Rezoverlay"
                 │                │                                      ▲ JSON-RPC :1705 (local)
   GET /Sessions │  metadata ws :3678, metadata pipe                     │
                 ▼                ▼                                      │
                         rezoverlay-server 127.0.0.1:8000 ◄──────────────┘
                         • device registry (SQLite)   • REST API /api/…
                         • now playing + cover art    • player release for updates
                                  ▲
                         HTTPS reverse proxy :443 ◄──wss /ws/device, https /api──► rezoverlay-player (each Pi)
  • The same music in every room. snapserver's meta stream plays whichever source is active, and the server keeps every Snapcast group on it.
  • No per-Pi setup.
    • Each Pi's ID is rpi-<SoC serial>. It doesn't change when the SD card is reflashed or cloned.
    • The Pis find the server through DNS SRV records, because they are on a different VLAN, where mDNS doesn't reach.
  • Per-room settings through the API: room name, volume, mute, latency, and what the screen shows (music, the current event, or the clock). Changes apply to snapserver and the screen at once.
  • Jellyfin: cast from any Jellyfin app to Rezoverlay. Casting goes through the Jellyfin server, so unlike Spotify Connect and AirPlay it doesn't need mDNS and works across VLANs and VPNs.
  • Events: the current event from the Club-Rézo admin API (name, times, image and program) is shown on the screens.
  • Automatic updates: at every boot, each Pi installs the player release the server publishes, before the player starts.
  • TLS: the Pis talk to the server through an HTTPS reverse proxy, verified against the internal root CA in their system trust store.

Repository

Path What
server/ rezoverlay-server: FastAPI app. Snapcast JSON-RPC client, go-librespot, shairport-sync and Jellyfin metadata readers, device registry, WebSocket hub
player/ rezoverlay-player: the pygame display, DNS SRV discovery, the snapclient launcher, the boot-time updater, and the Pi's systemd units (rezoverlay_player/systemd/, shipped in the package so updates can change them)
deploy/server/ Configs for snapserver, go-librespot, shairport-sync and jellyfin-mpv-shim, an example nginx proxy, systemd units, install.sh
deploy/player/ install.sh for a Pi with a checkout of this repository (see Raspberry Pi for the one-line install)
deploy/player-build-id.sh Build ID of the player in a checkout, shared by both install scripts

Server (Debian 12+)

git clone <this repo> && cd cr-rezoverlay
sudo ./deploy/server/install.sh

The script does the following:

  • Installs snapserver and shairport-sync, go-librespot from its latest GitHub release, and jellyfin-mpv-shim from PyPI into /opt/jellyfin-mpv-shim (with Debian's libmpv and PulseAudio).
  • Creates the PCM/metadata FIFOs in /var/lib/rezoverlay/fifo.
  • Installs the server into /opt/rezoverlay/venv.
  • Builds the player wheel into /var/lib/rezoverlay/player-release, which the Pis install at boot (see Updates).
  • Writes /etc/rezoverlay/server.env with a random API token.
    • The server listens on 127.0.0.1:8000 for the reverse proxy. If the proxy runs on another host, set REZOVERLAY_LISTEN_HOST=0.0.0.0 and REZOVERLAY_FORWARDED_ALLOW_IPS=<proxy IP>.
  • Enables snapserver, go-librespot, shairport-sync, jellyfin-pulse, jellyfin-mpv-shim and rezoverlay-server. jellyfin-mpv-shim only starts once it's logged in (see Jellyfin).

Options:

  • SNAPSERVER_DEB=/path/to/snapserver_*.deb installs an upstream snapserver package instead of Debian's.
  • GO_LIBRESPOT_URL=… pins a go-librespot release.

The Spotify device, the AirPlay speaker and the Jellyfin cast target are all called Rezoverlay. To rename them, edit /var/lib/go-librespot/config.yml, /etc/shairport-sync.conf and player_name in /var/lib/jellyfin-mpv-shim/conf.json (then set REZOVERLAY_JELLYFIN_DEVICE_NAME to the same name).

HTTPS reverse proxy

The Pis reach the API, the WebSocket and the updates only through an HTTPS reverse proxy:

  • Its certificate must be issued by the internal root CA that is installed on the Pis, for the host name the _rezoverlay._tcp record points to.
  • It must forward WebSocket upgrades on /ws/ and set X-Forwarded-For, so each Pi is recorded with its own IP.
  • deploy/server/nginx-rezoverlay.conf is an example.

Snapcast audio does not go through the proxy: snapclient connects to snapserver directly over plain TCP.

DNS records

The Pis look for two SRV records in each search domain their DHCP server hands out (search in /etc/resolv.conf). Hosts and ports are always taken from these records:

_rezoverlay._tcp.example.org.  3600 IN SRV 0 0 443  music.example.org.          ; the HTTPS reverse proxy
_snapcast._tcp.example.org.    3600 IN SRV 0 0 1704 music-server.example.org.   ; snapserver stream port
  • The _rezoverlay._tcp target must match the proxy certificate's name. The Pis always use HTTPS for it (server_tls = true).
  • The two records can point to different hosts.
  • Several records with different priorities/weights work as failover for the API. snapclient only uses the first one.
  • Check from a Pi: dig SRV _rezoverlay._tcp.example.org or /opt/rezoverlay/venv/bin/rezoverlay-player env.

Firewall between the VLANs

From To Port
Pi VLAN reverse proxy TCP 443, or the port in _rezoverlay._tcp (API, WebSocket, cover art, updates)
Pi VLAN server TCP 1704, or the port in _snapcast._tcp (audio)

The JSON-RPC port 1705, Snapweb on 1780 and go-librespot's API on 3678 only listen on localhost.

Phones and the server:

  • Phones find the Spotify Connect device and the AirPlay speaker through mDNS.
  • That works when the phones share a VLAN with the server.
  • Otherwise the router needs an mDNS reflector or gateway for _spotify-connect._tcp and _raop._tcp/_airplay._tcp, for example avahi with enable-reflector=yes.
  • mDNS doesn't cross a VPN (WireGuard, OpenVPN tun) even with a reflector. Over a VPN, use Jellyfin.
  • Jellyfin doesn't use mDNS: the shim connects out to the Jellyfin server, so the server only needs to reach Jellyfin over HTTPS.

Jellyfin

jellyfin-mpv-shim runs on the server as a cast target with no window, and rezoverlay-server reads the title, cover and position from the Jellyfin API.

Audio path: mpv plays to a small PulseAudio daemon (jellyfin-pulse, config in /etc/rezoverlay/jellyfin-pulse.pa), whose pipe sink writes into jellyfin.fifo paced by the system clock. mpv's own raw output (ao=pcm) has no clock: it stalls on the FIFO and delayed play/pause and track changes in Jellyfin by up to ~20 s. install.sh moves an existing ao=pcm mpv.conf aside (mpv.conf.pcm-old).

  1. In Jellyfin:
    • Create a user for the cast target, e.g. rezoverlay, with access to the music libraries.
    • Under Dashboard → API Keys, create a key named rezoverlay-server.
  2. In /etc/rezoverlay/server.env: set REZOVERLAY_JELLYFIN_URL (e.g. https://jellyfin.int.rezo) and REZOVERLAY_JELLYFIN_API_KEY, then sudo systemctl restart rezoverlay-server. The server verifies HTTPS against the system trust store; REZOVERLAY_JELLYFIN_CA_FILE adds another CA.
  3. Log the cast target in once, with Quick Connect:
    sudo -H -u jellyfin-shim /opt/jellyfin-mpv-shim/bin/jellyfin-mpv-shim \
      --config /var/lib/jellyfin-mpv-shim --no-gui --quick-connect --server https://jellyfin.int.rezo
    
    It prints a code. Sign in to Jellyfin as rezoverlay, enter the code under Quick Connect in the user menu, and stop the command with Ctrl-C once it says it's connected. Then sudo systemctl start jellyfin-mpv-shim. Quick Connect must be enabled on the Jellyfin server (Dashboard → General). Without it, run the same command without --quick-connect --server …: it asks for the server URL, the username and the password.
  4. Who can cast: Jellyfin only lets a user control another user's player when that user has Allow remote control of other users (Dashboard → Users → the user → Profile). Turn it on for the people who should cast to Rezoverlay, or log the cast target in with an account they already share.
  5. Cast: in a Jellyfin app, tap the cast icon and pick Rezoverlay. The volume in the app is the master volume, like the phone's volume for Spotify.

When two sources play at once, the screens show the one that started last, while snapserver plays the first active one in Spotify/AirPlay/Jellyfin order. Pause one before starting another.

First install the internal root CA, as for any machine on the network (/usr/local/share/ca-certificates/<name>.crt, then sudo update-ca-certificates). Then, on the Pi:

curl -fsSL https://rezoverlay.int.rezo/install.sh | sudo sh

The server serves this script with its own address filled in (from the proxy's Host and X-Forwarded-Proto, or REZOVERLAY_PUBLIC_URL). The script:

  • downloads the player release the server publishes (the one the Pis update to at boot) and checks its sha256;
  • refuses plain HTTP unless run with REZOVERLAY_ALLOW_HTTP=1;
  • can be run again at any time, which reinstalls the current release.

With a checkout of this repository, sudo ./deploy/player/install.sh installs the player from the checkout instead.

Both install snapclient and the player's dependencies from Debian packages, including python3-pygame (its SDL2 has the KMS/DRM driver). Neither the install nor later updates need PyPI. They then install the player as a release under /opt/rezoverlay/releases/ and set up three services:

  • rezoverlay-update: runs at boot, before the other two. See Updates.
  • rezoverlay-player: the fullscreen display on HDMI. It takes over tty1.
  • snapclient, through a drop-in. At every start, rezoverlay-player snapclient looks up _snapcast._tcp, then execs snapclient --host <target> --port <port> --hostID <device ID> --soundcard default:CARD=vc4hdmi.

Pis installed before the updater existed only need to run either installer once more. It moves them to the release layout.

/etc/rezoverlay/player.toml is optional. You can use it to change the sound card, use another CA bundle, turn off updates, or skip DNS in unusual setups. See player.toml.example, which the installers copy there.

The screen redraws only on changes and once a second, so the CPU stays near zero.

Boot screen. Both installers install plymouth, plymouth-themes and imagemagick, then run rezoverlay-player install-bootscreen. It sets up a Plymouth theme with the Club Rézo logo above a spinner, on the player's background colour:

  • ImageMagick trims the logo and fits it to the connected screen (45% of its width), and scales the spinner (3.5% of its height). Plymouth itself doesn't scale images.
  • The theme uses the two-step plugin and the spinner frames of the stock spinner theme.
  • cmdline.txt gets quiet splash plymouth.ignore-serial-consoles logo.nologo vt.global_cursor_default=0. Without plymouth.ignore-serial-consoles, Plymouth would draw on the Pi's serial console instead of HDMI. config.txt gets disable_splash=1, which removes the firmware's rainbow square.
  • The initramfs is rebuilt, so the splash shows early in the boot. It shows from the next boot.

rezoverlay-update runs the same step at every boot, so a new logo, a new release or a different screen reaches the Pis on their own. It only rebuilds when one of them changed; otherwise it takes a few milliseconds. If one of the packages is missing, it installs it.

The splash, with its spinner, stays up until the player's first frame, including while rezoverlay-update looks for a new release. The player ends Plymouth itself with plymouth quit --retain-splash, so there is no flash of console in between.

To change the logo, replace player/rezoverlay_player/plymouth/logo.png (a PNG with a transparent background) and publish the player (sudo ./deploy/server/install.sh on the server). To keep the stock boot, set boot_screen = false in /etc/rezoverlay/player.toml. The settings already written to cmdline.txt and config.txt stay as they are.

Screens and display modes

Each room has a display_mode, set through the API (PATCH /api/devices/{id}). Everything on screen is in French.

display_mode What the screen shows
auto (default) The event screen if there is a current event; otherwise the music screen while something plays or is paused; otherwise the clock
music Always the music screen. With nothing playing it behaves like auto (the event screen if there is a current event, else the clock)
lyrics The lyrics screen. For a track without synced lyrics it shows the music screen; with nothing playing it behaves like auto
event Always the event screen. Without a current event it behaves like auto
clock Date and time only, nothing else

The screens:

  • Music: a blurred cover background, the cover, title, artist, album, a progress bar, the room name and a clock. "EN PAUSE" appears above the title when paused. The source (Spotify or AirPlay) is not shown.
    • With a current event, a bar at the bottom shows the event's name and image, and the activity happening now ("En ce moment : …") or next ("À suivre à 22h15 : …").
  • Lyrics: the same background, top bar and event bar as the music screen. The cover is on the left with the title, artist and album under it (beside it on a portrait screen), and the progress bar runs the whole width of the screen. The track's lyrics fill the right of the screen: the line being sung in white (lit word by word when the lyrics time each word), the others are fainter the further away they are, like Apple Music on the Apple TV. • • • marks an instrumental passage; its dots light up one after another until the next line. See Lyrics.
  • Event: "Bienvenue à la {rezalNumber}" above the Rézal name (the main title), the start and end times ("20h00 – 4h00", without dates), "Une question ? Venez nous voir en A206 !" and the event's horizontal image, over a blurred copy of that image. The description isn't shown. A thin bar across the top edge fills up as the event goes on: orange (#F8891F), or blue (#34B5E9) when the image is clearly blue. A program lists the activities in progress ("EN COURS") and the next ones, with time and location.
    • With music playing or paused, a bar at the bottom shows its cover, title, artist and progress.
  • Clock: the room name, a large clock and the date ("Mardi 27 octobre").
  • No server: "Recherche du serveur…" with the DNS name it is trying and the device ID ("Identifiant").
# Kitchen always shows the music, the hall always shows the event, the bedroom only the time
curl -s -X PATCH https://music.example.org/api/devices/rpi-a1b2c3d4 -H "Authorization: Bearer $TOKEN" \
     -H 'Content-Type: application/json' -d '{"display_mode": "music"}'

Animations, all under a second (the Pi draws every frame of the screen's refresh rate, 60 fps, only while one runs, and once a second otherwise; the GPU does the moving, scaling and fading):

  • a finished activity scrolls up out of the program;
  • the music bar on the event screen grows in when music starts and shrinks away when it stops;
  • a new track slides in horizontally in that bar;
  • on the music and lyrics screens, a new cover and its background crossfade (the previous cover stays up to 1.5 s while the new one downloads);
  • the event screen rises out of the event bar of the music or lyrics screen like a sheet, and goes back down into it (when the display mode changes, an event starts while music plays, the music ends during an event, or the event ends);
  • the music screen turns into the lyrics screen and back (when the display mode changes, or the lyrics of a track are found or not): the cover grows or shrinks, the track details and the progress bar move to their new place, the lyrics fade in or out;
  • on the lyrics screen, the lyrics slide up to the next line, which lights up as the previous one fades;
  • while a line is sung, its words light up one after another if the lyrics time each word, and the dots of an instrumental passage light up one by one (the screen then draws every frame too).

Lyrics

When the track changes, rezoverlay-server looks for its synced lyrics (with the time of each line) and pushes them to the screens:

  1. Karalyr, for every source: a free karaoke lyrics database, every word timed, no API key. Its catalogue is still small, but what it has lights up word by word. The server looks the track up by title, artist, album and duration, then by title and artist alone (it speaks LRCLIB's API). It must reach www.karalyr.com over HTTPS. Set REZOVERLAY_KARALYR_URL= (empty) to turn it off.
  2. Jellyfin, for a track cast from Jellyfin: the lyrics of the Jellyfin library (.lrc files next to the music, or lyrics embedded in it), through GET /Audio/{id}/Lyrics.
  3. LRCLIB, for every source: a free lyrics database that needs no API key. The server looks the track up by title, artist, album and duration, then by title and artist alone. It must reach lrclib.net over HTTPS. Set REZOVERLAY_LYRICS_URL= (empty) to turn it off. REZOVERLAY_LRCLIB_APP_ID and REZOVERLAY_LRCLIB_API_KEY, when set, are sent with every LRCLIB request as the Lrclib-App-Id and Lrclib-Api-Key headers.

Lyrics without timing count as no lyrics: the screen shows the music screen instead. The answer for each track is remembered (the last 64 tracks). After an error (LRCLIB is often busy and answers 503 or 429), the screen shows the music screen and the server asks again 10 s, 30 s and 90 s later; if all fail, it asks again the next time the track plays.

The rooms hear the music about a second after the sources report it: that is snapserver's buffer (1000 ms in snapserver.conf). Each line is shown REZOVERLAY_LYRICS_DELAY_MS later (1000 by default). Change it along with the buffer.

Word by word: lyrics that time each word light up word by word, as sung: Enhanced LRC ([00:12.00]<00:12.00>Hello <00:12.60>world<00:13.20>, from LRCLIB or a Jellyfin .lrc file), the word cues of Jellyfin 10.11 and later, and all of Karalyr's lyrics. Most lyrics only give the time of each line: the whole line then lights up when it starts (no guessing at the words' timing).

GET /api/lyrics shows what the screens have: status is pending while the lookup runs, then found (with the lines: [ms, text], or [ms, text, word times] when the lyrics time each word) or none.

Events

rezoverlay-server polls the Club-Rézo admin API (https://admin.int.rezo/api/docs) every REZOVERLAY_EVENT_POLL_INTERVAL seconds (60 by default). It calls:

  • GET /event/current: a 404 means there is no current event.
  • GET /event/{slug}/horizontalImage: only fetched again when the event changes.
  • GET /event/{slug}/activity: the program. Finished activities are left out.

The result is pushed to the screens and is visible at GET /api/event. If the admin API can't be reached, the screens keep showing the last event until its end date.

The admin API wants a bearer JWT. rezoverlay-server gets one itself with the OAuth2 client credentials grant, caches it until shortly before it expires, and gets a new one if the API rejects it. Settings, in /etc/rezoverlay/server.env:

REZOVERLAY_EVENT_API_URL=https://admin.int.rezo/api
REZOVERLAY_EVENT_TOKEN_URL=https://sso.example.org/realms/rezo/protocol/openid-connect/token
REZOVERLAY_EVENT_CLIENT_ID=rezoverlay
REZOVERLAY_EVENT_CLIENT_SECRET=<secret>
#REZOVERLAY_EVENT_SCOPE=           # only if the admin API needs an optional client scope
#REZOVERLAY_EVENT_CLIENT_AUTH=basic # or "post" to send the secret in the form body
#REZOVERLAY_EVENT_CA_FILE=          # extra PEM CA, on top of the system trust store

Then sudo systemctl restart rezoverlay-server. Certificates for the admin API and the identity provider are checked against the system trust store, so the internal root CA must be installed on the server (or given in REZOVERLAY_EVENT_CA_FILE).

Keycloak setup

Create a confidential client with a service account in the realm that issues the admin API's tokens:

  1. Clients → Create client
    • Client type: OpenID Connect. Client ID: rezoverlay. Name: Rezoverlay (optional).
    • Capability config: turn Client authentication on. Under Authentication flow, keep only Service accounts roles checked: uncheck Standard flow and Direct access grants.
    • Login settings: leave empty, since this client never logs a user in.
  2. Credentials tab: keep Client Authenticator on Client Id and Secret and copy the Client secret. It goes in REZOVERLAY_EVENT_CLIENT_SECRET.
  3. Service accounts roles tab → Assign role: give the service account the role(s) the admin API requires to read events and activities.
    • These may be realm roles, or client roles of the admin API's client: use Filter by clients to find those.
    • The admin API decides which roles it accepts. Grant only the read access to events.
  4. Audience (the admin API requires aud to contain admin-rezo):
    • In Clients → rezoverlay → Client scopes tab, open rezoverlay-dedicated → Configure a new mapper (or Add mapper → By configuration) → Audience.
    • Included Client Audience: admin-rezo. If it isn't a client of this realm, put it in Included Custom Audience instead.
    • Turn Add to access token on. Add to ID token can stay off.
    • Keycloak ignores the audience request parameter (REZOVERLAY_EVENT_AUDIENCE) for client credentials, so this mapper is the only way to get the claim.
  5. Token URL: https://<keycloak host>/realms/<realm>/protocol/openid-connect/token. It goes in REZOVERLAY_EVENT_TOKEN_URL.
    • The realm's OpenID Endpoint Configuration link (/realms/<realm>/.well-known/openid-configuration) lists it as token_endpoint.

Keycloak accepts the secret both as HTTP Basic (the default here) and in the form body (REZOVERLAY_EVENT_CLIENT_AUTH=post).

Check it from the server before restarting rezoverlay-server:

TOKEN_URL=https://sso.example.org/realms/rezo/protocol/openid-connect/token
TOKEN=$(curl -s -u "rezoverlay:<secret>" -d grant_type=client_credentials "$TOKEN_URL" | jq -r .access_token)
curl -s -H "Authorization: Bearer $TOKEN" https://admin.int.rezo/api/event/current | jq   # 404 = no current event
  • An empty TOKEN ("error": "unauthorized_client") means Service accounts roles is not enabled on the client.
  • A 401 from the admin API usually means a missing audience mapper. Check aud with echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .aud.
  • A 403 from the admin API means a missing role.

To see the token rezoverlay-server actually uses, set REZOVERLAY_EVENT_LOG_TOKEN=true in /etc/rezoverlay/server.env and restart. Each new token is then logged in full, followed by its decoded claims (aud, azp, realm_access, resource_access, exp…), so you can compare them with what the admin API expects, or paste the token into the curl above. Anyone who can read the journal can use a logged token until it expires (expires_in, 5 minutes by default in Keycloak), so turn this off again once you're done.

After the restart, journalctl -u rezoverlay-server logs current event: … and curl -s https://<proxy>/api/event shows what the screens get.

Updates

At every boot, rezoverlay-update.service runs rezoverlay-player update as root, before the player and snapclient start:

  1. It finds the server through _rezoverlay._tcp and asks for GET /api/player/release over HTTPS. The proxy's certificate must verify against the system trust store; updates over plain HTTP are refused.
  2. If the server's build ID is the one the Pi runs, nothing changes.
  3. Otherwise it downloads the wheel and checks its size and sha256. It installs the wheel into a new venv, /opt/rezoverlay/releases/<build>, and smoke-tests it (imports, --version).
  4. It then switches /opt/rezoverlay/venv to the new release in one atomic step. The new release installs its own systemd units and reloads systemd.
  5. If any step fails, the Pi keeps its current release. The previous release stays on disk; older ones are deleted.

If the server can't be found within update_timeout (30 s), the Pi starts the current release anyway.

To publish a new player version:

  1. Commit the change.
  2. On the server, git pull and re-run sudo ./deploy/server/install.sh.
  3. Each Pi picks it up at its next boot, or right away with sudo systemctl restart rezoverlay-update rezoverlay-player snapclient.

The build ID is the git tree hash of player/, so server changes that don't touch the player don't make the Pis update.

Things to know:

  • Python dependencies are not updated: they come from Debian packages. A release that needs a new one fails its smoke test, and the Pi keeps its current release until install.sh is re-run with the new package listed.
  • Updates run as root. Anyone who can serve a release from a host whose certificate the internal CA has signed can run code as root on every Pi.

GET /api/devices shows the release each Pi runs in app_version (0.1.0+<build>).

API

Interactive docs: https://<proxy>/docs. Write calls need Authorization: Bearer <REZOVERLAY_API_TOKEN>.

Method Path
GET /api/devices All Pis, with display_connected, snapclient_connected and online
GET /api/devices/{id} One Pi
PATCH /api/devices/{id} {"room_name": "Kitchen", "volume": 40, "muted": false, "latency_ms": 0, "display_mode": "auto"} (all fields optional; display_mode: auto, music, lyrics, event or clock)
DELETE /api/devices/{id} Forget a Pi. It registers again with defaults if it is still running
GET /api/now-playing Current track and state
POST /api/playback/{action} play, pause or toggle the music the screens show. Answers {"source": "spotify", "action": "pause"}; 409 when nothing is playing or paused, 502 when the source refuses
GET /api/event Current event as sent to the screens ({"active": false} when there is none)
GET /api/lyrics Synced lyrics of the current track as sent to the screens (status: pending, found or none)
GET /api/art/{art_id} Cover or event image
GET /api/health Server and snapserver status
GET /api/player/release The player release the Pis install at boot (build ID, filename, sha256, size)
GET /api/player/release/{filename} The release wheel
TOKEN=$(sudo grep -oP 'TOKEN=\K.*' /etc/rezoverlay/server.env)
curl -s https://music.example.org/api/devices | jq
curl -s -X PATCH https://music.example.org/api/devices/rpi-a1b2c3d4 \
     -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"room_name": "Kitchen", "volume": 35}'
curl -s -X POST -H "Authorization: Bearer $TOKEN" https://music.example.org/api/playback/toggle

Play/pause goes to the source the screens show (the one playing, else the last one paused):

  • Spotify: go-librespot's local API.
  • Jellyfin: a Pause/Unpause command to the cast target's session, through the Jellyfin server.
  • AirPlay: shairport-sync's D-Bus remote control. The sending device (iPhone, Mac) carries it out, so it only works when that device accepts remote control; the iOS and macOS Music apps do. Otherwise the call answers 502.
  • The screens and /api/now-playing change once the source reports the new state, usually within a second or two.

How settings stay in sync:

  • A Pi appears in the API on its own the first time its display or its snapclient connects. Its room name defaults to its hostname and its volume to 50 %.
  • Volume, name or latency changed from another Snapcast app (e.g. Snapdroid) are written back to the database.
  • The phone's volume acts as a master volume on top of each room's volume.

Development

# server
python3.11 -m venv .venv-server && .venv-server/bin/pip install -e 'server[dev]'
.venv-server/bin/pytest server
.venv-server/bin/rezoverlay-server --fake-source --port 8000 --db /tmp/rezoverlay.db   # demo tracks and a demo event, no Spotify or admin API needed

# player (pygame-ce from PyPI on a desktop)
python3.11 -m venv .venv-player && .venv-player/bin/pip install -e 'player[dev]'
.venv-player/bin/pytest player
.venv-player/bin/rezoverlay-player run --windowed --server http://localhost:8000
.venv-player/bin/rezoverlay-player screenshot shot.png --size 1920x1080 --scene event
#   scenes: playing paused long noart idle disconnected event event-only event-plain music-event clock-forced

Set REZOVERLAY_DEVICE_ID=dev-2 to run a second player on the same machine.

Troubleshooting

  • Black screen or "no available video device": check journalctl -u rezoverlay-player.
    • vc4-kms-v3d must be enabled (the default in /boot/firmware/config.txt).
    • On some setups SDL picks the wrong /dev/dri/card*. Add Environment=SDL_KMSDRM_DEVICE_INDEX=1 to the unit.
    • Blank screen with no error: run a test program with SDL_LOGGING='*=verbose' (see the player's SDL_RENDER_DRIVER note in ui/app.py). Failed loading libGL.so.1 followed by Parameter 'texture' is invalid means SDL tried desktop OpenGL; the player forces OpenGL ES 2 (SDL_RENDER_DRIVER=opengles2), so this only affects other programs.
  • Animations stutter: the log's display ... (driver kmsdrm, GPU) line at startup must say GPU. Otherwise a warning above it says why the player fell back to drawing in software, which is much slower. Add Environment=REZOVERLAY_FRAME_STATS=1 to the unit (systemctl edit rezoverlay-player) to log, after each animation, its frames, those shown late, and the slowest frame times.
  • No boot screen: sudo rezoverlay-player install-bootscreen (in /opt/rezoverlay/venv/bin/) says what it did or what failed, and plymouth-set-default-theme should print rezoverlay. cat /proc/cmdline must contain splash.
  • No sound: snapclient -l lists the ALSA devices. Set soundcard in /etc/rezoverlay/player.toml, then sudo systemctl restart snapclient.
  • The TV's audio is behind the music in other rooms: raise that room's latency_ms through the API.
  • Rezoverlay doesn't appear in Jellyfin's cast menu: systemctl status jellyfin-mpv-shim. "Condition check failed" means it isn't logged in yet (see Jellyfin); journalctl -u jellyfin-mpv-shim shows login and mpv errors. The user casting needs Allow remote control of other users.
  • Jellyfin plays but no sound: systemctl status jellyfin-pulse and journalctl -u jellyfin-pulse. sudo -u jellyfin-shim env XDG_RUNTIME_DIR=/run/rezoverlay-pulse PULSE_SERVER=unix:/run/rezoverlay-pulse/native pactl list short sinks should list rezoverlay (RUNNING while playing, SUSPENDED when idle).
  • Jellyfin plays but the screens don't show it: curl -s https://<proxy>/api/now-playing, and journalctl -u rezoverlay-server | grep -i jellyfin for API key or certificate errors. REZOVERLAY_JELLYFIN_DEVICE_NAME must match the shim's player_name.
  • The lyrics screen shows the music screen instead: curl -s https://<proxy>/api/lyrics. "status": "none" means neither Karalyr, Jellyfin nor LRCLIB has synced lyrics for the track (check it on https://www.karalyr.com and https://lrclib.net), or the lookup failed: journalctl -u rezoverlay-server | grep -i lyrics. 503 Service Unavailable or 429 Too Many Requests there means LRCLIB is overloaded; the server tries again on its own, and the lyrics appear when a later try succeeds.
  • No event on the screens: curl -s https://<proxy>/api/event shows what the server has, and journalctl -u rezoverlay-server | grep -i event shows poll errors (token, 401/403, certificate). Check that the Pi's clock and time zone are right (timedatectl): program times are shown in the Pi's local time.
  • The Pi can't find the server: run rezoverlay-player env on the Pi. It prints the names it looked up and why each one failed.
  • CERTIFICATE_VERIFY_FAILED: the proxy's certificate doesn't verify against the Pi's trust store. Check that the internal root CA is in /usr/local/share/ca-certificates/ and that update-ca-certificates was run, and that the certificate's name matches the _rezoverlay._tcp target.
  • An update didn't happen: journalctl -b -u rezoverlay-update shows the check, the build IDs and any error. Re-run it with sudo systemctl restart rezoverlay-update.
  • Roll back to the previous release: ls /opt/rezoverlay/releases, then sudo ln -sfn releases/<build> /opt/rezoverlay/venv and restart the services. The next boot updates again unless the server publishes that build.