Preset snapcast from server config, auto-import shared Spotify authorization - #1024
Open
FlatterAtMainz wants to merge 49 commits into
Open
Preset snapcast from server config, auto-import shared Spotify authorization#1024FlatterAtMainz wants to merge 49 commits into
FlatterAtMainz wants to merge 49 commits into
Conversation
… gst-plugins-rs Cross-compiles the gst-plugins-rs spotify GStreamer plugin natively on the amd64 build host (no QEMU) against vendored, locally editable librespot (v0.8.0) and gst-plugins-rs (kingosticks/spotify-logging) sources, patched together via Cargo's [patch.crates-io]. Final stage assembles a debian:trixie-slim arm64 image with Mopidy 4 + Mopidy-Spotify 5 + extensions for the Raspberry Pi 5 / k3s "snapserver" deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds disk-persist + serde(derive) to librespot-playback so AudioKeys can be persisted under /var/lib/mopidy/cache/<file_id>.ext and reused across restarts instead of re-requesting them from Spotify every time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sted PHP script Deployed as spotify-auth in namespace snapserver (LoadBalancer IP 192.168.188.26), credentials injected from a Secret rather than hardcoded. Removed the disabled TLS verification on the accounts.spotify.com calls and made CLIENT_ID/CLIENT_SECRET/REDIRECT_URI configurable via env vars instead of hardcoded PHP constants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…mpile Dockerfile: apt-get/pip install were placed after the COPY of the freshly-built libgstspotify.so, so any Rust rebuild (which happens on every build) cache-busted the unrelated, expensive package installs too. Reordered them before that COPY and added BuildKit cache mounts for apt/pip. Verified: a second build after touching Rust source now shows both layers as CACHED. player.rs: append_track_json had three compile errors (wrong open() arity, nonexistent `id` field, missing serde_json dependency) and would have written one file per track forever even if it had compiled, since AudioItem isn't Serialize and the file path was per-track despite using append mode. Replaced with a small NowPlaying DTO appended as one JSONL line per track change to a single shared log (/var/lib/mopidy/cache/nowplaying.jsonl), intended for an external display to tail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spotify's Developer Dashboard rejects http:// redirect URIs (requires https:// or a loopback literal). spotify-auth is LAN-only, so the existing letsencrypt ClusterIssuers don't apply (they're HTTP-01 against the public ingress). Instead: a dedicated self-signed root (k8s/internal-ca.yaml, ClusterIssuer "internal-ca-issuer") issues a leaf cert for the spotify-auth hostname (k8s/auth-server-certificate.yaml), mounted into the pod and served via Apache's mod_ssl. SPOTIFY_REDIRECT_URI updated to https://. internal-ca.crt is the root's public certificate, exported for import into browsers/devices - no private key material in it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…y 3.4.2 stack Root cause (found via GST_DEBUG=librespot:6,spotifyaudiosrc:6 GST_DEBUG_LIBRESPOT=DEBUG on a live pod): librespot's audio CDN fetch accepted whatever storage-resolve returned as the first URL and gave up entirely on a non-206 response, instead of trying the next candidate URL. Spotify's CDN intermittently returns 500 for the first URL in the list, which made every single track fail with "track is not available" regardless of librespot/gst-plugins-rs version, account entitlement, or token scope - all of which were individually verified fine along the way. Backported the fix from upstream PR librespot-org/librespot#1722 (open, unmerged as of 2026-06-19) into vendor/librespot/audio/src/fetch/mod.rs. Also switched the Dockerfile from Mopidy 4 + Mopidy-Spotify 5.0.0 to Mopidy==3.4.2 + Mopidy-Spotify==5.0.0a3, matching what's already running in production (confirmed via `pip show` on live pods). Mopidy 4 deleted mopidy.models.serialize.ModelJSONEncoder, which the current Mopidy-Iris (3.70.0) still imports directly - Iris fails to load at all on Mopidy 4, upstream incompatibility, not something fixable here. Mopidy-Local and Mopidy-InternetArchive correspondingly pinned to their last Mopidy-3-compatible releases (3.2.1 / 3.1.2); the current Mopidy-Local 3.2.1 still imports the stdlib `imghdr` module, removed in Python 3.13 (PEP 594), so local file browsing is currently broken on this image - tracked as a follow-up. setuptools<81 pin carried over: Iris/YouTube still `import pkg_resources`, removed from setuptools 81+. Verified end-to-end on mopidy1 (k8s1:31320/mopidy_stable:test): both a regular track and an audiobook-style track play and advance in real time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mopidy2-4 had conf/podcast volumeMounts missing from their containers even though the volumes list still referenced them - they were silently running on a blank default Mopidy config with Spotify disabled. Restored to match mopidy1 and verified all four now load their real config and play Spotify audio on k8s1:31320/mopidy_stable:latest. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Expand the per-file_id JSONL log (written to /var/lib/mopidy/cache/<file_id>.jsonl) with all available AudioItem metadata: format, bytes_per_second, is_explicit, language, album_artists, popularity, track/disc number, and show_name for episodes. Fix the prior bug where audio_item.file_id was referenced (field does not exist on AudioItem); append_track_json is now called inside load_track where the actual FileId and AudioFileFormat locals are in scope, and removed from start_playback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'sync' target (git pull --ff-only origin main) as a prerequisite for 'build' and 'push', so both targets always start from the latest committed code. --ff-only aborts loudly if local and remote have diverged instead of silently creating a merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- vendor/librespot/.upstream-ref: records v0.8.0 (the tag the vendor is based on) - vendor/gst-plugins-rs/.upstream-ref: records the spotify-logging branch - Makefile: init-upstreams clones/fetches upstream repos into sibling dirs (../librespot-upstream, ../gst-plugins-rs-upstream) to avoid the nested-git conflict that would arise inside vendor/ - Makefile: update-librespot and update-gst-plugins-rs show new upstream commits and a file diff so you can review before manually integrating Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In librespot v0.8.0, SpotifyUri::to_id() returns String (not Result), so the ? operator in spotifylyricssrc/imp.rs:321 was invalid. Drop the ? after .to_id() to match the current API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dockerfile: - Split rust-cross-builder into rust-toolchain (apt + rustup) and rust-cross-builder (COPY + cargo). The toolchain layers are now independent of source changes, so apt/rustup only re-run when the toolchain definition itself changes. - Add --mount=type=cache to the apt install in rust-toolchain, matching the final stage's pattern. Package downloads survive cold layer cache. Makefile: - Add --cache-from / --cache-to type=registry to build and push targets. The buildx builder stores cache in its container; this exports it to REGISTRY/mopidy_stable:buildcache so layers survive builder restarts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both extensions import `imghdr`, which was removed from the Python 3.13 standard library (debian:trixie-slim). They fail at startup with ModuleNotFoundError, and the Local extension failure leaves Mopidy core in a state that breaks Iris connectivity. Neither is used in this Spotify-based setup, so removing them is the clean fix rather than adding a third-party imghdr backport. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The v0.9.0 vendor update (37c1d42) overwrote player.rs with upstream, erasing two sets of local modifications: 1. Audio key disk cache (DiskPersist<DataSer>): avoids a round-trip to the Spotify CDN for the decryption key on every play by persisting it to /var/lib/mopidy/cache/<file_id>.ext between sessions. 2. Per-file_id metadata log (append_track_json): writes a JSONL entry to /var/lib/mopidy/cache/<file_id>.jsonl on each play with full track metadata (name, artists, album, format, bitrate, etc.). Re-applies both patches against the v0.9.0 source. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
patches/librespot/player.patch and patches/gst-plugins-rs/spotifylyricssrc-imp.patch capture all local modifications relative to the upstream vendor state, so they survive future upstream syncs. New Makefile targets: - integrate-librespot / integrate-gst-plugins-rs: rsync upstream HEAD into vendor/ then immediately re-apply the stored patches (patch -p1 --fuzz=3) - apply-patches: apply all patches without rsync (e.g. after a bare rsync) - save-patches: regenerate .patch files from current vendor state vs upstream HEAD (requires init-upstreams; run after manually resolving conflicts) update-librespot / update-gst-plugins-rs now print a hint pointing to the integrate target so the full workflow is self-documenting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The v0.9.0 vendor update erased the three dependencies our local patches require from playback/Cargo.toml: disk-persist = "0.1" # audio-key disk cache serde / serde_json "1" # per-track metadata JSONL serialisation Also adds patches/librespot/playback-Cargo.patch so future integrate-librespot and apply-patches targets restore these deps automatically alongside the player.rs patch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.output-control__inner capped its height with max-height on mobile but used overflow-y: visible, so a snapserver device/group list taller than the popup just overflowed off-screen with no way to scroll to it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vendors github.com/FlatterAtMainz/Iris under vendor/iris (same pattern as the librespot/gst-plugins-rs vendoring, minus a patches/ layer since it's our own fork rather than a third-party upstream). The Dockerfile gains an iris-builder stage that runs npm ci/npm run prod and packages a wheel, which the final stage now installs in place of the PyPI Mopidy-Iris package. Makefile gets matching update-iris/integrate-iris targets for pulling in future fork changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
max-height: calc(100vh - 140px) sizes the popup using the full layout viewport, which on mobile includes space the browser's address bar can occupy. When the address bar is visible, the actually-visible viewport is shorter than 100vh, so the popup (anchored to the bottom, growing upward) extends above what's visible, hiding its top row. Add a 100dvh override, which tracks the real visible viewport, after the 100vh fallback for browsers that don't support dvh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ess bar Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On mobile the popup only auto-opens via PlaybackControls expanding (force_expanded=true), which also hides the popup's own close button (display:none in CSS) and suppressed its own click-outside overlay. That left no way to dismiss just the output-control popup - only tapping in the empty space above it (collapsing the whole playback bar) worked. Always render the click-outside overlay regardless of force_expanded so the popup can close independently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Errors surfaced through handleException often ended up either blank or as a bare technical fragment (e.g. "Could not load your profile" with no explanation, or a raw "SyntaxError: JSON.parse: unexpected end of input"). Add a humanizeError util that recognises common failure shapes (invalid/empty JSON responses, network failures, expired/revoked OAuth grants, known HTTP status codes) and prefixes the description with a plain-language explanation, applied centrally in the HANDLE_EXCEPTION handler so every existing call site benefits. Also: - handleException now falls back to a plain Error object's .message for the description, and no longer throws when data.error is undefined (message derivation had the same unguarded access). - Wrap the JSON.parse of xhr.responseText in HANDLE_EXCEPTION in a try/catch - a non-JSON error response would otherwise throw inside the exception handler itself. - Spotify getMe() and refreshToken() failures now get specific messages pointing at expired/revoked authorization, since that was the actual root cause behind the vague errors we hit in practice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vendored from a local merge of fix/mobile-output-control-popup and fix/error-message-context (both open as separate PRs upstream, since they're unrelated concerns). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Kaniko (what this cluster's Kubernetes-executor runner uses, since it has no privileged/DinD access for real docker buildx) ignores FROM --platform=$BUILDPLATFORM entirely and just builds as its own pod's real host arch - confirmed against Kaniko's own docs/issue tracker before touching anything, not assumed. Since every node here is arm64, that means a Kaniko build of this Dockerfile was already effectively native, but the Rust build stage's hardcoded --target aarch64-unknown-linux-gnu cross-compilation (gcc-aarch64-linux-gnu toolchain and all) would still have tried to run as if cross-compiling FROM some other arch, on a host that already IS the target - at best redundant, at worst broken. Both Rust-toolchain-install and cargo-build steps now branch on `dpkg --print-architecture` vs TARGETARCH (defaulted to arm64, and set explicitly in .gitlab-ci.yml, since Kaniko doesn't auto-populate this build-arg the way buildx does) at RUN-time: native build with no cross toolchain when they already match (this cluster's CI, always), the original cross-compilation path when they don't (still available for anyone building from an amd64 dev machine via buildx). Added .gitlab-ci.yml: builds mopidy_stable + spotify-auth via cluster/ci-templates, force-restarts mopidy1-4 and spotify-auth (mirroring the Makefile's existing `make deploy` scope, and covering spotify-auth which has no keel.sh annotation at all).
… optional chaining - Humanized error explanations now live under errors.* in en.yaml (via i18n()) instead of being hardcoded in JS, so they're translatable like the rest of the UI. - Replace `x && x.y` null checks with optional chaining per review feedback.
…onal chaining Addresses upstream PR review feedback (jaedb) on the plain-language error notification work: humanized explanations now live in en.yaml under errors.* instead of hardcoded JS strings, and null-checks use optional chaining.
The 2026-08-05 build hung for a full hour after the job pod's node (k8s6) briefly lost kubelet connectivity mid-build, breaking the runner's log stream - the job itself wasn't slow, GitLab just had no way to notice the connection had died. 30m still leaves headroom for a legitimately slow cold build (no cross-run Kaniko cache, armv8 Pi CPUs, full toolchain + dependency rebuild every run) while failing fast on a repeat of the same failure mode.
…ream clones Vendors our FlatterAtMainz/mopidy fork (branch stream-error-fix-v4.0.1, commit 4bcdfe8f) into vendor/mopidy, same pattern as vendor/iris: no patches/ layer, changes tracked directly in the fork's own git history. This carries the GStreamer stream_error auto-recovery fix built earlier against the v4.0.1 release tag. Also moves the librespot, gst-plugins-rs, and Iris upstream clones (used by the update-*/integrate-* Makefile targets) from loose siblings in the parent Cluster/ directory into upstream/ inside this repo, gitignored, so the whole build's inputs live in one place instead of scattered across an unrelated projects directory.
Replaces the PyPI-installed Mopidy==3.4.2 pin with a pip install of vendor/mopidy (our stream_error-patched v4.0.1 fork), now that vendor/iris carries the upstream fix for Mopidy 4's removed ModelJSONEncoder - the thing that previously blocked this migration. Mopidy-Spotify and Mopidy-InternetArchive move to their current stable releases (5.0.0 / 4.0.1), the first of each requiring Mopidy>=4.0.0. Verified in isolation (debian:trixie-slim, native arm64 build): the vendored source builds and installs cleanly via SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MOPIDY, both extensions import, and the mopidy CLI runs. Full image build with Iris + the Rust GStreamer plugin is the next step.
Without it, the build context included upstream/ (500MB+ of nested git clones, mostly the Iris checkout) now that those live inside this repo instead of as siblings - none of it is referenced by the Dockerfile.
…ailures getSearchResults never resolved its process notification on request failure or on cancel-button click, leaving it stuck in the UI forever. The five Spotify library-fetch actions had the same underlying gap (no rejection handler at all), silently swallowing errors and leaking stuck process state on any failed request.
Re-vendored from Iris @ 61cc027.
The fetch handler served every cached response indefinitely with no invalidation path: the cache is only purged in 'activate', which only fires when the service worker script's own bytes change - something a plain webpack rebuild of app.min.js/app.min.css never does. In practice, once a browser cached the app bundle on first visit, it kept serving that exact bundle forever, silently masking every subsequent deploy (bug fixes included) until a user manually cleared the service worker/cache storage. Flip to network-first: always attempt a live fetch first (still caching successful GET responses under the same blacklist rules as before), and only fall back to the cache when the network request itself fails. The cache now serves its intended purpose - an offline fallback - without being able to hide live updates from users who are online.
jaedb/Iris's master branch never received jojo141185's mopidy-alpha- compatibility fix (jaedb#1003) - it only landed on upstream's develop branch, which this fork doesn't track. This was previously applied by hand directly in custom-mopidy's vendored copy, bypassing this repo entirely, which meant a routine re-sync silently reverted it. Cherry-picking here makes this repo authoritative again. (cherry picked from commit 4954f6d)
The manual rsync + .upstream-ref tracking let this directory silently diverge from its source repo twice in one week (once when independent Mopidy-4 work landed on main without a corresponding Iris/ commit, once when a hand-applied core.py patch got clobbered by a routine re-sync because it only ever existed here, not upstream). Converting to a git subtree in the next commit so there's exactly one copy of this code, not two that need to be kept in sync by hand.
…auth Two related propagation gaps, both surfaced by the mopidy.conf config centralization: the server's get_config() RPC already sends snapcast_host/port/ssl/stream/enabled to every connecting browser, but PUSHER_CONFIG's handler only ever applied country/locale/the three *_authorization_url fields - snapcast values arrived and were silently dropped. Fixed by extending the same guarded first-time-seed pattern already used for country/locale (never clobbers a value the user set deliberately). Separately: Iris already has a manual "Share configuration" flow that can push a Spotify OAuth authorization to the server (set_shared_config), broadcast live to every other browser connected to that instance, but applying it required the user to manually open the notification/Settings prompt and click Import. Now PUSHER_SHARED_CONFIG auto-imports a shared spotify.authorization the same way, but only into browsers that don't already have their own - so re-authenticating with Spotify in every new browser (within the same mopidy instance) is no longer necessary. Also fixes a real pre-existing bug hit while wiring this up: importAuthorization(authorization) silently dropped a second `me` argument it was already being called with (Notifications.js's existing import flow), so action.me read by the SPOTIFY_IMPORT_AUTHORIZATION reducer case was always undefined. Scope: this syncs within browsers connected to the same mopidy instance only - mopidy1-4 remain 4 independent processes with separate shared_config storage, so authorizing on one instance does not (yet) propagate to the others.
Confirmed broken by live testing in a private browser window: neither
snapcast_host nor snapcast_enabled ever got applied. Root cause -
store/index.js's initialState hardcodes concrete snapcast defaults
(host: 'localhost', port: '1780', enabled: false, ssl: false), unlike
spotify.country/locale/authorization which genuinely start undefined.
The guard pattern this borrowed from country/locale ("only seed if
still undefined") can never fire for snapcast, because none of those
fields are ever undefined - not even in a brand new browser.
Replaced with a dedicated one-time flag (ui.snapcast_config_seeded),
the same pattern already used for ui.initial_setup_complete, which
persists correctly across reloads since 'ui' isn't blacklisted from
redux-persist. Confirmed the Spotify authorization auto-import
(PUSHER_SHARED_CONFIG) is unaffected - spotify.authorization has no
hardcoded default, so that guard was correct as originally written.
Confirmed by live testing: snapcast_enabled did get set to true (the
previous fix), but the UI still showed Disconnected until manually
toggling the enabled checkbox off and back on. Root cause - the seed
dispatched a plain snapcastActions.set({enabled: true}), but only
SNAPCAST_SET_ENABLED's middleware actually calls connect()/disconnect()
as a side effect. App.js's own connect-on-boot effect only runs once
(deps: [isReady]) and had already captured enabled=false - the
hardcoded initialState default - by the time this async PUSHER_CONFIG
response arrived, so nothing ever dialled the connection despite the
flag being correct in state.
Switched the enabled seed to snapcastActions.setEnabled(), which goes
through that same middleware and triggers connect(). Connection detail
fields (host/port/ssl/stream) are dispatched first, since
SNAPCAST_CONNECT reads them live from state at dispatch time - they
need to already be correct before setEnabled(true) can connect to the
right server.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related propagation gaps in how the server's
[iris]config (get_configRPC) and the "Share configuration" feature (shared_config,set_shared_config/get_shared_config) reach a connecting browser's Redux state:get_configalready sendssnapcast_host/port/ssl/stream/enabledto every connecting browser, butPUSHER_CONFIG's handler only ever appliedcountry/locale/the three*_authorization_urlfields — snapcast values arrived and were silently dropped. Fixed by seedingstate.snapcastthe same way, but as a dedicated one-time flag (ui.snapcast_config_seeded) rather than reusing the country/locale "seed only if still falsy" pattern:store/index.js'sinitialStatehardcodes concrete snapcast defaults (host: 'localhost',port: '1780',enabled: false,ssl: false), unlikespotify.country/localewhich genuinely startundefined— so a value-based guard can never fire for snapcast, not even in a brand new browser. Also had to route theenabledseed throughsnapcastActions.setEnabled()rather than a plainset(), since onlysetEnabled's middleware actually callsconnect()/disconnect()as a side effect —App.js's own connect-on-boot effect only runs once (deps:[isReady]) and had already capturedenabled=falseby the time this async config response arrives, so a plainset()updated the flag correctly but never dialled the connection.set_shared_config), broadcast live to every other browser connected to that instance (shared_config_changed) and fetched automatically on every connect (getSharedConfig()onPUSHER_CONNECTED) — but applying it required manually opening the notification/Settings prompt and clicking Import.PUSHER_SHARED_CONFIGnow auto-imports a sharedspotify.authorizationthe same way, but only into browsers that don't already have their own, so re-authenticating with Spotify in every new browser (within the same mopidy instance) is no longer necessary once someone has shared it once.importAuthorization(authorization)silently dropped a secondmeargument it was already being called with (Notifications.js's existing import flow), soaction.meread by theSPOTIFY_IMPORT_AUTHORIZATIONreducer case was alwaysundefined.All of this was verified against a live cluster (4 independent mopidy instances behind one Iris/spotify-auth setup) rather than just locally, including two real regressions caught by live testing in a fresh private-browser session and fixed in follow-up commits (both explained in their own commit messages) - the sentinel-value guard bug and the missing
setEnabled()call.Scope: this syncs within browsers connected to the same mopidy instance only. If you run multiple independent mopidy processes (e.g. one per room) with separate
shared_configstorage, authorizing on one instance does not propagate to the others - that would need a separate mechanism.Test plan
[iris]config hassnapcast_host/enabledset: confirm Settings shows the right host and Snapcast auto-connects on load, without needing to manually toggle it off/on.