Commits
Tapping the miniplayer only opened the full PlayerScreen for the local library
(player === "upload"); for a remote device (player === "rockbox") the tap was a
no-op, and PlayerScreen also force-closed / returned null unless the source was
"upload". Allow both sources: the miniplayer tap opens the screen for a remote
device too, and PlayerScreen stays open for it (transport works via the same
targeted commands; the local-only queue list just stays empty).
Redix.command/3 EXITS (not {:error, _}) with {:redix_exited_during_call, :noproc}
when the connection is gone — e.g. during a restart while many WebSocket
terminate/2 handlers run on_disconnect Redis calls — which crashed those
connection processes and spewed [error] logs. Wrap every adapter call so any
failure is treated as a cache miss / no-op.
Commands from the miniplayer weren't reaching the player: play/pause STATE
synced (track/status frames display regardless of id), but the transport
commands went nowhere.
Cause: device_message tagged broadcasts with the client-reported `device_id`
from the payload. An old CLI (pre-0.7.1) clobbers its own id from a
device_registered broadcast, so its tracks were tagged with the WEB's id; the
miniplayer then targeted commands at that id and the server routed them back to
the web (which ignores them), never to the CLI.
Fix: use the connection's registered device_id (assigned at register,
authoritative) for caching, primary, and the broadcast tag — ignore the payload
id entirely. Robust regardless of client version.
Add a "Multiple players per user" section (per-device cache, presence,
set_primary/primary_changed, auto-adopt) and update the dispatch walkthrough
(set_primary branch, register snapshot, on_disconnect) + message-type list.
Mirror the web fix on mobile: replace the singleton remote source with a
per-device model (devicesAtom + activeDeviceIdAtom). The SourceSheet now lists
every connected player with its current track; selecting one shows/controls it
and sends set_primary (synced across clients via primary_changed). Transport
commands target the active device only. Handles the presence protocol (devices
snapshot, per-device track/status, device_unregistered, primary_changed).
The StickyPlayer modelled the remote source as a singleton, so two players
streaming at once fought over one now-playing and one command target. Replace it
with a per-device model:
- devicesAtom (device_id → state) + activeDeviceIdAtom. Each connected player
gets its own entry; no shared/conflicting state.
- Source selector lists every device (with its current track); selecting one
shows/controls it AND sends set_primary so it becomes the scrobble source,
synced across the user's clients via primary_changed.
- Transport commands target the active device only, so controlling one player
never disturbs another.
- Handle the presence protocol: devices snapshot on connect, per-device track/
status upserts, device_unregistered removal, primary_changed convergence
(without stealing focus from Spotify/local engine).
A user can now run several players (e.g. two Rocksky CLIs) at once without them
conflicting. Fixes the web UI flip-flopping between simultaneous players.
- Per-device now-playing cache (np:{did}:{device_id}) so a newly-connected
client gets a snapshot and lists every active player. Only devices that have
actually sent a track appear (controllers excluded).
- Explicit primary device: the user's public profile now-playing + scrobble is
driven ONLY by the selected primary device. New set_primary message stores
primary_device:{did} and broadcasts primary_changed so all the user's clients
converge on the same active device. Auto-adopts a lone player when none is
selected/connected (headless CLI still scrobbles).
- The per-user profile machinery (nowplaying/lastsong/ws_lastsong/stopped +
NATS song.changed/stopped, incl. Navidrome coordination) is unchanged — it
just runs only for the primary device, so non-primary players never touch it.
- Presence: device_unregistered broadcast on disconnect (Connection.terminate →
Handler.on_disconnect); register replies now include a devices snapshot.
36 tests, incl. multi-device isolation, set_primary switching, and disconnect.
The genre Artists/Albums tabs built link targets with `record.uri.split("at://")`
with no null guard. Genres like "Ska Punk" contain an artist/album whose uri is
null, so `null.split(...)` threw and crashed the whole page (the Artists tab
renders first by default).
Add a null-safe `uriToPath` helper (lib/uri.ts) that returns "#" for a
missing/malformed uri, and use it across all three genre tabs (Artists, Albums,
Tracks — the last also had a latent unguarded [1].replace).
Add REPL/bb command groups to operate the two prod systemd services over SSH:
the Elixir remote-ws relay and the Go deezer metadata microservice.
- console.remote: shared SSH ops helper (status/restart/start/stop/logs/health/
deploy/shell) parameterized by unit + port, targeting the prod box. systemctl
and journalctl run --no-pager so they work from the REPL or a bb one-shot.
- console.remote-ws (rocksky-remote-ws, :4000) and console.deezer
(rocksky-deezer, :8090): thin wrappers, each also with a `dev` local-run
(mix phx.server / go run main.go).
- Registered in core.clj registry, dev/user.clj aliases, and bb.edn as rws:*
and dz:* tasks (prefixed to avoid the existing docker restart/logs names).
Verified: namespaces compile, `bb ls` lists both groups, and `bb rws:health` /
`bb dz:health` return {"status":"ok"} from prod.
The player remote-control /ws relay is now served by the Elixir remote-ws
service, with Caddy routing /ws to it. Remove the Node implementation:
- delete src/websocket/handler.ts
- drop the /ws route, createNodeWebSocket/upgradeWebSocket/injectWebSocket
wiring from src/index.ts
- remove the now-unused @hono/node-ws dependency
The NATS song.changed/song.stopped events the subscribers rely on are now
published by remote-ws, so subscribers/status.ts is unaffected.
DEPLOY ORDER: ensure Caddy routes /ws to remote-ws (and the service is up)
before shipping this, or /ws will 404.
Document the request lifecycle: OTP entrypoint + supervision tree, the
HTTP→WebSocket upgrade path, the per-socket connection process, Handler dispatch,
and an end-to-end CLI→web frame-flow diagram.
Same bug as the CLI: the connect daemon read `deviceId` from every server
message, including the `device_registered` broadcast carrying ANOTHER device's
id when a new device (e.g. a miniplayer) joins. That overwrote its own id, so its
now-playing pushes were tagged with the wrong device and miniplayers mislabeled
the source.
Only adopt the id from the registration reply (status: "registered"); ignore
device_registered announcements.
The CLI adopted `deviceId` from ANY server message carrying that field —
including the `device_registered` broadcast the server sends when ANOTHER device
joins (e.g. the web miniplayer "rocksky-web"). That overwrote the CLI's own id,
so its subsequent track pushes were tagged with the other device's id and every
miniplayer mislabeled the source (showing "rocksky-web" instead of "Rocksky
CLI"); the scrobble `source` was wrong too.
Fix: only adopt the id from the registration reply (status: "registered"), and
ignore device_registered announcements. Bump CLI to 0.7.1.
Defense-in-depth: web + mobile now prefer the player's self-reported name
(embedded in its payload, authoritative regardless of device-id routing) over
the server-computed top-level device_name, so the label is correct even against
an un-updated CLI.
The relay has no cookies/sessions/CSRF/LiveView, so it never uses the endpoint's
secret_key_base — Phoenix only needs one present to boot. Generate it at runtime
instead of requiring a SECRET_KEY_BASE env var, so prod boots with no extra
secret. Update README and the systemd unit comment accordingly.
config/config.exs imports "#{config_env()}.exs", so a prod boot (MIX_ENV=prod)
needs config/prod.exs. It was missing, so `mix compile` on the server failed with
File.Error reading config/prod.exs. Add the compile-time prod config; runtime
values still come from config/runtime.exs.
Migrate player remote-control WS to Elixir/Phoenix
Proxy the player remote-control WebSocket to the new remote-ws service on
localhost:4000 (REMOTE_WS_PORT); everything else on api.rocksky.app stays on the
Node API. Routing the whole /ws path to one upstream keeps the in-memory device
registries from splitting between the two relays.
Phase 1 of migrating apps/api/src/websocket/handler.ts to Elixir/Phoenix/Ecto.
The existing Node /ws server stays authoritative; this is a parallel, wire- and
NATS-compatible reimplementation.
New service at remote-ws/ (OTP app :remote_ws):
- Raw-JSON WebSocket at GET /ws via WebSock+Bandit (not Phoenix Channels) to keep
the exact wire protocol: ping/pong, register, command, message(track|status).
- RemoteWs.Devices: duplicate Registry keyed by DID, replacing the Node
devices/deviceNames/userDevices maps (auto-cleans on disconnect).
- RemoteWs.StopDebouncer: GenServer port of the 15s song.stopped debounce.
- RemoteWs.NowPlaying: faithful port of the enrichment + ws_lastsong gating,
same Redis keys/TTLs and song.changed/song.stopped NATS payloads.
- RemoteWs.Auth: Joken HS256 (ignore-expiration) + access-token jti revoke check.
- Redis/NATS/Store behind behaviours (Redix, Gnat, Ecto) so the gating logic is
unit-tested against in-memory doubles — 29 tests, no live services.
- Reuses the same env var names as apps/api (JWT_SECRET, XATA_POSTGRES_URL,
REDIS_URL, NATS_URL); listen port is REMOTE_WS_PORT.
Ops:
- systemd/rocksky-remote-ws.service
- CI: remote-ws job added to .github/workflows/tests.yml (paths-filtered)
Ships the remote-control feature: the CLI player appears as a controllable
device in the web/mobile miniplayers.
Three fixes to the CLI↔miniplayer remote sync:
- Progress: the miniplayers now keep their smooth 100ms local tick and only
snap to the device's pushed elapsed on a track change or a >2s gap (a real
seek), so the periodic (~4s) reconcile no longer causes visible jumps.
- Play-state: the CLI now embeds is_playing in each track push. The initial
`status` message is sent before a client adopts the device (and so is
ignored); carrying play-state on the track makes the play/pause icon
correct from the first frame.
- Device name: the CLI also puts device_name inside `data`, which the relay
forwards untouched, so the source selector shows the configured name even
on an API build without the top-level device_name field. Clients read
either location.
The interactive TUI wires its own runtime inline and never calls
startHeadlessRuntime, so the /ws remote connection only started for the
`rocksky mpd` daemon — running the TUI player advertised no device to the
web/mobile miniplayers. Start (and stop) startRockskyRemote directly in
cmd/tui.tsx alongside the existing session/settings wiring.
Enable CLI playback control via web/mobile miniplayers
Make the Rocksky CLI player a controllable device on the /ws relay, like
the Rockbox companion: it registers with the session token, streams its
now-playing (track + status) to the web/mobile miniplayers, and executes
play/pause/next/previous/seek commands from them — elapsed time stays in
sync both ways.
- cli: new tui/rockskyWs.ts (register, push state, handle commands),
wired into startHeadlessRuntime so it runs for the TUI and `mpd` daemon.
Device name is configurable via [remote] name in settings.toml
(default "Rocksky CLI"); ROCKSKY_NO_REMOTE disables it.
- api: include device_name in ws broadcasts so clients can label the source.
- web: add a "device" player source to StickyPlayer — remote now-playing,
live progress, and transport sent back as commands.
- web-mobile: label the remote source with device_name; add remote
previous + seek.
The bell popover Body used zIndex: 2, so the feed's sticky pills (z-50)
and stories carousel (z-60) stacked over the dropdown. Bump it to 100 so
the popup sits above timeline chrome while staying below the modal tier
(dialogs at z-50/60, KeyboardShortcuts at z-1000).
- Drop the subject card border in dark mode (web via `.dark &`, mobile is
always dark so remove it outright); in light mode it was white-on-white.
- Shrink actor avatars and enlarge the album-art thumbnail in both apps.
- Show a react-content-loader skeleton in the web popup while the first
notification page loads.
Enrich each notification server-side with its subject song (album art,
title, artist) resolved from the subjectUri in hydrate(), exposed via a
new subjectView lexicon def. Render a subject card (art + ellipsized
title/artist) below the actor text in the web popup and mobile list.
Group notifications client-side by subject: likes, follows and comment
reactions collapse into one row with stacked avatars ("A, B and N others
liked your scrobble"); comments/replies/mentions stay individual. Done
client-side because the SSE stream pushes individual notifications and
invalidates the list, so regrouping on render stays correct. The
relative date rendering is unchanged.
avatar.ts fetched the profile straight from the user's PDS, so it timed
out from egress that self-hosted PDSes block (Contabo -> caramelo). Use
the AppView-first fetchBskyProfile helper and only overwrite fields it
actually returned.
The spotifyUser object was spread verbatim into app.rocksky.actor.getProfile,
leaking the user's Spotify account email. Strip it like the token secrets
already are.
Self-hosted PDSes (e.g. caramelo.social.br on Locaweb BR) silently drop
traffic from our Contabo VPS IP, so com.atproto.repo.getRecord against
the origin PDS always timed out in prod — corrupting avatar/displayName
for those users.
Add lib/bskyProfile.fetchBskyProfile: read avatar + displayName from the
Bluesky public AppView (public.api.bsky.app, globally reachable, returns
a ready-made avatar CDN URL) and only fall back to a direct PDS read when
the AppView lookup fails. Wire it into getProfile, GET /profile and
GET /users/:did, and stop hand-building CID-less avatar URLs.
getProfile's refreshProfile overwrote users.avatar/display_name with an
empty name and a CID-less avatar URL whenever the bsky profile fetch in
retrieveProfile failed (record stays {}), and published the garbage to
NATS. A single failed fetch permanently corrupted the row.
Now only write a field when the fetch actually produced it, preserving
existing DB values otherwise; build the avatar URL from the real CID and
mimeType extension; and serve the resolved user in presentation so the
response and DB agree.
Open the command palette pre-filtered by type via single-key shortcuts:
s (songs), a (artists), l (albums), u (people); "/" opens it scoped to
all. Threads the requested scope through a new searchModalScopeAtom that
the palette reads on open. Regroups the help modal with a dedicated
"Search" section documenting the new keys.
Implement Raycast-style command palette for web search
Replace the inline search popover with a full command palette (web only;
web-mobile keeps its own search). Opened by clicking the sidebar search
field or pressing "/".
- Grouped results (Songs/Artists/Albums/Playlists/People) with artwork,
a scope filter dropdown (All + per-type), and full keyboard navigation
(arrows, Enter to open, Esc to close)
- Reuses the existing AT-URI -> path routing so navigation is unchanged
- Types the federated search response (SearchResponse union + guards),
replacing the old any[]
- Wires "/" and Esc through the global keyboard-shortcut handler
Add an app-wide keyboard-shortcut layer mounted in the root route:
- "/" focuses search (with a "/" hint badge in the input that hides
once the user types)
- "?" opens a themed help modal listing every shortcut
- "g" combos navigate (h/l/c/p/r/w); "e" opens audio settings;
"t" toggles theme; "k/n/b/m/←/→" drive playback
- Esc closes the help modal
Playback shortcuts reuse the sticky player's existing transport
handlers, published to a new playerControlsAtom and cleared when
nothing is playing so media keys stay inert on idle pages.
Self-host JetBrains Mono (Regular/Medium/Bold woff2) and add a
--font-mono CSS variable in both apps. Apply it to first-tier data
tokens — handles, tags/genres, durations, counts, and dates — via
inline fontFamily and the shared Duration/Handle styled components.
Labels stay in RockfordSans; wrapped/ seasonal pages and Recharts SVG
axis formatters are intentionally excluded.
Bump the pinned install snippets to match the released SDK versions:
clojure 0.8.1-SNAPSHOT, kotlin 0.9.1.
Add trackNumber/discNumber to the songMatchView lexicon (pkl source +
generated JSON) so matchSong's `matches` list carries the fields Deezer
now backfills. Regenerate server lexicon types, the TypeScript and Go
SDKs, and the crates/rocksky-sdk Rust bindings; forward the fields
through the hand-written DeezerMatch type via the existing `...m` spread.
Bump every native/FFI SDK to a patch release (TS/Python/Ruby/Elixir
0.8.1, Clojure 0.8.1-SNAPSHOT, Kotlin 0.9.1, Erlang 0.5.1, Gleam 1.8.1);
rocksky-sdk already at 0.6.1, sdk/rust deprecated, Go versioned by tag.
The /search endpoint omits track_position and disk_number, so matches
were always missing them. Deep-fetch the full track for the top 5
ranked matches to backfill trackNumber/discNumber in the response.
The SDK exposes a UniFFI Object named `Library` (the uploaded-music client),
whose generated Kotlin class shares its simple name with `com.sun.jna.Library`
imported by the UniFFI runtime. An unqualified `Library` then resolved to our
Object, breaking `UniffiLib`/`loadIndirect` and failing `:rocksky:compileKotlin`
during `sdk/publish :kotlin`.
Alias the JNA import as `JnaLibrary` (so unqualified `Library` stays our Object)
and qualify the two JNA usages. Bake the same post-patch into gen-bindings.sh so
future UniFFI regens stay fixed.
Propagate the new songViewDetailed.matches field (array of songMatchView)
to every SDK that models the matchSong response, without bumping versions
(none are published yet):
- TypeScript + Go: regenerated from lexicons via `bun run lexgen:types`
(SongMatchView + matches on SongViewDetailed).
- crates/rocksky-sdk (jacquard engine): regenerated song.rs via
scripts/gen-lexicons.sh; cargo fmt normalizes the generator's formatting
drift so only the SongMatchView struct + matches field remain in the diff.
- sdk/rust (standalone HTTP SDK): hand-added SongMatchView struct + matches
field to generated.rs, re-exported as SongMatch in models.rs, and extended
the matchSong test to assert the ranked list round-trips.
The FFI SDKs (kotlin, python, ruby, erlang, gleam, elixir, clojure) return
matchSong as opaque JSON with an extra_data catch-all, so `matches` already
surfaces at runtime with no code change.
The Deezer fallback previously only triggered when Spotify failed entirely.
A track resolved via Spotify or MusicBrainz kept its track_number/disc_number
as-is — and the MusicBrainz conversion defaults both to 0 when a recording has
no release media — so those zeros were never filled.
Add backfill_track_disc_from_deezer to both crates: when a resolved track has
track_number == 0 or disc_number == 0, query Deezer and fill just those
(gated, so no extra call when both are already set).
- webscrobbler resolve_track: applied at the DB, Spotify and MusicBrainz return
sites (the Deezer-fallback branch already carries Deezer's numbers).
- scrobbler: route all submit sites through a single submit_scrobble
choke-point that backfills before rocksky::scrobble, so every resolution path
(cache, DB, Spotify, MusicBrainz) is covered across scrobble / scrobble_v1 /
scrobble_listenbrainz.
Audited every metadata enrichment flow that resolves tracks via Spotify /
MusicBrainz and wired the Deezer fallback into the ones that were still missing
it, so no pipeline ends up with incomplete metadata when the other providers
fail.
Rust:
- crates/webscrobbler: new deezer module (client + EnrichedTrack->Track) and a
Deezer step in resolve_track between Spotify and MusicBrainz.
- crates/mirror: enrich_via_deezer fills album_art / isrc / track & disc number
/ duration on NormalizedTrack after a Spotify miss (durationMs already ms).
TypeScript (apps/api):
- New shared lib/deezer.ts helper (enrichWithDeezer, never throws).
- nowplaying.service.ts (live scrobble path): fill album art, ISRC, duration,
track/disc number, label, release date/year, genres, artist picture, deezer
link after MusicBrainz/Spotify.
- subscribers/status.ts: Deezer fallback for album art, and resolve a correct
track number (payload -> DB -> Deezer) that is only published when > 0. Adds
trackNumber (minimum 1) to the actor.defs#trackView lexicon.
- scripts/backfill-isrc-mbid.ts: Deezer ISRC fallback when Spotify has none.
A Spotify failure (rate limiting / HTTP 429, auth, or exhausted timeout
retries) previously threw out of the match retrieve step, aborting the whole
match before the Deezer enrichment block ran. Guard the Spotify call so any
failure degrades to no Spotify result and the flow continues to Deezer, which
fills the missing metadata (or builds the track from scratch when no record
matched). The failure is logged as a warning so 429s stay visible.
Add echo's Logger middleware so the service emits a per-request access log
(method, URI, status, latency) for all routes, not just the incidental
cache hit/miss lines.
Change the Deezer service default port from 8089 to 8090 across the Go
service, the apps/api DEEZER_URL default, and the Rust scrobbler client. The
Go service now reads DEEZER_PORT first, falling back to PORT then the default.
Add Deezer enrichment service to enhance track metadata
Add a standalone Deezer Go microservice (deezer/) modeled on musicbrainz/
that takes title+artist+optional album and returns fully enriched track
metadata plus a ranked list of best matches. It owns rate limiting
(50 req / 5s) and an in-memory TTL cache, and deep-fetches the top candidate
to fill every available field (ISRC, UPC, label, genres, release date/year,
track/disc number, hi-res album art + artist picture).
When Spotify search fails or returns partial data we end up with incomplete
metadata; Deezer now fills the gaps (or builds the track from scratch):
- apps/api matchSong: ctx.deezer client + DEEZER_URL env; fallback after the
Spotify block. Adds an additive, non-breaking `matches` list to
songViewDetailed (pkl -> lexicon JSON -> generated TS). Score scaled 0-100.
- Rust scrobbler: DeezerClient + try_deezer_enrich fallback across scrobble,
scrobble_v1 and scrobble_listenbrainz.
Duration unit: Deezer returns seconds; the service x1000 to durationMs to
align with Spotify's native duration_ms (verified against the live API).
Tests mock the real Deezer API with httptest; a DEEZER_SMOKE-gated live smoke
test exercises the real endpoint (off in CI). Adds systemd unit, root npm
script, and a gated GitHub Actions job for the Go tests.
Add notifications and GIF support across all SDKs with TypeScript typing
Wire the new app.rocksky.notification.* XRPC group (getUnreadCount,
listNotifications, updateSeen) and shout GIF/sticker/clip attachments into
every SDK, and type all previously-untyped TypeScript client responses.
Core (crates/rocksky-sdk):
- Regenerate shout + notification lexicon bindings (adopt only those to avoid
codegen churn); shout record gains gif/facets, message now optional
- Typed AppView notification methods (unread_count/notifications/update_seen)
with NotificationView/Actor/List/UnreadCount/UpdateSeenResult wire types +
a POST mutate helper
- ShoutGif input + agent shout_with_gif / reply_shout_with_gif
FFI cores: uniffi (typed Records + methods + ShoutGifInput), capi C ABI
(rocksky_update_seen, gif shout fns), rustler NIF (update_seen, gif shout nifs).
Language SDKs:
- python/kotlin: regenerate uniffi bindings
- ruby/clojure/erlang/elixir/gleam: hand-written wrappers over capi/nif
- typescript: notifications + gif; type every Promise<unknown> read against the
regenerated lexicon views (only the generic get() escape hatch stays unknown)
- go: regenerate types; typed notification methods + gif shout variants
Bump versions: rust sdk 0.6.0, uniffi/nif 0.3.0, ts 0.8.0, python/ruby/elixir
0.8.0, gleam 1.8.0, erlang 0.5.0, kotlin 0.9.0, clojure 0.8.0-SNAPSHOT.
Add mention notifications and deep-linking for shouts
Shout facets carried resolved mention DIDs but never dispatched notifications.
Add a "mention" notification type and a notifyMentions() helper wired into both
createShout and replyShout: it maps each facet DID to a users.id and notifies
them, skipping the subject owner / parent-comment author (already notified) and
self-mentions.
Also make notification rows deep-link to the shout instead of the actor's
profile: turn the subject at-uri into its page path plus a #shout-<id> hash, and
have ShoutList scroll to and briefly highlight that shout once loaded. replyShout
now stores the underlying subject uri (not the parent shout uri) so the link is
navigable. Applies to all shout-related notification types; follows still link
to the actor.
The `.dark` class is set on #root, so html/body (outside #root) never
receive the themed --color-background and stayed white. That white showed
through on overscroll/rubber-band at the top of the timeline in dark mode.
Sync html/body backgroundColor (and color-scheme) with the active theme
in the same effect that already syncs the theme-color meta tag.
feat(onboarding): guide new users on empty profiles, home & upload
Web + web-mobile: detect a brand-new user's own empty profile (own
profile + 0 scrobbles) and replace the empty/ugly tabs with a guided
onboarding experience, plus a first-steps modal that auto-opens once.
- New Onboarding components (@emotion/styled): NewUserGuide inline hero +
action cards, OnboardingModal (web) / OnboardingSheet (mobile), and a
thin dismissible WelcomeBanner on the web home page.
- Get-started steps: Connect a scrobbler (/mirrors), Import history
(docs.rocksky.app/cli/import, new tab), Scrobble from apps (/apikeys),
Upload music (/library/upload). Spotify intentionally excluded (beta).
- Robust own-profile detection: match viewed profile against
localStorage "did" OR the logged-in profileAtom (did/handle), and
persist the logged-in did to localStorage in useProfile so detection is
stable across the Main-layout remount on every navigation.
- Fix: Follow button no longer shows on your own profile (web now gates
on isOwnProfile instead of a raw localStorage compare).
- apikeys pages: add help link to docs.rocksky.app/integrations.
- Upload screen: "Stream it anywhere" note — uploads play via any
Navidrome/Subsonic client or @rocksky/cli, with docs links.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The NavTab.icon type `ComponentType<{ size?: number; strokeWidth?: number }>`
was too narrow: Tabler icons type `size` as `string | number`, and React's
propTypes variance makes them unassignable to it under `tsc -b`. Widen `icon`
to `ElementType`, which accepts both the Tabler icons and the custom BellIcon.
`styled(Link)` erases TanStack Router's generic route typing, so the typed
`to="/profile/$did" params={{ did }}` form no longer type-checks under
`tsc -b`. Use a resolved path string (`to={`/profile/${handle}`}`) with no
params — the same pattern the existing MentionLink styled(Link) uses.
Tapping the miniplayer only opened the full PlayerScreen for the local library
(player === "upload"); for a remote device (player === "rockbox") the tap was a
no-op, and PlayerScreen also force-closed / returned null unless the source was
"upload". Allow both sources: the miniplayer tap opens the screen for a remote
device too, and PlayerScreen stays open for it (transport works via the same
targeted commands; the local-only queue list just stays empty).
Redix.command/3 EXITS (not {:error, _}) with {:redix_exited_during_call, :noproc}
when the connection is gone — e.g. during a restart while many WebSocket
terminate/2 handlers run on_disconnect Redis calls — which crashed those
connection processes and spewed [error] logs. Wrap every adapter call so any
failure is treated as a cache miss / no-op.
Commands from the miniplayer weren't reaching the player: play/pause STATE
synced (track/status frames display regardless of id), but the transport
commands went nowhere.
Cause: device_message tagged broadcasts with the client-reported `device_id`
from the payload. An old CLI (pre-0.7.1) clobbers its own id from a
device_registered broadcast, so its tracks were tagged with the WEB's id; the
miniplayer then targeted commands at that id and the server routed them back to
the web (which ignores them), never to the CLI.
Fix: use the connection's registered device_id (assigned at register,
authoritative) for caching, primary, and the broadcast tag — ignore the payload
id entirely. Robust regardless of client version.
Mirror the web fix on mobile: replace the singleton remote source with a
per-device model (devicesAtom + activeDeviceIdAtom). The SourceSheet now lists
every connected player with its current track; selecting one shows/controls it
and sends set_primary (synced across clients via primary_changed). Transport
commands target the active device only. Handles the presence protocol (devices
snapshot, per-device track/status, device_unregistered, primary_changed).
The StickyPlayer modelled the remote source as a singleton, so two players
streaming at once fought over one now-playing and one command target. Replace it
with a per-device model:
- devicesAtom (device_id → state) + activeDeviceIdAtom. Each connected player
gets its own entry; no shared/conflicting state.
- Source selector lists every device (with its current track); selecting one
shows/controls it AND sends set_primary so it becomes the scrobble source,
synced across the user's clients via primary_changed.
- Transport commands target the active device only, so controlling one player
never disturbs another.
- Handle the presence protocol: devices snapshot on connect, per-device track/
status upserts, device_unregistered removal, primary_changed convergence
(without stealing focus from Spotify/local engine).
A user can now run several players (e.g. two Rocksky CLIs) at once without them
conflicting. Fixes the web UI flip-flopping between simultaneous players.
- Per-device now-playing cache (np:{did}:{device_id}) so a newly-connected
client gets a snapshot and lists every active player. Only devices that have
actually sent a track appear (controllers excluded).
- Explicit primary device: the user's public profile now-playing + scrobble is
driven ONLY by the selected primary device. New set_primary message stores
primary_device:{did} and broadcasts primary_changed so all the user's clients
converge on the same active device. Auto-adopts a lone player when none is
selected/connected (headless CLI still scrobbles).
- The per-user profile machinery (nowplaying/lastsong/ws_lastsong/stopped +
NATS song.changed/stopped, incl. Navidrome coordination) is unchanged — it
just runs only for the primary device, so non-primary players never touch it.
- Presence: device_unregistered broadcast on disconnect (Connection.terminate →
Handler.on_disconnect); register replies now include a devices snapshot.
36 tests, incl. multi-device isolation, set_primary switching, and disconnect.
The genre Artists/Albums tabs built link targets with `record.uri.split("at://")`
with no null guard. Genres like "Ska Punk" contain an artist/album whose uri is
null, so `null.split(...)` threw and crashed the whole page (the Artists tab
renders first by default).
Add a null-safe `uriToPath` helper (lib/uri.ts) that returns "#" for a
missing/malformed uri, and use it across all three genre tabs (Artists, Albums,
Tracks — the last also had a latent unguarded [1].replace).
Add REPL/bb command groups to operate the two prod systemd services over SSH:
the Elixir remote-ws relay and the Go deezer metadata microservice.
- console.remote: shared SSH ops helper (status/restart/start/stop/logs/health/
deploy/shell) parameterized by unit + port, targeting the prod box. systemctl
and journalctl run --no-pager so they work from the REPL or a bb one-shot.
- console.remote-ws (rocksky-remote-ws, :4000) and console.deezer
(rocksky-deezer, :8090): thin wrappers, each also with a `dev` local-run
(mix phx.server / go run main.go).
- Registered in core.clj registry, dev/user.clj aliases, and bb.edn as rws:*
and dz:* tasks (prefixed to avoid the existing docker restart/logs names).
Verified: namespaces compile, `bb ls` lists both groups, and `bb rws:health` /
`bb dz:health` return {"status":"ok"} from prod.
The player remote-control /ws relay is now served by the Elixir remote-ws
service, with Caddy routing /ws to it. Remove the Node implementation:
- delete src/websocket/handler.ts
- drop the /ws route, createNodeWebSocket/upgradeWebSocket/injectWebSocket
wiring from src/index.ts
- remove the now-unused @hono/node-ws dependency
The NATS song.changed/song.stopped events the subscribers rely on are now
published by remote-ws, so subscribers/status.ts is unaffected.
DEPLOY ORDER: ensure Caddy routes /ws to remote-ws (and the service is up)
before shipping this, or /ws will 404.
Same bug as the CLI: the connect daemon read `deviceId` from every server
message, including the `device_registered` broadcast carrying ANOTHER device's
id when a new device (e.g. a miniplayer) joins. That overwrote its own id, so its
now-playing pushes were tagged with the wrong device and miniplayers mislabeled
the source.
Only adopt the id from the registration reply (status: "registered"); ignore
device_registered announcements.
The CLI adopted `deviceId` from ANY server message carrying that field —
including the `device_registered` broadcast the server sends when ANOTHER device
joins (e.g. the web miniplayer "rocksky-web"). That overwrote the CLI's own id,
so its subsequent track pushes were tagged with the other device's id and every
miniplayer mislabeled the source (showing "rocksky-web" instead of "Rocksky
CLI"); the scrobble `source` was wrong too.
Fix: only adopt the id from the registration reply (status: "registered"), and
ignore device_registered announcements. Bump CLI to 0.7.1.
Defense-in-depth: web + mobile now prefer the player's self-reported name
(embedded in its payload, authoritative regardless of device-id routing) over
the server-computed top-level device_name, so the label is correct even against
an un-updated CLI.
Phase 1 of migrating apps/api/src/websocket/handler.ts to Elixir/Phoenix/Ecto.
The existing Node /ws server stays authoritative; this is a parallel, wire- and
NATS-compatible reimplementation.
New service at remote-ws/ (OTP app :remote_ws):
- Raw-JSON WebSocket at GET /ws via WebSock+Bandit (not Phoenix Channels) to keep
the exact wire protocol: ping/pong, register, command, message(track|status).
- RemoteWs.Devices: duplicate Registry keyed by DID, replacing the Node
devices/deviceNames/userDevices maps (auto-cleans on disconnect).
- RemoteWs.StopDebouncer: GenServer port of the 15s song.stopped debounce.
- RemoteWs.NowPlaying: faithful port of the enrichment + ws_lastsong gating,
same Redis keys/TTLs and song.changed/song.stopped NATS payloads.
- RemoteWs.Auth: Joken HS256 (ignore-expiration) + access-token jti revoke check.
- Redis/NATS/Store behind behaviours (Redix, Gnat, Ecto) so the gating logic is
unit-tested against in-memory doubles — 29 tests, no live services.
- Reuses the same env var names as apps/api (JWT_SECRET, XATA_POSTGRES_URL,
REDIS_URL, NATS_URL); listen port is REMOTE_WS_PORT.
Ops:
- systemd/rocksky-remote-ws.service
- CI: remote-ws job added to .github/workflows/tests.yml (paths-filtered)
Three fixes to the CLI↔miniplayer remote sync:
- Progress: the miniplayers now keep their smooth 100ms local tick and only
snap to the device's pushed elapsed on a track change or a >2s gap (a real
seek), so the periodic (~4s) reconcile no longer causes visible jumps.
- Play-state: the CLI now embeds is_playing in each track push. The initial
`status` message is sent before a client adopts the device (and so is
ignored); carrying play-state on the track makes the play/pause icon
correct from the first frame.
- Device name: the CLI also puts device_name inside `data`, which the relay
forwards untouched, so the source selector shows the configured name even
on an API build without the top-level device_name field. Clients read
either location.
The interactive TUI wires its own runtime inline and never calls
startHeadlessRuntime, so the /ws remote connection only started for the
`rocksky mpd` daemon — running the TUI player advertised no device to the
web/mobile miniplayers. Start (and stop) startRockskyRemote directly in
cmd/tui.tsx alongside the existing session/settings wiring.
Make the Rocksky CLI player a controllable device on the /ws relay, like
the Rockbox companion: it registers with the session token, streams its
now-playing (track + status) to the web/mobile miniplayers, and executes
play/pause/next/previous/seek commands from them — elapsed time stays in
sync both ways.
- cli: new tui/rockskyWs.ts (register, push state, handle commands),
wired into startHeadlessRuntime so it runs for the TUI and `mpd` daemon.
Device name is configurable via [remote] name in settings.toml
(default "Rocksky CLI"); ROCKSKY_NO_REMOTE disables it.
- api: include device_name in ws broadcasts so clients can label the source.
- web: add a "device" player source to StickyPlayer — remote now-playing,
live progress, and transport sent back as commands.
- web-mobile: label the remote source with device_name; add remote
previous + seek.
- Drop the subject card border in dark mode (web via `.dark &`, mobile is
always dark so remove it outright); in light mode it was white-on-white.
- Shrink actor avatars and enlarge the album-art thumbnail in both apps.
- Show a react-content-loader skeleton in the web popup while the first
notification page loads.
Enrich each notification server-side with its subject song (album art,
title, artist) resolved from the subjectUri in hydrate(), exposed via a
new subjectView lexicon def. Render a subject card (art + ellipsized
title/artist) below the actor text in the web popup and mobile list.
Group notifications client-side by subject: likes, follows and comment
reactions collapse into one row with stacked avatars ("A, B and N others
liked your scrobble"); comments/replies/mentions stay individual. Done
client-side because the SSE stream pushes individual notifications and
invalidates the list, so regrouping on render stays correct. The
relative date rendering is unchanged.
Self-hosted PDSes (e.g. caramelo.social.br on Locaweb BR) silently drop
traffic from our Contabo VPS IP, so com.atproto.repo.getRecord against
the origin PDS always timed out in prod — corrupting avatar/displayName
for those users.
Add lib/bskyProfile.fetchBskyProfile: read avatar + displayName from the
Bluesky public AppView (public.api.bsky.app, globally reachable, returns
a ready-made avatar CDN URL) and only fall back to a direct PDS read when
the AppView lookup fails. Wire it into getProfile, GET /profile and
GET /users/:did, and stop hand-building CID-less avatar URLs.
getProfile's refreshProfile overwrote users.avatar/display_name with an
empty name and a CID-less avatar URL whenever the bsky profile fetch in
retrieveProfile failed (record stays {}), and published the garbage to
NATS. A single failed fetch permanently corrupted the row.
Now only write a field when the fetch actually produced it, preserving
existing DB values otherwise; build the avatar URL from the real CID and
mimeType extension; and serve the resolved user in presentation so the
response and DB agree.
Open the command palette pre-filtered by type via single-key shortcuts:
s (songs), a (artists), l (albums), u (people); "/" opens it scoped to
all. Threads the requested scope through a new searchModalScopeAtom that
the palette reads on open. Regroups the help modal with a dedicated
"Search" section documenting the new keys.
Replace the inline search popover with a full command palette (web only;
web-mobile keeps its own search). Opened by clicking the sidebar search
field or pressing "/".
- Grouped results (Songs/Artists/Albums/Playlists/People) with artwork,
a scope filter dropdown (All + per-type), and full keyboard navigation
(arrows, Enter to open, Esc to close)
- Reuses the existing AT-URI -> path routing so navigation is unchanged
- Types the federated search response (SearchResponse union + guards),
replacing the old any[]
- Wires "/" and Esc through the global keyboard-shortcut handler
Add an app-wide keyboard-shortcut layer mounted in the root route:
- "/" focuses search (with a "/" hint badge in the input that hides
once the user types)
- "?" opens a themed help modal listing every shortcut
- "g" combos navigate (h/l/c/p/r/w); "e" opens audio settings;
"t" toggles theme; "k/n/b/m/←/→" drive playback
- Esc closes the help modal
Playback shortcuts reuse the sticky player's existing transport
handlers, published to a new playerControlsAtom and cleared when
nothing is playing so media keys stay inert on idle pages.
Self-host JetBrains Mono (Regular/Medium/Bold woff2) and add a
--font-mono CSS variable in both apps. Apply it to first-tier data
tokens — handles, tags/genres, durations, counts, and dates — via
inline fontFamily and the shared Duration/Handle styled components.
Labels stay in RockfordSans; wrapped/ seasonal pages and Recharts SVG
axis formatters are intentionally excluded.
Bump the pinned install snippets to match the released SDK versions:
clojure 0.8.1-SNAPSHOT, kotlin 0.9.1.
Add trackNumber/discNumber to the songMatchView lexicon (pkl source +
generated JSON) so matchSong's `matches` list carries the fields Deezer
now backfills. Regenerate server lexicon types, the TypeScript and Go
SDKs, and the crates/rocksky-sdk Rust bindings; forward the fields
through the hand-written DeezerMatch type via the existing `...m` spread.
Bump every native/FFI SDK to a patch release (TS/Python/Ruby/Elixir
0.8.1, Clojure 0.8.1-SNAPSHOT, Kotlin 0.9.1, Erlang 0.5.1, Gleam 1.8.1);
rocksky-sdk already at 0.6.1, sdk/rust deprecated, Go versioned by tag.
The SDK exposes a UniFFI Object named `Library` (the uploaded-music client),
whose generated Kotlin class shares its simple name with `com.sun.jna.Library`
imported by the UniFFI runtime. An unqualified `Library` then resolved to our
Object, breaking `UniffiLib`/`loadIndirect` and failing `:rocksky:compileKotlin`
during `sdk/publish :kotlin`.
Alias the JNA import as `JnaLibrary` (so unqualified `Library` stays our Object)
and qualify the two JNA usages. Bake the same post-patch into gen-bindings.sh so
future UniFFI regens stay fixed.
Propagate the new songViewDetailed.matches field (array of songMatchView)
to every SDK that models the matchSong response, without bumping versions
(none are published yet):
- TypeScript + Go: regenerated from lexicons via `bun run lexgen:types`
(SongMatchView + matches on SongViewDetailed).
- crates/rocksky-sdk (jacquard engine): regenerated song.rs via
scripts/gen-lexicons.sh; cargo fmt normalizes the generator's formatting
drift so only the SongMatchView struct + matches field remain in the diff.
- sdk/rust (standalone HTTP SDK): hand-added SongMatchView struct + matches
field to generated.rs, re-exported as SongMatch in models.rs, and extended
the matchSong test to assert the ranked list round-trips.
The FFI SDKs (kotlin, python, ruby, erlang, gleam, elixir, clojure) return
matchSong as opaque JSON with an extra_data catch-all, so `matches` already
surfaces at runtime with no code change.
The Deezer fallback previously only triggered when Spotify failed entirely.
A track resolved via Spotify or MusicBrainz kept its track_number/disc_number
as-is — and the MusicBrainz conversion defaults both to 0 when a recording has
no release media — so those zeros were never filled.
Add backfill_track_disc_from_deezer to both crates: when a resolved track has
track_number == 0 or disc_number == 0, query Deezer and fill just those
(gated, so no extra call when both are already set).
- webscrobbler resolve_track: applied at the DB, Spotify and MusicBrainz return
sites (the Deezer-fallback branch already carries Deezer's numbers).
- scrobbler: route all submit sites through a single submit_scrobble
choke-point that backfills before rocksky::scrobble, so every resolution path
(cache, DB, Spotify, MusicBrainz) is covered across scrobble / scrobble_v1 /
scrobble_listenbrainz.
Audited every metadata enrichment flow that resolves tracks via Spotify /
MusicBrainz and wired the Deezer fallback into the ones that were still missing
it, so no pipeline ends up with incomplete metadata when the other providers
fail.
Rust:
- crates/webscrobbler: new deezer module (client + EnrichedTrack->Track) and a
Deezer step in resolve_track between Spotify and MusicBrainz.
- crates/mirror: enrich_via_deezer fills album_art / isrc / track & disc number
/ duration on NormalizedTrack after a Spotify miss (durationMs already ms).
TypeScript (apps/api):
- New shared lib/deezer.ts helper (enrichWithDeezer, never throws).
- nowplaying.service.ts (live scrobble path): fill album art, ISRC, duration,
track/disc number, label, release date/year, genres, artist picture, deezer
link after MusicBrainz/Spotify.
- subscribers/status.ts: Deezer fallback for album art, and resolve a correct
track number (payload -> DB -> Deezer) that is only published when > 0. Adds
trackNumber (minimum 1) to the actor.defs#trackView lexicon.
- scripts/backfill-isrc-mbid.ts: Deezer ISRC fallback when Spotify has none.
A Spotify failure (rate limiting / HTTP 429, auth, or exhausted timeout
retries) previously threw out of the match retrieve step, aborting the whole
match before the Deezer enrichment block ran. Guard the Spotify call so any
failure degrades to no Spotify result and the flow continues to Deezer, which
fills the missing metadata (or builds the track from scratch when no record
matched). The failure is logged as a warning so 429s stay visible.
Add a standalone Deezer Go microservice (deezer/) modeled on musicbrainz/
that takes title+artist+optional album and returns fully enriched track
metadata plus a ranked list of best matches. It owns rate limiting
(50 req / 5s) and an in-memory TTL cache, and deep-fetches the top candidate
to fill every available field (ISRC, UPC, label, genres, release date/year,
track/disc number, hi-res album art + artist picture).
When Spotify search fails or returns partial data we end up with incomplete
metadata; Deezer now fills the gaps (or builds the track from scratch):
- apps/api matchSong: ctx.deezer client + DEEZER_URL env; fallback after the
Spotify block. Adds an additive, non-breaking `matches` list to
songViewDetailed (pkl -> lexicon JSON -> generated TS). Score scaled 0-100.
- Rust scrobbler: DeezerClient + try_deezer_enrich fallback across scrobble,
scrobble_v1 and scrobble_listenbrainz.
Duration unit: Deezer returns seconds; the service x1000 to durationMs to
align with Spotify's native duration_ms (verified against the live API).
Tests mock the real Deezer API with httptest; a DEEZER_SMOKE-gated live smoke
test exercises the real endpoint (off in CI). Adds systemd unit, root npm
script, and a gated GitHub Actions job for the Go tests.
Wire the new app.rocksky.notification.* XRPC group (getUnreadCount,
listNotifications, updateSeen) and shout GIF/sticker/clip attachments into
every SDK, and type all previously-untyped TypeScript client responses.
Core (crates/rocksky-sdk):
- Regenerate shout + notification lexicon bindings (adopt only those to avoid
codegen churn); shout record gains gif/facets, message now optional
- Typed AppView notification methods (unread_count/notifications/update_seen)
with NotificationView/Actor/List/UnreadCount/UpdateSeenResult wire types +
a POST mutate helper
- ShoutGif input + agent shout_with_gif / reply_shout_with_gif
FFI cores: uniffi (typed Records + methods + ShoutGifInput), capi C ABI
(rocksky_update_seen, gif shout fns), rustler NIF (update_seen, gif shout nifs).
Language SDKs:
- python/kotlin: regenerate uniffi bindings
- ruby/clojure/erlang/elixir/gleam: hand-written wrappers over capi/nif
- typescript: notifications + gif; type every Promise<unknown> read against the
regenerated lexicon views (only the generic get() escape hatch stays unknown)
- go: regenerate types; typed notification methods + gif shout variants
Bump versions: rust sdk 0.6.0, uniffi/nif 0.3.0, ts 0.8.0, python/ruby/elixir
0.8.0, gleam 1.8.0, erlang 0.5.0, kotlin 0.9.0, clojure 0.8.0-SNAPSHOT.
Shout facets carried resolved mention DIDs but never dispatched notifications.
Add a "mention" notification type and a notifyMentions() helper wired into both
createShout and replyShout: it maps each facet DID to a users.id and notifies
them, skipping the subject owner / parent-comment author (already notified) and
self-mentions.
Also make notification rows deep-link to the shout instead of the actor's
profile: turn the subject at-uri into its page path plus a #shout-<id> hash, and
have ShoutList scroll to and briefly highlight that shout once loaded. replyShout
now stores the underlying subject uri (not the parent shout uri) so the link is
navigable. Applies to all shout-related notification types; follows still link
to the actor.
The `.dark` class is set on #root, so html/body (outside #root) never
receive the themed --color-background and stayed white. That white showed
through on overscroll/rubber-band at the top of the timeline in dark mode.
Sync html/body backgroundColor (and color-scheme) with the active theme
in the same effect that already syncs the theme-color meta tag.
Web + web-mobile: detect a brand-new user's own empty profile (own
profile + 0 scrobbles) and replace the empty/ugly tabs with a guided
onboarding experience, plus a first-steps modal that auto-opens once.
- New Onboarding components (@emotion/styled): NewUserGuide inline hero +
action cards, OnboardingModal (web) / OnboardingSheet (mobile), and a
thin dismissible WelcomeBanner on the web home page.
- Get-started steps: Connect a scrobbler (/mirrors), Import history
(docs.rocksky.app/cli/import, new tab), Scrobble from apps (/apikeys),
Upload music (/library/upload). Spotify intentionally excluded (beta).
- Robust own-profile detection: match viewed profile against
localStorage "did" OR the logged-in profileAtom (did/handle), and
persist the logged-in did to localStorage in useProfile so detection is
stable across the Main-layout remount on every navigation.
- Fix: Follow button no longer shows on your own profile (web now gates
on isOwnProfile instead of a raw localStorage compare).
- apikeys pages: add help link to docs.rocksky.app/integrations.
- Upload screen: "Stream it anywhere" note — uploads play via any
Navidrome/Subsonic client or @rocksky/cli, with docs links.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The NavTab.icon type `ComponentType<{ size?: number; strokeWidth?: number }>`
was too narrow: Tabler icons type `size` as `string | number`, and React's
propTypes variance makes them unassignable to it under `tsc -b`. Widen `icon`
to `ElementType`, which accepts both the Tabler icons and the custom BellIcon.