The bridge to everything else
v0.5.5.3It's as simple as:
# Get current track info (just open in browser)
http://127.0.0.1:8080/nowplaying
# Get album artwork (returns image)
http://127.0.0.1:8080/nowplaying/artwork
# Browse all albums in library
http://127.0.0.1:8080/library/albums
# Skip to next track
curl -X POST http://127.0.0.1:8080/player/next
Try it now: /nowplaying • /nowplaying/artwork • /library/albums
Web-based remote control with integrated library search. All actions use POST-Redirect-GET pattern. Supports keyboard shortcuts: / (search), Space (play/pause), ←/→ (prev/next), ↑/↓ (volume ±1%), Shift+↑/↓ (volume ±5%), M (mute).
Full web dashboard with library search, now playing, controls, volume, ratings, shuffle/autodj/repeat toggles. Panel visibility, order, and collapsible grouping are configurable via dashboardLayout in settings.
Search: client-side fuzzy album/artist matching + server-side track search. Albums containing matching tracks are boosted (e.g. “wonderwall” surfaces its album). Inline track expansion on album results. Explore button seeds explore.html?album=&artist=.
volup/voldown - Adjust volume by 5%.
volup1/voldown1 - Adjust volume by 1% (fine control).
shuffle=enable, shuffle-off=disable both, autodj=start AutoDJ, repeat=cycle mode
love - Toggle love tag on current track.
rate/0 - Toggle bomb (don't play). Click on → click off.
rate/0.5-5 - Toggle star rating; 0.5 increments accepted (e.g. 2.5). Click to set, click same to clear.
ban - Ban track from shuffle, skip to next (undo: skip back within 10s).
setban - Set ban flag without skipping.
love and rate answer two audiences from one route.
A browser posting the no-JS dashboard’s <form> (its Accept carries
text/html) gets the 303 → /dashboard?msg=… it always has.
Every other caller — fetch/XHR, whose Accept is */* — gets JSON:
200 { result, message } on success, or an error envelope on refusal with a code you can branch on:
LIBRARY_TAGS_READ_ONLY (403, a configuration that will still be refusing tomorrow — the message
names which of apiReadOnlyMode / apiReadOnlyLibrary / apiReadOnlyLibraryTags is
responsible), PARTY_LOCKED (403, passes when the party ends), NO_TRACK (409),
NOT_FOUND (404, the feature is switched off). Gate the affordance ahead of time on
libraryTagsWritable from GET /system/features; treat these codes as the authority in the moment.
mood - Set AutoQ mood channel (form: mood=Energetic).
react/fire|heart|like|dislike - Submit reaction. Fire triggers queue refresh.
refresh-queue - Refresh vibe list and queue tracks.
influence/artist|genre/up|down - Thumbs up/down for artist or genre.
Play or queue a playlist (form: playlistUrl=...&action=now|next|last).
Dashboard layout, display toggles, and feature kill-switches are all configured in mbxhub.json. Full field list with defaults, types, and descriptions: GET /system/settings/schema or the config reference. Live feature state (except proxy) is exposed via GET /system/features. Section IDs for dashboardLayout.order and .hidden: status, search, nowplaying, rating, controls, volume, charms, mood, playlists, toggles, footer.
Now Playing Styles (nowPlayingStyle, default: "full"):
| Style | Description |
|---|---|
full | Standard layout with full-size artwork and metadata |
horizontal | Side-by-side artwork and metadata |
noart | No artwork, text-only display |
split | Two-column grid (art left, metadata right). Column ratio configurable via localStorage mbxh_split_ratio (default 55, range 40–70). Falls back to stacked below 480px |
immersive | Full-bleed album art with gradient overlay and blurred letterbox fill for non-square art. Metadata fades on hover/touch, fades out after dashboardLayout.immersiveFadeDelay (0 = always visible). Set dashboardLayout.immersiveAlwaysShowProgress: true to pin the seek bar at full height regardless of the chrome fade. |
Client-side override: localStorage key mbxh_np_style overrides the server default per-device. Header button cycles through all styles. All styles have zoom-level overrides (67%, 50%, 27%). The compact style was removed in v0.5.2.3 — existing configs gracefully fall back to full.
MBXHub can serve custom HTML pages from a configurable directory. This enables building custom web UIs that use the REST API.
/pages/ with correct Content-Type headers.
Default pages are extracted on first run and can be customized without losing changes on updates.
Set pagesPath in mbxhub.json to customize the pages directory:
{
"pagesPath": "C:\\MyCustomPages"
}
Default location: %APPDATA%\MusicBee\MBXHub\pages\
Set defaultPage to change the root URL redirect:
{
"defaultPage": "/pages/player.html"
}
Options: /dashboard (default), /pages/player.html, /pages/play.html, /pages/partymode/, /pages/nowplaying.html, or any custom page.
Kiosk Mode: Lock the display to a single page/app. All navigation redirects to the default page.
{
"defaultPage": "/pages/partymode/",
"kioskMode": true
}
Multi-page apps work: sub-paths are allowed (e.g., /pages/partymode/ allows /pages/partymode/guest.html). API calls and resources still work. Only editable in mbxhub.json (not exposed via API).
Serves index.html from the pages directory
Serves any file from the pages directory (HTML, CSS, JS, images, fonts)
/pages/index.html - Landing page listing available views/pages/player.html - Legacy (kept for bookmarks and default-page users; receives no new features — use /pages/play.html). Full-featured desktop player with:
/pages/nowplaying.html - Focused now-playing view with artwork, lyrics, and queue. Auto-stacks below 800px with accordion sections (Now Playing, Queue, Lyrics) — expanding one collapses the others/pages/hud.html - MBXHub HUD: a compact now-playing / queue / browse / reactions page. Works standalone in any browser, and is the page the MBXHub Shell's Overlay hosts (on by default; summon with Ctrl+Shift+M; toggle from the Shell tray Settings → Enable Overlay or overlay.enabled in mbxhub-shell.json, where overlay.hotkey / overlay.startPage also live). theme.overlayThemeMode picks which theme slot the Shell-hosted surfaces use — the HUD, the HUD’s settings window and the Charm Bar, always the same slot (0 = follow the active mode); the HUD re-reads the theme on every summon, so a theme change lands without a restart. In a narrow overlay the Ctrl+K palette temporarily widens the window while open. See the Overlay section for what a page needs to do to behave well when hosted. /pages/overlay.html is an alias. Browse landing: the HUD remembers the last Browse choice between Library and Slices — the last press on the view switch, per HUD — and reopens it. hud.browseDefault is auto (the default), library or slices. auto lets the device decide: a phone lands on Slices, a desktop on the library. library or slices forces that landing on every device. The class is detected, never configured — the Shell's overlay window is a known desktop, and a browser is read from its viewport width, which is what the browser's own Request desktop site moves. Only those two: the Mixer is use-case specific and Stations is less known, so pressing either does not overwrite the memory and neither can be set as the default. Mixer and Stations are registry views on that switch — overlay-placement entries in their own manifests, like Music Map and Slices — so hiding either in Charm Manager (charmBar.hidden) removes it from the switch; Library is built in and always present. Immersive is unchanged — whatever fills the frame opens full-window exactly as Browse does. Close box: one button, drawn by the page, pinned to the top-right corner in every size and in immersive. There were two before — the Shell drew one in a 24px band at full size and the page drew its own inline in the compact bar, so cycling size moved it, and immersive had none at all. The Shell now draws none for the HUD and keeps no band, returning that 24px to the window as height. Hidden outside the Shell: a page in a browser tab cannot close its own window. Immersive browse: pressing Browse while Browse is already the current view hides the HUD’s own chrome — the target bar, the Services block, the now-playing strip and the view selector — and gives the whole window to the embedded library. browse.html is unchanged. The ⋮ menu carries the same entry. Two ways out, both always there: a persistent round control at the bottom-left and Esc. It is a real control rather than a hover reveal because on a device this view is touch-first, and it never survives a change of view. Metadata links: on the Playing view the artist and the album are links — they have carried the accent since the sentence was built, and now they act on it. Each opens the HUD’s own Browse frame drilled into that artist or that album, the album carrying its album artist so a title two artists share opens the right one; an empty value is not a link. browse.html gained the matching half: a navigate message arriving before that page has finished loading is held until it is ready, rather than drilling underneath the tab render that follows — which applies to every sender of that message, the Ctrl+K palette and the dashboard search overlay included. Listen Here in the HUD: one output with configurable placement: title bar (default), Services, or neither. hud.listenHerePlacement supplies the system default; HUD presentation can override it on this device. Controls remain full-mode only; nothing is added to the compact bar. Volume in the title bar: the speaker, the − / + steppers beside it (shown on touch screens), the level and the fader name are one cluster, shown or hidden together by hud.volumeControls (shown, the default, or hidden), with a per-device override in HUD Settings; hidden, the title bar re-fits without them. Dragging the speaker left or right steps the mixer volume only while hud.volumePull is on, and it is off by default. With the pull off, a tap on the speaker still mutes, and the − / + buttons and the arrow keys on the focused speaker (Tab to it; a mouse click does not focus it) still step the volume. Extra transport button: hudExtraTransportButton is the hub’s default; each HUD screen can choose its own in HUD Settings (Extra Transport Button, a charm or Hidden): Apply to This device keeps the choice in a cookie on that screen (mbxh_hud_extra), All devices changes this hub value where the hub accepts settings changes from that screen, and Follow the hub clears the screen’s own choice; a change is drawn the next time that HUD loads. Cast is the MBXHub Shell’s, not the browser’s: the Shell casts MusicBee’s queue, from the current track on, to a Google Cast receiver, and the cast outlives the page. The HUD places Cast independently using hud.castPlacement (titlebar, services, neither), with a per-device override. play.html and the dashboard carry the same control (components/cast-remote.js). It is shown while the hub’s casting is on (cast.enabled) and hidden entirely when it is off. With nothing cast, pressing it lists the receivers from /cast/receivers with a Rescan row, and picking one starts the cast; while a cast runs the button is lit, and pressing it opens play/pause, previous, next, stop, seek, volume and mute for the receiver. It needs the Shell running on the hub’s computer (without one the control says so in one line) and remote connections on, because the receiver fetches each track from the hub’s LAN address. On the rolled-up strip, the Cast button opens the same control before or during a cast without unrolling the HUD. Shell opens its Cast window; browser hosts open the anchored panel. Mixer content keeps clear of the bottom transport controls. See Cast for the routes. The output switch: speakers (room) ↔ browser (this device), shown when /system/features says streaming is on. The same output layer play.html runs (components/listen-here.js): flipping to browser takes the room’s current track over onto the HUD’s own audio at the room’s position and pauses the room; flipping back pauses here and resumes the room; while on browser, Play on selected output in the HUD’s browse pane and its queue verbs drive a local queue. The room is paused at takeover; later room playback is left alone, so both outputs may sound until a listener stops one or changes output. The HUD follows the owned track for title, artwork and progress; stars and hearts are read-only during independent local playback. Volume controls are labeled Mixer volume and name the resolved mixer target. In Browse, Playback requested acknowledges the request, not receiver playback. Mixer network entries select the endpoint fader; This device plays in the browser. Feature reads are shared and refreshed on foreground after 30 seconds or on retry, without periodic discovery. Remembered per HUD (localStorage["mbxhub-hud-output"]), separately from play.html’s.
/pages/browse.html - Library browser with 8 responsive tabs, drilldown navigation, fuzzy search with auto-search fallback (triggers server search when no inline matches), album art grid, video direct play, batch queue, and playlist picker. Framed, the row queue verbs (Play now / Next / Last) go to the host page as mbxhub.frameAction messages only once the host has answered the frame’s ask ({ frameHostQuery: true } up, { frameHost: true } back; play.html, the HUD, the dashboard and player.html all answer). Until then a verb waits one beat and calls /queue/* itself, so a page of your own that frames browse.html without answering still gets working rows — with the frame’s own standing, not the host’s/pages/config.html - Settings and configuration for dashboard layout and AutoQ parameters/pages/autoq.html - AutoQ Tuning Console with mixer-style sliders for scoring weights and normalization ranges/pages/autoqworkbench/ - AutoQ Workbench — five single-purpose pages that make up one workbench. Each is reachable on its own and each is an entry on the autoqworkbench charm. The charm is not device-specific, so it ships visible. No page adds a REST endpoint — all five ride existing surfaces:
/pages/autoqworkbench/faders.html - Tunings mixing console: vertical faders for scoring weights (7), reaction scores (5), influence scores (4), and normalization ranges (14), grouped exactly as GET /autoq/settings groups them. 400ms-debounced PUT /autoq/settings of the whole settings object, save indicator judged from the response, Reset-to-server refetch. The normalization rack goes inert while percentile normalization is on (same treatment as the Tuning Console)/pages/autoqworkbench/live.html - ON-AIR: current track with artwork, a Valence/Arousal plot carrying the 12 mood channels and the target ring, a Camelot ring lighting the harmonic neighbors, reactions (fire / heart / downvote inline, upvote / ban in overflow), and Mode via the shared radio strip. 5s poll over /nowplaying, /autoq/radio, /autoq/track-mood. The plot’s Library view is the music map (/autoq/mood-map) with a Slice by control; in Slice by: Mood the lit moods are named under the map and on the selector, and a click lights only the mood of the region under it - once that mood is the one lit, a click on its star opens the card. A fullscreen button fills the screen with the map and its slice controls; in a Shell window it asks the host (overlay.fullscreen). The queue-order why-trail is not here — that trail is internal to the WinForms Workbench and has no REST surface/pages/autoqworkbench/builder.html - Builder queue: search the library, stage tracks locally, Apply pushes the whole stage as one POST /queue/add. Apply is the only queue-write verb; Clear empties the screen only (two-tap armed); Reload is user-driven and nothing auto-refreshes. The queue below renders read-only with a current-row pill/pages/autoqworkbench/stations.html - Saved stations: flow and seed count per row, Play, inline rename (Enter commits, Escape reverts), two-tap-armed Delete, and “Save current run” via POST /autoq/stations/from-run. There is no name-only create — a station needs seeds, a journey or a ring/pages/autoqworkbench/programs.html - Saved programs (sequences of stations): entry count per row, Play, inline rename, two-tap-armed Delete. Management only — creating a program needs at least one resolving entry, so programs are created elsewhere and managed here/pages/mixer.html - Unified fader mixing surface: three independent faders for Player (MusicBee), Device (Windows audio), and Endpoint (network speaker) volume. Configurable default fader, mute controls, endpoint source selection. An Output picker chooses where the sound goes: Speakers (always present — it is the way back), This device (Listen Here, when streaming is available), then each configured endpoint; picking an endpoint stores mixer.defaultEndpointId. In its own window the page holds the Listen Here audio itself. Framed — inline on the dashboard, in the HUD’s charm frame, or in play.html — the <audio> element belongs to the host page, so the mixer asks the host to set its own output with a mbxhub.hostLocal message ({ type, verb: "listenHereSetOutput", mode: "browser" | "speakers" }, or query: true to ask without changing anything) and the host answers with mbxhub.hostLocalState. Every host answers from one implementation, ListenHere.attachHostLocal(output) in components/listen-here.js; a page of your own that frames the mixer must call it, or This device is never offered. That is a separate channel from mbxhub.frameAction: a host-local verb names no route and lends the frame none of the host’s standing, and only verbs on MBXShared.hostLocalVerbAllows are performed/pages/stations.html - Saved AutoQ stations, listed and started. Each row carries the station's flow, seed count, genre-free flag and when it was made; tapping one calls POST /autoq/stations/{id}/play. The header shows AutoQ's own state, because starting a station switches AutoQ on. Read-and-start only: rename, genre-free and delete live on the radio strip's stations menu, and building a station is a Workbench verb. Reached from the HUD's Services row (Stations) or as its own charm window/pages/matrix.html - OREI BK-808 HDMI matrix charm. Audio extraction leads: pick which input feeds the external audio out and the SPDIF mode (bind-to-input / bind-to-output / audio matrix). Below it: per-output video routing with editable input/output names (browser-local), route-to-all, scene recall/save, power. Control rides POST /api/proxy to the matrix's /cgi-bin/instr HTTP API — private-LAN only, the browser never contacts the device. Shown by default — hide it under Settings → Dashboard → Hidden charms if there is no BK-808 on the LAN; the page itself is always reachable at this URL/pages/explore.html - Album art explorer: browse albums as artwork with source filters, search, sort, image gallery, PDF booklets, play/queue. Accepts ?album=&artist= for seeding. Artist discography grid in expanded view. Expanded-view hero has paired close (×) and dashboard home (⌂) buttons — close stays on explore, home returns to /dashboard (home hidden in iframe-mode)/pages/play.html - Use MusicBee from a browser. Laid out like the MusicBee AMOLED skin: artist picker (left) with infinite-scroll + jump-on-letter typeahead, pluggable middle pane (Albums / Library / Now-Playing), upcoming-queue + now-playing card (right), full-width transport footer.
/influences/currentmbxhub.log via /system/client-log (no console-only logging)/pages/arc.html — Arc, the waypoint run builder. Pick a start, an optional middle and an end, and the run fills in between. Each stop carries a ring — a percentile radius that decides how wide that stop’s “like tracks” neighborhood is, so dragging it visibly grows or shrinks the candidates rather than being an abstract number. Candidates are ranked on the same continuity blend AutoQ picks with, and every row shows why it is close (chroma agreement, the key move, close mood, similar timbre, similar dynamics) alongside the raw blend distance — or a tags chip when a track has not been analyzed yet and the score is tag affinity alone. The mood plot places the stops you have set and draws the arc through them; it is a projection of a much wider comparison, so the list order is the authority and the page says so. Choose a stop by search, from Now Playing, or by picking out of another stop’s neighborhood./pages/switcher.html — the View Switcher: Alt-Tab for overlay sets. Summoned with Ctrl+Shift+S (overlay.switcherHotkey); a transient centered list of saved sets — ↑↓ pick, Enter applies, Esc cancels without applying; Save current… and Snap back home below. Each set can carry its own summon chord, bound in the tray’s Hotkeys dialog (one row per saved set) and stored on the set record, so pressing it applies the set from anywhere; the switcher shows the chord beside the set. Save current… captures every open overlay window — including the Charm Bar when it is up: the bar is content, not chrome, so a set that lacks it closes it on apply (want it uncaptured? close it before saving). The HUD alone lives outside the set system and survives every apply. Shell only in practice./pages/rail.html — The desktop Charm Bar, summoned by Ctrl+Alt+B or Shell tray Settings > Charm Bar. Configure every item and its order in Settings → Charms → Desktop charmbar, in either MusicBee or the web editor. MusicBee, app launchers, HUD, saved sets and charm windows share one configurable list. Hiding a button does not disable its action. Each charm can declare presentationDefaults for dashboard, desktop, and hud; choose Default, Show or Hide independently. The bar follows the selected hub and caches host-button preferences per target for failed loads. Arrow keys select, Enter activates, Escape dismisses; an all-hidden bar remains dismissible. Existing docking, sizes, resizing and window toggle behavior are unchanged. /charms/pages?for=rail supplies window members and a presentation object with ordered visible item keys; launchers remain local-only. Charm Manager permission controls remain in the MusicBee dialog./pages/tree.html — Library Tree. Three-level tree (Album Artist → “Year – Album” → Track) with an A–Z scrubber rail, lazy-loaded albums and tracks (a level is fetched the first time it is expanded), a live filter box, and per-row Play / Queue Next / Queue Last / Send to AutoQ actions on albums and tracks. Works standalone or as a dashboard charm./pages/slices.html — Slices. The library as sideways rows of album covers. Key rows come from existing endpoints or a DSL formula — New, Never Heard, Favorites, Random (with a ↻ that refetches), Unrated, Top Rated, Recently Played, With Booklets, With Video, Playlists, Stations (a press STARTS the station — it has no contents until it runs) — and a group row expands one field into a row per value, largest first, from GET /library/slices: Genre, Year, Decade, Rating, Mood. Everything past the first slices.topN values of a group sits behind a More genres (N) line that opens a filterable list; picking a value from it pins that value as its own row (slices.pinned). A row’s header opens the whole thing as a grid, with Back restoring the scroll position. Mood drills down at ALBUM level — its rows refetch GET /library/slices?by=mood&preview=0 instead of running a mood: search, because mood: is a post-filter and a lone one hands MusicBee the whole library, which is over the post-filter cap (413). A cover opens the album sheet — a bottom sheet on a phone, a panel under the row on a desktop — carrying Play / Next / Last / AutoQ over the album and a per-track ⋯ menu (Play now, Play next, Add to end, Send to AutoQ); on a desktop a ▶ appears on a cover on hover and plays that album without opening anything. Rows… chooses which rows appear and in what order (drag, or Alt+↑/↓), and carries an Apply to: This device | All devices choice. All devices writes slices.order and slices.hidden to the hub, and from a device that had a layout of its own it writes slices.pinned too and then removes that device's stored layout — the store is one record, so handing it over hands the pins over with it, and the device follows what it just published rather than publishing rows it cannot see. This device keeps the layout — order, hidden and pinned — in this browser’s localStorage under mbxhub-slices-rows and sends the hub nothing; a device with no stored layout follows the hub’s, and Follow the hub removes the stored layout. Where the hub will not accept a settings change from this caller (disableRemoteConfig), All devices is shown disabled with the reason. A More… pin has no dialog and does not follow the dialog’s last Apply to position: it goes to the device’s own store while the device has a layout of its own, or while the hub will not accept a settings change from this caller — and pinning is then the moment that device starts keeping a layout of its own, which Follow the hub gives up — and to the hub otherwise. A pin or an unpin the hub refuses is put back on screen and the page says it was not saved. Rows are picked and ordered in the page; the row formulas and the row numbers (slices.rowQueries, slices.topN, slices.rowLength) are always hub settings. Ships visible as the slices charm./pages/wiim.html — WiiM streamer control charm: transport (prev / play-pause / next, seek), volume + mute, source selection, shuffle/repeat, and now-playing readout, plus collapsible cards for the equalizer (on/off, 24 named presets, full 10-band custom curve), the device’s saved presets grid, and device status (output mode, casting flag, remote battery, writable channel balance). Every device call is relayed through POST /api/proxy — the page never fetches the WiiM directly, because the device is HTTPS-only and a browser on a plain-HTTP page cannot reach it. Writes are confirmed by read-back, never by the device’s OK. Shown by default — hide it under Settings → Dashboard → Hidden charms if there is no WiiM on the LAN; the page itself is always reachable at this URL./pages/streamsdk.html — StreamSDK device charm, for streamers built on the StreamUnlimited StreamSDK platform — the first is the Fosi Audio S3. Scan discovers candidates via POST /devices/endpoints/scan?protocol=ssdp and identifies them by probing the StreamSDK API through the proxy, so no addresses are typed; confirmed devices are kept as a browser-local tap-to-switch list and the most recent one reconnects on load. Devices keep their SSDP-advertised name (“Fosi S3”, not a bare IP) with the model as a subline, and a row remembered before a scan learned the name upgrades in place. While connected, a Device web client link opens the unit’s own web UI at /webclient/ on port 80 — an S3-class feature; LinkPlay/WiiM units have no equivalent. Action rows in the settings tree carry a play glyph (▶) that triggers the node. The hero carries now-playing, transport verbs taken from the device’s own per-source controls map, volume and mute, and the physical inputs (Bluetooth, Line In, HDMI, Optical) — network sources are receivers, selected by the sender, so they are shown as status only. Below it the device’s settings tree is rendered generically from getRows: toggles, sliders, enum pickers, text fields, action buttons, forms, nav rows and read-only values, plus nine one-tap equalizer presets over the ten bands and an allow-listed Extras section. A capability filter drops platform controls the unit does not have and fails open, so an unmapped path still renders. Four paths — firmware update, factory reset, the network wizard and the password change — are refused in nsdk.js before any request is made. Every device call is relayed through POST /api/proxy, and every write is confirmed by read-back rather than by the device’s response code. Shown by default; hide it under Settings → Dashboard → Hidden charms if there is no such device on the LAN./pages/cast.html — the Cast Charm: the one Cast control on a page of its own, so the Charm Bar carries a cast icon. Press it for the speakers on the network, pick one, and MusicBee’s queue plays there; while a cast is on, the page shows what the speaker is playing with transport, position, volume and mute. The same control the HUD, Play and the dashboard mount, backed by the /cast/* routes the Shell answers, so it works hosted in the Shell’s own window as well as in a browser. Shown by default; hide it under Settings → Dashboard → Hidden charms./pages/media.html — Full-bleed image/video viewer over the configured media folders (also reachable as /pages/media): auto-rotation with crossfade, video playback with seek and volume, chrome overlay with media-type + category selectors, shuffle, a filterable file list, fullscreen, and keyboard (arrows, space, F) / touch-swipe navigation. The address bar names the file on screen (?mode=images&cat=<folder>&file=<name>; mode=library&file= for library videos), so that address reopens on it; the link button copies the file's own URL./pages/settings.html — Settings editor rendered from GET /system/settings/schema: category and tier filters, search across all fields, and per-field save. Blocked when disableRemoteConfig is true or while a party is running./pages/components/dashboard.css — Dashboard stylesheet (~2,900 lines). Served as an external linked resource by /dashboard. Theme variables are injected separately in an inline <style> block ahead of this link; this file holds the bulk of dashboard styling (layout, transport, charm bar, command palette, theme designer, party banner, search results). Linked with ?v={version} for release-driven cache invalidation./pages/components/dashboard.js — Dashboard client script (~2,800 lines). WebSocket lifecycle, transport handlers, theme designer, charm bar, partial-section reload, Cmd+K wiring. Loaded with defer by /dashboard after an inline <script> that defines the per-request _themeData JSON. Same ?v={version} cache-busting pattern./pages/components/cmdk.html — v0.5.3.0 Cmd+K command palette. Self-mounting <style> + <dialog> + <script> fragment. XSS-safe (textContent only, no untrusted innerHTML). Federated search via /search, recents via /search/history with localStorage fallback. Actions are POSTs (Play/Pause/Skip/Volume, Start AutoQ radio) or navigations (Charms / Settings / Mixer / Player / Browse / Explore / ARiA). Settings nested under dashboardLayout.commandPalette.{enabled, openShortcut, showChip, recentLimit, bucketLimit, actions}./pages/components/cmdk-bootstrap.js — v0.5.3.0 palette loader. Drop-in <script> include — loads search-shared.js then injects cmdk.html via DOMParser so embedded scripts execute. Self-mounting; degrades gracefully when search-shared.js is unreachable. Wired into dashboard, play.html, explore.html, nowplaying.html, browse.html, and hud.html./pages/search-shared.js — Search lifecycle helper. Provides MBXSearch.attachSearch for debounced typeahead with abort-on-keystroke (exposed as .abort() on the returned handle for v0.5.3.0+ callers that need to cancel in-flight fetches before navigation). Required by cmdk; usable standalone./pages/components/shared.css — v0.5.3.0 shared frontend core. Cross-page styles consolidated from per-page duplication: CSS reset + box-sizing, prefers-reduced-motion rules, focus / focus-visible defaults, dual theme palettes (default + theme-quiet, light + dark), scrollbar styling, .album-art-placeholder, .hl highlight class, .empty-state. Linked first in each shell page's <head> so per-page styles can override./pages/components/shared.js — v0.5.3.0 shared frontend core. Cross-page helpers under the MBXShared global namespace: setPageName / getPageName, clientLog (batched + sendBeacon-on-pagehide) + flushClientLog, esc (HTML-encode), DIACRITICS (char map ported from MusicBee's character map — union of the browse / explore / player maps, 1:1 char invariant preserved), normalize (iter-based: DIACRITICS lookup → lowercase → strip apostrophes → collapse non-alphanumerics to single spaces → trim; NFD form removed in the Option C foundation), findHighlightSpans(text, query) (returns Array<{start, end}> half-open ranges in ORIGINAL-text index space — walks normalized text, finds full-phrase + per-word occurrences, leaves HTML rendering to the caller so pages can wrap spans their own way), fmtDuration (ms → m:ss or h:mm:ss), connectWebSocket (subscribe + JSON parse + dispatch-by-event + auto-reconnect with capped backoff), urlHashOf(url) (22-char base64url SHA1-truncated hash of a file URL — used for /library/file/{urlHash}/* routes and ?trackUrl= deep links; byte-equivalent on secure and non-secure LAN-HTTP contexts). Loaded with defer by every shell page./manifest.webmanifest — v0.5.3.2 PWA manifest. Returns application/manifest+json. The install identity is id: "/" — Chromium keys an installed app on that value, so it never changes; a change would fork every existing install into a second app. Both name and short_name are templated with the host's advertised name — the discoveryName field in mbxhub.json, set via Plugin Settings → “Advertise on local network → Name”. The advertised name is independent from the machine's hostname: a host whose Windows computer name is living-room-pc but whose advertised name is “prod” serves name: "MBXHub - prod" (browser install dialogs / Manage apps UI) and short_name: "MBXHub - prod" (OS desktop / home-screen / app-drawer labels) — “living-room-pc” never appears in the install UI. If discoveryName is empty, the manifest falls back to the Windows computer name, and ultimately to the literal "host" if even that is unavailable. Trade-off: changing the advertised name updates both fields on the next manifest fetch — already-installed devices see the new label after one reinstall or manifest refresh. Standalone display, theme color matches the dashboard surface, icons array references /icons/icon-{192,512}.png plus maskable variants. Served with Cache-Control: no-cache (Chromium's installed-PWA “update on reload” path otherwise heuristically caches the manifest indefinitely). Note: PWA install itself is gated by browser policy to HTTPS or localhost; over plain LAN HTTP the manifest still drives iOS “Add to Home Screen” and Android home-screen pins (no service worker required for those), but the rich Chrome/Edge “Install as app” surface is unavailable. display_override asks for window-controls-overlay (Chromium installed windows: Edge, Chrome): the OS title bar is not drawn and the dashboard’s own header becomes that strip — wordmark, live hub status and a state line (play/pause glyph, “Title – Artist”, eliding; nothing when stopped) on the left as the drag region (passive text keeps the window grabbable), the icon cluster on the right as the one no-drag island beside the OS buttons — so “MBXHub - name” is shown once, not by the title bar and again by the page. Firefox and Safari ignore it and keep the header row. Shortcuts: the installed app’s jump list (right-click the taskbar icon) offers Now Playing, Play, Browse, HUD and Slices. Opening a hub link while the app is open focuses that window and navigates it (launch_handler navigate-existing). With the app’s Open supported links switch on (edge://apps or chrome://apps, per app), links the Shell opens from the tray land in the app window; off, they open in the default browser. Firefox has no installed app; the browser tab is the floor./pages/history.html — v0.5.3.2 Recently-played viewer. Day-range chips (24h / 7d / 30d / etc.) sourced from GET /library/recent?days=N. Track list shows title / artist / album / albumArtist plus playCount + skipCount columns; artist and album cells are clickable links into /pages/browse.html. Per-row P (Play Now) and L (add to MusicBee queue) buttons. Page-2 prefetch as the user scrolls. Backed by the /library/recent cache (10-min TTL, invalidated on TrackChanged). Footer link disabled by default./pages/hubs.html — v0.5.3.2 Hub-switcher page. Reached from the dashboard’s gear menu (the Hub row names the current hub; its Switch… link opens this page) and the footer. Renders the “Hub neighbors” list — other MBXHub instances this browser has reached recently, read from localStorage['mbxhub-hub-neighbors'] — plus a free-text host:port input fallback. Pure DOM, no innerHTML, no remote calls. Footer link disabled by default./icons/{name} — v0.5.3.0 PWA icon set. Resolves the embedded MBXHub.Resources.icons.{name} and serves with image/png. Standard set: icon-192.png, icon-512.png, icon-maskable-192.png, icon-maskable-512.png, apple-touch-icon-180.png, plus favicon.ico and favicon.png. The browser-chrome paths /favicon.ico, /favicon.png, /apple-touch-icon.png and /apple-touch-icon-precomposed.png are served from the same set, so every hub page gets the mark in its tab without linking an icon. The last is the older iOS spelling and answers with the same 180px image as /apple-touch-icon.png.To customize pages:
player.html or create new HTML filesMBXHub serves /llms.txt - an AI-friendly API reference. Use it with Claude or any AI to generate custom pages:
/nowplaying, /player/play) that work on any MBXHub instance%APPDATA%\MusicBee\MBXHub\pages\http://127.0.0.1:8080/pages/partyon.htmlThe generated code uses relative URLs, so it works on your local MBXHub without modification.
The MBXHub Shell can host any hub page in a borderless, always-on-top window over
whatever you are doing — including a full-screen game. /pages/hud.html
is the page it hosts by default. This section is what a page needs to know to behave
well there. A page that ignores all of it still works; it simply behaves like a browser
tab in a window with no browser, which is the wrong thing in every specific way.
Chosen by the user, never inferred from width: full (the whole page),
player (a wide short bar) and transport (buttons only). This
is a different axis from a narrow-width media query — a narrow full
window is still full. The frame locks the height in both compact views and
caps the width so a bar can never fill the screen. Do not fight either: design the row
to shed.
The window can be dragged as narrow as the user likes. Drop the least important control
one at a time until the row fits, and keep every escape hatch — whatever changes
the view, whatever closes the window, and the primary action. Detect overflow by
measuring a child’s RIGHT EDGE: scrollWidth does not grow for flex
children spilling out of an overflow: visible container, so it will
cheerfully report that nothing is wrong.
The window’s URL is composed from a host and port the user picks
(overlay.target, or the Shell’s own hub when empty), so every fetch
written as a relative path goes to whichever hub served the page — same origin, no
proxy, no CORS. Never hard-code a host. A page may be looking at a hub on another
machine and cannot tell by itself, so if WHICH hub matters to what you show, say so on
screen.
The window is warm-hidden on dismiss so recall is instant — which means your timers
keep running and a hidden page keeps costing. The host sends
overlay.visibility: stop on hidden, and poll ONCE immediately on show
rather than waiting out the interval.
The convention hud.html follows: dim after two consecutive misses (the last
frame is still roughly true), go dark after four with a single dot as the only live
control, and recover automatically on the first good answer. No dialog, no toast, no
red — the usual cause is the user closing MusicBee on purpose, and several windows
each raising an error for one event reads as several problems.
Host to page, via a message event on
window.chrome.webview:
overlay.modeChanged with mode — authoritative.
Render this, not what you asked for.overlay.visibility with shown — see above.overlay.drawer with active, edge,
out and pinned — the drawer’s state, and
authoritative in the same way. Draw the chevron from out and the
pin from pinned, never from your own click, or the control ends up
claiming a state the window is not in.Page to host, via window.chrome.webview.postMessage:
overlay.mode with mode — ask to change view. The
host answers with overlay.modeChanged; treat your request as pending
until it does.overlay.expand with width, and
overlay.restore — widen temporarily (the command palette does
this) and put it back. Always restore.POST /overlay/window/{id}/fullscreen on the Shell bridge, body
{"on":true|false} (absent means true — an empty POST to
this route is a request to go fullscreen). hud names the Shell’s own
HUD window, the same id the Charm Bar already uses for it; any open charm window is
addressable by its own id. This is the only bridge route that reports what HAPPENED
rather than what was accepted — 404 when no such window is open,
501 when the Shell has no handler wired — because open and close
can be checked afterwards by looking at the window, and a fullscreen that went
nowhere leaves nothing to ask.overlay.fullscreen / overlay.fullscreenExit — take
the whole monitor, and give it back. A page in a browser tab calls
requestFullscreen; hosted it cannot, because the window covering the
screen is a host act. The host answers with
{type:"overlay.fullscreenChanged", on:bool} and the page paints
THAT, never its own click. The monitor is the one the window is already on, and its
full bounds rather than its work area, so the taskbar is covered. A drawer comes
fully out first and its watch and clip are dropped for the duration — a
resting drawer paints only its thumb through a window region, and a fullscreen
window wearing one is invisible. Escape leaves fullscreen before it can dismiss
the window.overlay.dragStart — begin a window drag from a page surface.overlay.ownsChrome — the page draws its own close box, so the host draws none and keeps no band for one. Declared rather than inferred: OverlayViews.For falls back to Normal, so every charm window resolves to mode full and a mode test would read it as page-chromed, leaving charm windows with no way to close. A page that sends nothing keeps the host button.overlay.openMusicBee — open-or-focus the LOCAL machine’s
MusicBee (the Shell’s own tray verb). A host action, honored only where the
host wired it (the Charm Bar); deliberately not a generic launch-anything verb.overlay.close — dismiss.overlay.contentHeight with height — ask the host
to make the window as tall as your content, in CSS pixels. Height only: a page
that could set both dimensions could size itself to the whole display. Measure
your CHILDREN, not a container that is already height:100% —
asking that element how tall its content is answers “however tall you
already are”, which is a rail that can only ever agree with itself.overlay.contentSize with width and height —
the two-axis form, for a page the host docks as a bar. The host honors ONE of
them, the bar’s long axis (height for a vertical bar, width for a
horizontal one), and ignores the other, so the same rule holds; send the axis
you do not mean as 0. A docked bar is told its side by an
overlay.barSide echo (side, horizontal),
re-sent on every navigation and show like the other echoes; until it arrives,
lay out as a column.overlay.openWindow with windowId, page,
anchor and optional additive — ask the host to show
another hub page as its own window, growing out of the anchor. The anchor is in
YOUR coordinates (CSS pixels inside your viewport) and the host converts, because
only it knows where its window sits and what the monitor scale is. A page that
could name absolute screen pixels could anchor a window anywhere on the desktop.
It toggles on VISIBILITY: a window on screen closes, one that is not (never opened,
or dismissed by its own close button) is shown. One at a time by default — a
plain ask closes every other visible window first, the asking bar excepted;
additive:true (Shift+click on the bar) shows it alongside.overlay.windowState with open (an array of window
ids) — the windows that are VISIBLE right now, sent on every
open/close/dismiss/reveal and again on every navigation and show. The Charm Bar
paints its marks from this and from nothing else.overlay.drawerPin with pinned — pin the drawer
open, or let it slide away again. Ask for the opposite of what the host last TOLD
you, not of what your button looks like; the button is an echo and may be
mid-update.overlay.drawerToggle — slide the drawer out, or back to its
edge.The host honors messages only from the origin the window was opened for.
window.chrome.webview is not proof of our host. It exists
in any WebView2 container — a Game Bar widget, for instance. Its presence tells
you a bridge exists, not that anyone is listening. Gate host-only affordances (the view
chevron, the close button) on having been TOLD something by the host, and degrade to a
plain page otherwise. hud.html keeps its chevron hidden until then.
The Shell substitutes the theme into the served HTML, so the page is correctly themed on
the very first frame. Do not wait for /system/theme to style the page —
fetch it to react to changes, not to paint. The window is rebuilt on some transitions,
so first-byte correctness is what the user actually sees.
In mbxhub-shell.json, alongside the Shell’s own settings:
overlay.enabled, overlay.hotkey (summon and dismiss),
overlay.cycleHotkey (next view), overlay.startPage,
overlay.target (host:port, empty follows the Shell’s own hub),
overlay.mode, the per-view rectangles overlay.bounds,
overlay.bounds.player and overlay.bounds.transport, plus
overlay.windows, overlay.combinedSplits,
overlay.engine.sets and overlay.engine.charmWindows.
Logging: logLevel (the Shell’s own level, default Info) and
followHubLogLevel (default true: the Shell follows the connected hub’s
effective level and logLevel is only the fallback before a hub answers; false
makes logLevel the Shell’s fixed level). The tray’s Logging menu writes
both.
Hotkeys and the target are editable from the Shell tray under Settings, and
Reset overlay layout puts the HUD at home without touching them:
Full view, centered on the primary monitor — deliberately not the first-run factory
placement. First-run is where a window you have never seen should politely appear; home
is where a window you have lost should unmistakably come back, and the center of
the primary screen is the one place on any desk that cannot be off-screen, behind another
monitor, or under the taskbar. Reset also clears the drawer (overlay.drawers).
MBXHub advertises itself on the local network via three protocols so clients can find it automatically.
Port 8080 is a default, not a guarantee. First run selects the first free even pair from 8080–8098; port 80 is never an auto-candidate (a privileged http.sys port needing a URL-ACL reservation to bind). Once chosen the port persists — a later conflict notifies rather than silently hopping. A client that hardcodes 8080 works on most machines and fails on the interesting ones. Four mechanisms, each covering what the one above it cannot:
presentationUrl, modelName: MBXHub, the friendly name and a stable UUID, and it enumerates every instance — a machine running several MusicBee instances returns several named hubs, which is the one case the other three answer ambiguously or not at all. Any language with a UDP socket; no OS-specific APIs. Unavailable when discoveryEnabled is false.netstat cannot answer this: every HttpListener port is held by the kernel and reports as System (PID 4). http.sys knows the owning application — Firebug.PortOwner.ForPort(port), or by hand netsh http show servicestate view=requestq, reading the Registered URLs block under a Processes: entry. The owning image's full path also yields the MusicBee install tree, which is what tier 3 needs for a portable install; the binding shape tells you the posture (+:8080 = bound wildcard, so the URL ACL is present; a localhost registration = loopback-only fallback). Live state — nothing is registered when the hub is down.mbxhub.json, top-level restPort: normal install %APPDATA%\MusicBee\MBXHub\, portable <MusicBee install tree>\AppData\MBXHub\, preferring the normal path. Also carries restEnabled, requireBasicAuth and allowRemoteConnections (default false when absent — a hub that has not run setup answers this computer only) — enough to turn “not reachable” into a specific diagnosis.GET /ping, requiring service: "MBXHub" in the response body. A port being open proves nothing — another process may hold it, and a URL-ACL reservation outlives the hub. Only the identity in the body is evidence.When nothing answers, stop looking for a hub and ask for one — the asking IS the request. The four tiers above answer “where is the hub”, and the hub lives inside MusicBee: with MusicBee closed there is nothing to find, and probing harder will not produce it. So while the hub is absent the Shell binds the hub’s own port and answers GET /system/uptime in its place — then releases the port and starts MusicBee. A client that already probes /system/uptime needs no code for this at all: the probe it sends anyway is the request to come up.
{"success":true,"data":{"startedAt":"…","uptimeSeconds":0,"uptime":"0s","standIn":true}}
standIn: true means the hub is not up yet, it is coming. Keep polling rather than treating that first 200 as connected; uptimeSeconds is 0 because nothing has been up, and the hub answers with its own the moment it takes the port back./nowplaying is deliberately not answered: a stand-in now-playing would have to invent a state for a player that is not running.smtc.enabled, and standIn.enabled./ and every page path answer a standby page (Start / Not now); API paths answer 503 HUB_STANDBY; only GET /system/uptime starts MusicBee. The Shell toasts once per outage when one of its own windows finds the stand-in where the hub should be: standIn.toast, default on, tray Settings → Standby toast.When no Shell is answering either, two records are written for you. Do not go hunting for a path. The first says where MusicBee is — %LOCALAPPDATA%\MBXHub\instances.json, plain indented JSON, one row per install:
{"instances":[{"id":"940f2d53","path":"C:\\MB3\\Plugins\\",
"version":"0.5.5.1","musicBeeExe":"C:\\MB3\\MusicBee.exe",
"firstSeenUtc":"…","lastSeenUtc":"…"}]}
musicBeeExe is the full path to the executable, not its directory: the consumer is a launch, so it is handed the thing it launches rather than a convention every reader has to know. One writer per row, so there is nothing to lock, and a run that cannot resolve MusicBee does not erase what an earlier run knew.
The second says where MBXHub is, and Windows already has it. Every run registers, and repairs, three things under the current user — which is also why a moved or upgraded install heals itself the next time it starts:
HALRAD.MBXHub.MusicBee, at HKCU\Software\Classes\AppUserModelId\HALRAD.MBXHub.MusicBee — the identity Windows files our toasts and SMTC session under;%APPDATA%\Microsoft\Windows\Start Menu\Programs\MBXHub.lnk, stamped with that AUMID. Launch this and you launch MBXHub exactly as a person does from the Start Menu, with no path of your own to keep correct;HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\MBXHub, whose DisplayIcon is the full executable path and InstallLocation its folder — the fallback when you need the file itself rather than a launch.Two caveats worth knowing rather than discovering: all three are written under HKCU, so read them as the user who runs MusicBee, not from a service account; and they are written only when the Shell’s SMTC feature is enabled, so their absence means “not registered” and never “not installed”.
Already running, and you only want MusicBee? The Shell answers on its own port — smtc.port in mbxhub-shell.json, default 8081, reported by GET /system/features as shellBridge.port once the Shell has announced it; only a Shell that has never written its config is assumed at the hub’s port + 1. GET /meta/app/start answers { ok, canStart, reason } with no side effects, so you can ask before drawing a button rather than firing the POST to find out and starting MusicBee on somebody’s machine as a side effect of drawing a list. POST /meta/app/start does it. Local callers always; remote callers only when the operator has enabled remote start.
Diagnosing a failure costs two local reads: no registration and no MusicBee process means MusicBee is not running; no registration while MusicBee is running means REST is not listening (restEnabled: false, or the bind failed); a registration with no answer means checking requireBasicAuth. Loopback clients need no firewall or URL-ACL step at all — loopback never traverses the firewall, and the localhost prefix needs no reservation.
DnsServiceRegister API.
Requires Windows 10 1809+; gracefully skipped on older systems. Registered as MBXHub (Name)._http._tcp.local with TXT records for path and version.
UPnP device description XML. Contains device info, service URLs, and presentation URL.
WS-Discovery metadata exchange endpoint. Windows sends a SOAP GetMetadata request after discovering MBXHub via UDP Probe; response includes PresentationUrl pointing to /dashboard.
| Field | Description |
|---|---|
| deviceType | urn:halrad-com:device:MBXHub:1 |
| friendlyName | MBXHub instance identifier |
| presentationURL | Dashboard URL (/dashboard) |
| controlURL | REST API base (/api) |
| eventSubURL | WebSocket endpoint (/ws) |
| Port | Protocol | Owner | Purpose | When needed |
|---|---|---|---|---|
| 8080 | TCP | Plugin | REST API + WebSocket (restPort) | Always — the plugin's main HTTP listener |
| 8081 | TCP | Shell | SMTC control routes /meta/smtc/* (smtc.port, default 8081 — the Shell’s own port, not derived from the REST port) | Only when MBXHub.exe is running. Open it for remote SMTC retargeting from the dashboard, or for Bluetooth/lock-screen control on a different machine. Skip it for plugin-only / NAS / headless installs. |
| 1900 | UDP | Plugin | SSDP (UPnP discovery) | For Windows Network folder + SSDP browsers |
| 3702 | UDP | Plugin | WS-Discovery (Windows Network folder) | For Windows Explorer Network integration |
| 5353 | UDP | Shell | mDNS: the Shell’s Cast receiver scan. The hub’s own mDNS announcement is served by Windows’ DNS Client service, under that service’s own rule, and needs no rule of ours | Whenever Cast is in use; advertising alone does not need it |
First run: on a fresh install the plugin claims the first free REST port, defaulting to 8080 and stepping up through 8082, 8084 … 8098 if one is taken; the Shell’s own port (smtc.port, 8081 unless set) does not move with it. Port 80 is never auto-selected — it needs a URL-ACL reservation, so set it manually only for intentional production use.
Plugin-managed (REST port + Shell SMTC port + UDP discovery) — one rule covers everything the plugin can see. Use the Settings → Firewall panel or CLI:
MBXHub.exe firewall add --name MBXHub --tcp 8080,8081 --udp 1900,3702,5353 --urlacl 8080,8081
Shell-only (SMTC port, added by Shell itself) — MBXHub.exe --install writes a MBXHub-Shell rule and URL ACL for smtc.port automatically; --uninstall removes it. You only need to run firewall commands manually if you skip --install.
Override the Shell SMTC port by editing smtc.port in mbxhub-shell.json. Every plugin-side firewall surface reads that value from the Shell’s config (falling back to restPort + 1 only when no config exists), so a changed smtc.port is checked and repaired correctly — no manual follow-up.
The overlay bridge binds under the port wildcard — http://+:<smtc.port>/overlay/, covered by the same http://+:<smtc.port>/ reservation as the SMTC routes, refusing every caller not on this machine per request. Install and repair also reserve the explicit http://127.0.0.1:<smtc.port>/overlay/, the fallback rung for a box with no wildcard reservation; on a box that has one, an explicit prefix is bindable but never routed a request, which is why the wildcard comes first.
Checking and repairing: MusicBee → MBXHub Settings → Network Status lists every reservation and rule for both processes, each checked individually (including WHO holds a reservation — one granted to the wrong principal reads as broken, not present), plus the two discovery UDP rows, Advertising (1900, 3702) and Cast discovery (5353). Anything missing gets a one-click elevated Fix, which runs MBXHub.exe firewall repair — narrow by contract: it fixes only what is missing or wrongly-held, never recreates an existing rule, never touches RDP. The Network Status Fix, the plugin’s startup prompt and the Shell’s tray and startup repair (when the local hub answers) pass the needed list from the audit as --udp <list|none>, so they open only the UDP ports in use. When there is no audit to ask (the hub is down or on standby, or Fix is pressed before the tab’s first refresh), the repair falls back to opening all three discovery ports on a paired install, as before. Every Fix surface also passes --program <path> for the programs that listen on the discovery ports (MusicBee and the Shell from the plugin, the Shell’s own exe from the Shell), so the repair counts a rule scoped to one of them, as the audit does. A block rule that applies to them cannot be repaired by adding an allow: the repair names it, adds nothing for discovery and exits 1. Both processes also self-check at startup and prompt with the same repair; mbxutil status reports the same list.
discoveryEnabled (default: true), discoveryName (default: empty = machine name).
The discovery name is used as the friendly name across all protocols and in GET /system/version.
HTML status dashboard with live stats: system info (version, uptime, host, modules), library counts (tracks, albums, artists, genres, playlists, podcasts), AutoQ state (status, vibe list, mood cache, auto scan), and feature flags. All data fetched client-side from existing API endpoints.
Returns system status and the hub’s modules (JSON): {status, modules:[{name, status, detail}]}. modules lists what the plugin registered — REST, WebSocket, Discovery — with status running, off or failed to start, then Shell, connected or not connected (whether the Shell has announced itself to this hub). detail adds this PC only for a REST listener that fell back to localhost, or the WebSocket client count; null otherwise.
Where truedat resolves each of its artifacts — asked of truedat itself (--paths --json), never derived by the hub, since its resolution order and host-suffixed naming are its own convention. Response: {resolvedFrom, paths:[{key,label,path,present,note}], capturedUtc, stale, staleReason}. resolvedFrom names which step chose the catalog — --moods argument means the hub pinned it via the moodsFilePath setting, anything else means truedat discovered it — which is the distinction a wrong-catalog report turns on. Absent artifacts keep their entry (present:false) rather than being omitted. truedat holds one run slot, so during a scan this returns the last good answer with stale:true and its capture time; 503 when truedat cannot be resolved and nothing is cached, 502 when its output cannot be read. Rendered as the Truedat Paths panel at the bottom of /status.
Diagnostic surface — a live “Geiger counter” for steady-state activity. Off by default; gated on diagnostics.diagEndpointEnabled = true in mbxhub.json. Returns 404 when disabled, with explanatory body.
/diag serves a self-contained HTML page that polls /diag/perf at 1 Hz from the browser and renders inline-SVG sparklines for: process CPU %, network bytes in/out, working-set / private bytes, thread count, handle count, GC gen0/1/2 collection rate. Last 120 samples (~2 min) held client-side. Server is stateless — no background timer, no ring buffer, no allocation while the page is closed.
/diag/perf returns an instant snapshot of public process counters via System.Diagnostics.Process, System.Net.NetworkInformation, and GC.CollectionCount. Fields: ts, processorMs, workingSetMB, privateMB, threadCount, handleCount, gen0/gen1/gen2, bytesIn, bytesOut, logicalProcessors. Cumulative counters; the page diffs them client-side to derive per-second rates.
/diag/mbxhub returns a per-MBXHub instrumentation snapshot — counters specific to this plugin (request rates, broadcaster fan-out, lock waits, etc.) rather than the OS-level metrics in /diag/perf. The /diag page renders this as a second tile row. Same diagEndpointEnabled gate.
/diag/autoq/picks returns the last 20 AutoQ decision records, newest first — one per pick. Each names which entry point ran (fill, pick, generate, journey, preview, connect, station-play, send), what each eligibility gate removed, where the pick aimed and how far it reached, the top scorers with the components that produced them, what a quota cut, the ordering, and what reached the queue. Double-gated: needs diagEndpointEnabled and diagnostics.pickJournalEnabled. With the journal on, the same records are also appended one JSON object per line to autoq-picks.log beside mbxhub.log (5 MB, 2 archives), so a bug report carries them; the wire and the file use the same formatter and are byte-identical. Two shapes a reader must handle: a normally-numeric field is the string "NaN" / "Infinity" / "-Infinity" when non-finite, and on a send record sentUrls/sentQueued are the listener’s own tracks while queued is AutoQ’s fill — they are never combined into one number. A send record also carries sendMode (shown as mode= on the summary line): the steer variant that applied (refresh/reuse/restart), the same value the send’s response reports as mode, so the toast a listener saw and the journal line a triager reads cannot tell two different stories. sentUrls itself is capped at 50 entries so one huge multi-select cannot write an unbounded line; sentTotal carries the uncapped count of what was sent. A record with no poolRecorded never drew a pool at all — distinct from a draw that came back empty. Every record carries waypoints; it is non-empty only when the pick built a journey, and then holds the route in order — first = start and last = end — beside sequence, the arc actually produced through it. That can be a journey, preview, connect or send record, or a station-play of a saved journey station: the route is recorded on whichever entry point opened the pick. The summary line shows route=N only when there is a route, so route=3 against a two-track request is a journey that picked up a waypoint nobody asked for.
Edges in the journal. An ordered pick record carries edges — on (edge segments shaped the joins), off (edge data is present but Edge transitions is switched off), building or none — and the summary line says it out loud: edges=on, edges=OFF(unused), edges=none, and names the join into the fill in words: join=9A/122->8A/120(edge) is the queued track’s end into the first pick’s start, key/BPM each side, and whether the incoming side was its edge segment. joins lists what met at each adjacent pair of sequence, aligned with pairKeyDistance: fromKey/fromBpm (the outgoing track’s end), toKey/toBpm (the incoming track’s start), and fromSource/toSource — edge when that side was the track’s edge segment, whole when it was the whole track. join is the same for the step into the fill that joinKeyDistance measures; its outgoing side has no source.
/diag/snapshot (v0.5.2.6+) takes a JSON body containing the page’s client-side ring (samples + derived rates + small header) and appends a formatted text block to diag-snapshots.log next to mbxhub.log. Each snapshot is delimited by “=” rules with a per-metric min/avg/max/latest table and the raw samples JSON below for forensic re-processing. Triggered by the Snapshot button on the /diag page; same diagEndpointEnabled gate. Response: { success, path, bytes, at }.
/diag/search (v0.5.3.0) returns a rolling p50/p90/p95/p99 summary over the last 256 search calls (in-memory ring, ~28 KB, server-wide singleton). Summary is segregated by endpoint: library (calls into /library/search), federated (calls into /search), and combined. Each block carries totalMs {p50,p90,p95,p99,max,mean}, candidates {p50,p95,max}, and mbCalls {p50,p95,max}. The recent array (default 50, override with ?recent=N, capped at ring capacity) lists the newest entries with per-stage timings (mb / cue / am / pf / sort / pg) and per-family MB-call counts (qf / ql / qlr / gt / gts / gp / gb / pl). POST /diag/search/reset clears the ring (operator-triggered flush; empty body, send Content-Length: 0). Same diagEndpointEnabled gate. Returns 403 FORBIDDEN when ApiReadOnlyMode is set — kiosk deployments can’t have their perf history wiped by remote clients.
// Enable in mbxhub.json:
{
"diagnostics": {
"diagEndpointEnabled": true
}
}
// Then visit:
// http://host:8080/diag → live page
// http://host:8080/diag/perf → JSON snapshot
Returns API version information. Includes host field with the configured discovery name (or machine name if not set), and branch — the git line this build came from (master, release, a feature branch, or unknown outside a repo). The branch is deliberately absent from displayVersion for the lines we ship from: a bare version number IS the release, and a suffix marks something as not one. It is reported here instead, because a version string is identity and this endpoint is diagnostics — without it, answering “which line is this build on” means having the repo to hand and running git branch --contains. Also signature and signatureVerified: the running plugin is checked against the Halrad signing certificate (a valid signature AND our certificate, since either alone can be satisfied by a file that should not pass); signatureVerified is true only for a shipped, unaltered build. “Could not determine” is reported as its own state, never as verified. The Diag page shows the same.
Returns this node's capabilities for cross-channel SSDP discovery. Used by Shell and other MBXHub nodes to identify what this instance offers.
// Response:
{
"node": "plugin",
"version": "0.5.5.3",
"capabilities": ["rest-api", "websocket", "player-control", "autoq", "discovery"],
"endpoints": {
"rest": "http://host:8080",
"ws": "ws://host:8080/ws"
}
}
Health check endpoint (alias: /system/ping). Returns status, service and apiVersion.
apiVersion is the extension contract's version, not the product's. It is a string, currently "1". Under a fixed value no field is removed from a response and no response type changes; fields may be added, so a client must tolerate members it does not know. A change to the contract bumps the value, and the bump is announced. The product version moves every release and is reported by GET /system/version — pin an integration against apiVersion, never against that. It is on /ping because /ping is the first response most integrators read, so the contract is known before anything else is.
// GET /ping
{
"success": true,
"data": {
"status": "ok",
"service": "MBXHub",
"apiVersion": "1"
}
}
Returns enabled feature flags: banlist, ratings, loved, reactions, streaming, playReporting (false when apiDisablePlayReporting or apiDisablePlayCountUpdates is on — clients watching this flag stop sending play reports under either), partymode, autoq, cast (the cast.enabled switch: false means every /cast route answers 503 CAST_DISABLED; whether a Shell is up to carry a cast is the route’s own answer), transcode (true only when transcode.enabled AND ffmpeg actually resolves — the capability statement clients key the ?compat=1 ask on), transcodeFormat (what a ?compat=1 answer is converted to, the transcode.format setting, e.g. "flac"; present only while transcode is true; a Cast sender takes the converted item’s content type from it, and an older hub without the field converts to flac), diag (v0.5.3.4 — mirrors diagnostics.diagEndpointEnabled). Clients use these to show/hide UI elements.
Also carries libraryTagsWritable — whether a library-tag write (POST /dashboard/love, POST /dashboard/rate/{n}) can actually succeed right now. This is a different question from ratings/loved, which say whether the feature exists at all: a hub can have ratings enabled and still refuse every write, which is the stock configuration (apiReadOnlyLibraryTags defaults true). Effective, like streaming beside it: false when any of apiReadOnlyMode / apiReadOnlyLibrary / apiReadOnlyLibraryTags is on, and false while a party is active (tag writes are refused for every role during a party, DJ included). Render the stars and heart either way — reading a rating always works; what this flag governs is whether to offer to change it. It is a snapshot, so treat the verb’s own 403 as the authority.
And tagEditNotice — whether a client may explain that refusal. Independent of libraryTagsWritable: that one says the write is refused, this one says whether to say so out loud. False when disableTagEditNotice is on, which exists for a hub deliberately run read-only, where the block is the intended behavior and being told about it is noise. When false, still retire the control and still carry the reason on hover — only the transient notice is suppressed, never the explanation itself. An older hub omits the field entirely; treat absent as true, because a needless toast is a smaller failure than a control that silently does nothing.
Also carries webSocketAuth — "clear" or "restricted", the resolved credential mode of the /ws feed (never the stored “follow profile” value). restricted means a remote page must mint a ticket at POST /ws/ticket before each connect and a program must send its key on the handshake; see WebSocket Events → Credentials. An older hub omits the field; treat absent as clear.
Also carries lanBase — the http://<ip>:<restPort> a device elsewhere on this network uses to reach this hub. The field is absent when nothing qualifies, because responses omit null properties — read a missing key exactly as you would read null — and nothing qualifies when either no adapter carries a usable address or allowRemoteConnections is off, in which case the hub refuses every LAN caller anyway and a URL here would name a door that is closed. A page opened at 127.0.0.1 must use this, not location.host, when it hands a URL to something that will fetch the URL itself — a Cast receiver resolves a loopback address to the receiver, not to the hub. The address is read from the live adapters each time the endpoint is called: the adapter must be up and must not be loopback or a tunnel, and its IPv4 must be in a private range and not APIPA (169.254.x.x) — which is why a VPN or a public address is never published. A default gateway only breaks ties between addresses that already qualify; it is not a requirement. A non-null value is not a promise of reachability — the URL ACL, the Windows Firewall and IP filtering can each still refuse a given peer.
Also carries shellBridge — { port, binding, seenUtc } when the MBXHub Shell has announced its overlay bridge this hub run, otherwise null. A page offering “open this as a desktop window” should use that port rather than assuming restPort + 1; null means fall back to the convention. binding is wildcard (the normal case: the bridge binds under the Shell port’s http://+: reservation) or loopback (a box with no such reservation); loopback-only is enforced per request either way. It is not a liveness claim — it says where the Shell said it was listening, so probe before drawing anything.
The Shell announcing where its overlay bridge listens: { "port": 8081, "binding": "loopback" }. An empty body clears the announcement, which is a clean Shell shutdown saying so. Read back through GET /system/features.
Local callers only (403 otherwise): this is one process on this machine telling another where it is, and a page that believed a remote announcement would send pop-out requests at a port somebody else chose. Held in memory, never persisted — a bridge that is not running should stop being advertised when the hub restarts, and the Shell re-announces on every start.
The machine audited against the configured setup mode: which URL reservations and firewall rule this mode needs, and which of them are actually there. mode is local, private or shared; each entry’s verdict is fine, wrong (needed and missing) or extra (present and not needed — what a hub left behind after moving down to This computer only). consistent is true when nothing the mode needs is missing; extra entries never break it. wrong and extra list entry names, and may also contain Firewall rule — the rule is audited alongside the entries but is not a row in entries[], so do not expect to find every name there.
discovery[] holds the two discovery UDP rows, Advertising (1900, 3702) and Cast discovery (5353), each with name, ports, purpose, needed, present, verdict and notNeededReason. notNeededReason says why a row is not needed, in the Network Status tab’s words (remote access is off, advertising is off, Cast is not in use), and is absent when the row is needed. Advertising is needed when the hub advertises itself, Cast discovery when Cast is in use, and nothing is needed under local. An enabled inbound allow rule covering the row’s ports counts as present whatever its name, provided it is not scoped to a Windows service and, if scoped to a program, that program is the one listening (MusicBee for the advertising ports, the Shell for Cast discovery). With Cast not in use nothing listens on 5353, and a rule for either program counts. An enabled block that applies to the listening program wins, and Windows’ own built-in discovery rules are service-scoped, so they do not count. A discovery row’s name joins wrong or extra the same way, except that a needed, missing row is wrong only while Windows Firewall is on; with it off the verdict is fine. repairUdpPorts is the ports of the rows the audit says are needed, comma-separated ("" when none, which includes local even with Cast on); Fix passes --udp none when it is empty.
Local callers only (403 LOCAL_ONLY otherwise), and read-only: it exposes nothing a local netsh http show urlacl does not, and repair stays a console act — MusicBee → MBXHub Settings → Network Status → Fix. This endpoint offers no write. 503 NOT_AVAILABLE when the audit is not wired in this host.
// GET /system/network-setup
{
"success": true,
"data": {
"mode": "private",
"entries": [
{ "url": "http://+:8080/", "name": "REST API", "needed": true, "present": true, "verdict": "fine" }
],
"firewallRule": { "needed": true, "present": true, "firewallOn": true },
"discovery": [
{ "name": "Advertising", "ports": "1900,3702", "purpose": "SSDP and WS-Discovery: phones and the Shell find this hub; it appears in the Windows Network folder. The hub's mDNS announcement is served by Windows' DNS Client service, under that service's own rule", "needed": false, "present": false, "verdict": "fine", "notNeededReason": "advertising is off" },
{ "name": "Cast discovery", "ports": "5353", "purpose": "mDNS: the Shell finds Cast receivers", "needed": true, "present": true, "verdict": "fine" }
],
"repairUdpPorts": "5353",
"wrong": [],
"extra": [],
"consistent": true
}
}
The hub’s own route table: one entry per method and path it registers. kind is static (exact path), pattern (path is the regular expression the request path is matched against), prefix (path is the bare prefix, e.g. /pages/, and any longer path under it matches) or template (path is a {name} template such as /playlists/{url}, matched by its own code). action is the access-profile class the route is registered under. Static routes come first, then dynamic routes in the order they are tried. The API validator measures its coverage against this list.
Local callers only (403 LOCAL_ONLY otherwise), and read-only. 503 NOT_AVAILABLE when the route table is not wired in this host.
// GET /system/routes
{
"success": true,
"data": {
"count": 4,
"routes": [
{ "method": "GET", "path": "/system/status", "kind": "static", "action": "Read" },
{ "method": "GET", "path": "^/playlists/(.+)/files$", "kind": "pattern", "action": "Read" },
{ "method": "GET", "path": "/stream/", "kind": "prefix", "action": "Read" },
{ "method": "GET", "path": "/playlists/{url}", "kind": "template", "action": "Unclassified" }
]
}
}
v0.5.3.4. Returns the friendly display-name MB has configured for each of the 48 tags exposed by browse.customSorts (Custom1..Custom16, Virtual1..Virtual25, Year, OriginalYear, SortAlbum, SortAlbumArtist, SortArtist, SortTitle, SortComposer). Server reads Setting_GetFieldName for each; only entries whose user-set name differs from the raw enum are returned. Used by settings.html's customSorts tag-picker to render AutoQ Moods (Custom1) instead of just Custom1.
// GET /system/custom-tag-names
{
"success": true,
"data": {
"names": {
"Custom1": "AutoQ Moods",
"Custom2": "Mood Rating",
"Virtual1": "Energy"
}
}
}
Get or update hub configuration (ports, enabled modules, log verbosity, library behavior). restPort and restEnabled changes require localhost (403 for remote). All changes blocked for remote clients during party mode.
Beyond the network fields (restPort, restEnabled, wsEnabled, defaultPage), GET and PUT also surface (MBRC was cut; mbrcPort / mbrcEnabled no longer exist):
{
"logLevel": "Info", // trace/debug/info/warn/error, case-insensitive; propagates to Shell
"effectiveLogLevel": "Info", // GET only, read-only: the level actually in force, which a Shell
// with followHubLogLevel on follows
"library": { "disableStrictSearch": false } // v0.5.2.4+: disables phrase-per-field strict search
}
GET additionally returns a read-only dashboardLayout block. Static pages have no other route to these values — the dashboard is server-rendered and reads settings directly, but browse.html and the injected command palette are plain assets, so this response is their config channel. Write these through PUT /system/config (or the settings page), not here:
{
"dashboardLayout": {
"disableVirtualTracklistBadge": false, // the CUE / SET LIST badge: browse.html's chip,
// and the HUD's over the artwork
"liveSetBadgeTarget": "nowplaying", // where that badge goes: "nowplaying" | "play"
"hideFileInfo": false, // the codec / rate line. Lives on HubSettings, not
// inside DashboardLayout; carried here because it is
// a Dashboard knob and this is the block clients read
"commandPalette": {
"enabled": true, // the ONLY off switch on play/explore/nowplaying/browse/hud,
// which include the palette bootstrap unconditionally
"openShortcut": "Ctrl+K",
"recentLimit": 8, // clamped 1..50 client-side
"bucketLimit": 100, // clamped 1..500 client-side
"actions": [] // empty = curated defaults
}
}
}
GET also returns a read-only dice block, { "scope": "library", "onPress": "play", "rolls": "album" } — the hub’s autoQ.diceScope / autoQ.diceOnPress / autoQ.diceRolls, which the dice page reads when a device follows the hub (see POST /autoq/dice). Written through PUT /system/config.
GET also returns a read-only hud block, for the same reason — browse.html has to know these before it sizes its album fetch. Write them through PUT /system/config or the settings page:
{
"hud": {
"recentAlbumViewLimit": 0, // albums shown when Browse is sorted by Recently Added
"randomAlbumViewLimit": 0, // albums shown when Browse is sorted by Random
"presentation": { // system defaults; HUD device overrides are local
"backgroundMode": "immersive", // solid (Low power), small, immersive
"artworkSizePercent": 0, // 0 keeps preset; 25 to 100 scales artwork
"backgroundDimPercent": 40,
"bottomBarStyle": "filled", // filled, transparent
"metadataVisibility": "auto", // auto, always, hidden
"metadataPlacement": "below", // below (between artwork and transport; normal HUD only), overlay (on the artwork)
"metadataDetail": "full", // full, compact (title and artist only)
"transportSize": "m", // s, m, l, xl, xxl: the HUD's own button size, not the dashboard's controlsSize
"controlsVisibility": "auto",
"fadeDelayMs": 4000, // 2000, 4000, 6000, 10000
"alwaysShowProgress": false,
"listenHerePlacement": "titlebar", // titlebar, services, neither
"castPlacement": "titlebar",
"volumeControls": "shown", // shown, hidden: the whole title-bar volume cluster
"volumePull": false, // drag the speaker for volume; tap-mute and - / + work either way
"queueOpen": false,
"lyricsOpen": false
}
}
}
Both album limits are 0 = unlimited, which is the shipped behavior. They cap only the two discovery sorts: A–Z, Album Artist and Year are lookup orders whose completeness the A–Z rail depends on, and are never capped. Random additionally bypasses the browse page's per-sort memoize cache so each visit is a fresh shuffle — at 25 albums the cached order would otherwise be the same 25 for the whole session.
Only fields a client actually reads are carried. commandPalette.showChip is deliberately absent because the chip is rendered server-side. Added v0.5.5.0 — before it, every palette knob above was declared, validated and read at the right name but never traveled, so changing one did nothing.
logLevel is validated against the same vocabulary as HubLogger.ParseLevel/ShellLog.ParseLevel; invalid values are silently ignored (same soft-skip pattern used for restPort range checks). library.disableStrictSearch takes a nested object — the parent object is created if missing. Neither requires localhost; both are UX-level settings safe to change remotely. Everything else in mbxhub.json, including search.live.minQueryLength and the dashboardLayout values above, is written through PUT /system/config and listed by GET /system/settings/schema; this endpoint carries a hand-picked subset and is not a general settings writer.
Returns schema for all configurable settings. Each entry includes:
| Field | Description |
|---|---|
key | Dotted key path (e.g. autoQ.batchSize) |
type | bool, int, double, string, enum, list (current/default are JSON arrays; PUT /system/config also accepts a comma-separated string), dict (a keyed string map such as slices.rowQueries; current/default are JSON objects and a PUT sends the whole object) |
category | Grouping: General, AutoQ, Scoring, Dashboard, API, etc. |
tier | A number: 0 basic, 1 advanced, 2 dangerous. A charm row carries its manifest setting’s tier (basic, advanced or dangerous) as the same number |
description | Human-readable explanation |
default | Default value |
current | Current live value |
min, max, step | Range constraints (numeric types only) |
options | Valid values (enum types only) |
requiresRestart | Whether a restart is needed for the change to take effect |
Security: blocked when disableRemoteConfig is true (403) or during party mode (403). The /pages/settings.html page consumes this endpoint.
Also lists one charm.<id>.<key> row per setting of each charm holding the settings grant, under that charm’s category — see Charms, Verbs, the store and settings.
Update configurable settings via dotted key paths. Only properties decorated with [ConfigSetting] can be modified, and only those in a category the schema offers — the keys this route writes are exactly the keys GET /system/settings/schema lists.
Body: JSON object with dotted keys:
{"autoQ.batchSize": 10, "debugMode": true}
Console-only categories are refused. A key in Network, Features, Security or RateLimit is answered at the machine, in MBXHub's settings dialog in MusicBee, and this route replies 403 SETTING_CONSOLE_ONLY naming the offending keys — and applies nothing at all, so pairing a protected key with an ordinary one writes neither. A security control you can change over the network is not a security control.
charm.<id>.<key> keys are a charm’s settings: they are written to that charm’s store, never to mbxhub.json, and an undeclared key or a value the setting does not accept is 400 CHARM_SETTING_UNDECLARED / 400 CHARM_SETTING_INVALID, naming the keys, with nothing applied — see Charms, Verbs, the store and settings.
Security: blocked by apiReadOnlyMode (403), disableRemoteConfig (403), and party mode (403). POST is an alias for PUT.
Get or set the default redirect page (body: {"defaultPage":"/pages/player.html"})
Security: PUT is blocked by disableRemoteConfig (403) for a caller that is not on the loopback interface.
Get the theme THIS caller renders with, or update the hub's own theme. GET answers the caller's resolved theme — its own values if the device carries a mbxh_device_theme cookie (see /device/theme), otherwise the hub's. ?scope=hub answers the hub's own values regardless of the caller's cookie. PUT always writes the hub.
Two configurable mode slots (mode1, mode2) each with 13 HSL fields: accentHue (0–360), accentSaturation (0–100), accentLightness (0–100), bgHue (0–360), bgSaturation (0–100), bgLightness (0–100), surfaceHue (0–360), surfaceSaturation (0–100), surfaceLightness (0–100), textHue (0–360), textSaturation (0–100), textLightness (0–100), intensity (0–100, saturation multiplier: 0 = grayscale, 100 = full color). Partial updates supported — only include the fields you want to change.
// GET response:
{
"activeMode": 1,
"mode1": {
"accentHue": 197, "accentSaturation": 80, "accentLightness": 55,
"bgHue": 203, "bgSaturation": 30, "bgLightness": 94,
"surfaceHue": 203, "surfaceSaturation": 30, "surfaceLightness": 96,
"textHue": 203, "textSaturation": 50, "textLightness": 13,
"intensity": 100
},
"mode2": {
"accentHue": 197, "accentSaturation": 80, "accentLightness": 55,
"bgHue": 203, "bgSaturation": 30, "bgLightness": 7,
"surfaceHue": 203, "surfaceSaturation": 30, "surfaceLightness": 10,
"textHue": 203, "textSaturation": 50, "textLightness": 90,
"intensity": 100
},
"active": {
"accentHue": 197, "accentSaturation": 80, "accentLightness": 55,
"bgHue": 203, "bgSaturation": 30, "bgLightness": 94,
"surfaceHue": 203, "surfaceSaturation": 30, "surfaceLightness": 96,
"textHue": 203, "textSaturation": 50, "textLightness": 13,
"intensity": 100
},
"disablePinchZoomLock": false,
"deviceOverride": false, // true when these values came from this device's own cookie
"canWriteHub": true // whether a PUT /system/theme from this caller would be accepted
}
// PUT examples:
{"activeMode": 2} // Switch to mode 2
{"mode1": {"accentHue": 180}} // Update just accent hue on mode 1
{"mode1": {"bgLightness": 10, "textLightness": 90}} // Make mode 1 dark
{"mode1": {"intensity": 0}} // Desaturate mode 1 to grayscale
deviceOverride says whether the values returned are this device's own rather than the hub's; it is reported under ?scope=hub too, where the values are the hub's but the device's state is still what the caller needs to know. canWriteHub says whether a PUT /system/theme from this caller would be accepted, so an editor can show the right affordance instead of discovering a 403 on save.
PUT broadcasts a ThemeChanged WebSocket event and sets the mbxh_theme cookie for dashboard SSR.
Security: PUT is blocked by disableRemoteConfig (403) for a caller that is not on the loopback interface.
Set, or give up, THIS device's own theme. PUT takes the same body as PUT /system/theme and starts from the theme this caller currently renders with, so a body naming one field changes one field. DELETE is how a device goes back to following the hub.
The whole effect is a cookie on the caller — mbxh_device_theme, a year long, path /, SameSite=Lax. Nothing on the hub changes: no setting is written, no ThemeChanged event is broadcast, and other devices see nothing. disableRemoteConfig therefore does not apply — it guards the hub's settings, and this is not one, so a phone that could never change the hub's theme can change its own.
A device with no cookie follows the hub, and every page it is served is rendered with the hub's theme. DELETE expires the cookie rather than writing the hub's current values into it: a device holding a copy of today's hub theme looks right and has silently stopped tracking every later change.
Both answer the same payload as GET /system/theme — PUT with deviceOverride: true and the device's new values, DELETE with deviceOverride: false and the hub's.
The theme editor — the dashboard's theme drawer, behind the gear, and /pages/theme.html — carries an Apply to: This device | All devices choice. This device saves through PUT /device/theme, which disableRemoteConfig does not gate. All devices saves through PUT /system/theme, and where the hub will not accept that from this caller it is shown disabled with the reason. All devices includes THIS device: saving there from a device that had a theme of its own gives that theme up (the same DELETE /device/theme), so the screen shows what was just published and the device follows the hub again — otherwise it would publish a change it could not see. Follow the hub (DELETE /device/theme) is offered while the device has a theme of its own. A device with its own theme ignores the hub's ThemeChanged broadcast.
Not on the party surface: while a party is running, a remote guest's device cannot set a theme of its own.
// PUT examples (same body as PUT /system/theme):
{"activeMode": 2} // This device uses mode 2
{"mode1": {"bgLightness": 10, "textLightness": 90}} // Dark, on this device only
// Response header on PUT:
Set-Cookie: mbxh_device_theme=v1.2.0.197-80-55-...; Path=/; Max-Age=31536000; SameSite=Lax
// Response header on DELETE (the cookie is expired, not overwritten):
Set-Cookie: mbxh_device_theme=; Path=/; Max-Age=0; SameSite=Lax
Process metrics for the MusicBee host process. Used by MBXHVAL for remote monitoring.
// Response:
{
"success": true,
"data": {
"process": {
"name": "MusicBee",
"cpuPercent": 2.15,
"memoryMB": 185.3,
"privateMemoryMB": 210.5,
"threadCount": 42,
"handleCount": 1250
},
"gc": {
"gen0": 150,
"gen1": 30,
"gen2": 5,
"totalMemoryMB": 45.2
},
"timestamp": "2026-03-10T12:00:00.0000000Z"
}
}
Server start time and uptime. Used by dashboard to detect restarts.
// Response:
{
"success": true,
"data": {
"startedAt": "2026-03-10T20:30:00.0000000Z",
"uptimeSeconds": 3600,
"uptime": "1h 0m"
}
}
Generate QR code PNG image for the MBXHub base URL. Optional ?url= for custom target.
Open the plugin's Settings dialog on MusicBee's UI thread — used by the first-run setup screen's Settings link. Local, or a paired Shell: a remote caller must carry a valid X-MBXHub-SAC signature (see Signed calls from a paired Shell under Charms & Capabilities). With no signature at all: on a paired hub 403 SAC_REQUIRED, and on a hub that has never paired the unchanged 403 FORBIDDEN. With a signature that does not hold up — including on a hub with no secret to check it against — 403 SAC_INVALID. 404 when disabled via disableOpenSettings — the kill switch outranks a caller that authenticated — and 503 when the plugin UI isn't available. The dialog opens on the hub's screen, which on a headless jukebox is a screen nobody is standing at.
Redeem a pairing code for the Secure Access Channel secret — the exchange that makes a Shell a paired Shell. Body {"token":"ABCDE-FGHIJ"}, in the body and never a query string. Answers {"secret":"<base64url>","hubId":"<hub id>"}; the Shell keys its copy of the secret by that hubId, so both fields are needed. Closed unless a window is armed at the hub's console. A person clicks Pair a Shell in the MBXHub settings dialog on the hub's own machine; that click is the only thing that opens this endpoint, and there is no way to arm one remotely. The window lasts five minutes, is good for one redemption, and closes after five wrong codes. Refusals: 403 SAC_PAIRING_CLOSED when no window is open (the state of every install nearly all of the time), 403 SAC_PAIRING_REFUSED for a wrong code or a spent budget, 500 SAC_PAIRING_UNAVAILABLE when the hub has nowhere per-user to keep a secret — that last one leaves the window open, because nothing was handed out. The code is Crockford base32 and is read forgivingly: the hyphen is optional, case does not matter, and O/0 and I/l/1 are the same character. The 32-byte secret crosses the network once, in this one response, in the clear — there is no TLS, and a five-minute window a person opened by hand with somebody watching is what bounds that. Every attempt is logged with the caller's address; the code and the secret are not. When no window is armed, the optional REST password stands in front of this route like any other non-local call. One secret per hub. Pairing replaces any previous pairing — a Shell paired earlier will need to be paired again (a Shell on the hub's own machine is re-paired automatically).
Returns the client's IP address as seen by the server. Used by the SMTC Link Charm to discover the client's local Shell when accessing a remote dashboard.
Other MBXHub instances on the LAN, as discovered by the Shell's SSDP scan. The plugin reads the shared file %LOCALAPPDATA%\MBXHub\hub-neighbors.json (written by the Shell) and returns its contents; shared.js calls this on every page load and merges the result into the browser list that /pages/hubs.html renders. Returns an empty array when the file is missing — Shell not running, nothing discovered yet, or a different user profile. Never errors: hub-neighbors is a hint, not a contract.
The canonical list of registered sorts (built-ins plus customs), so callers don't have to guess sort ids. Direction is request-side only — never a property of an entry; directionApplies says whether asc/desc is meaningful for that sort at all. Returns {sorts: []} rather than an error when the registry isn't wired.
// Response:
{
"success": true,
"data": {
"sorts": [
{ "id": "album", "label": "Album", "kind": "builtin",
"directionApplies": true, "valueKind": "text" }
]
}
}
Diagnostic probe over the sort index: returns the first 20 album names in the requested order. Exists to smoke-test sort wiring on its own, without going through a /library/* endpoint. sort is required. dir defaults to asc.
Errors: 400 MISSING_SORT (no sort parameter), 400 UNKNOWN_SORT (id doesn't resolve against /system/sort-registry), 503 SORT_INDEX_UNAVAILABLE (index not wired).
Push a named event into the WebSocket broadcaster from sub-process components (Shell, scripts, automations). It goes out as the SystemEvent type, carrying {name, payload} — the pushed name and the request body — so a client subscribes to SystemEvent, never to the pushed name: only the types in EventTypes.All can be subscribed to, and a client with no subscriptions receives it either way. A body that is empty or not JSON leaves payload null and the event still goes out. Used to fan out signals that originate outside the plugin process. CastStatus is the one name the hub also acts on: from this machine, or signed by the paired Shell (X-MBXHub-SAC over the method, path and body), it sets or clears the speaker admitted for its audio and artwork (see Cast); unsigned from elsewhere it is fanned out but changes nothing. A signature that does not verify changes nothing either: where the caller would have been let in without the header, the push is still fanned out and the hub logs a warning; where the header is what got it past the password, API keys or Shared LAN, it is 403 SAC_INVALID and is not fanned out.
Answer: {success: true, data: {name, accepted: true}}. A CastStatus answer's data also carries speakerAdmitted: true only when the push was believed (from this machine, or signed by the paired Shell), it says the cast is active with a usable receiver.address, and that speaker is the one admitted once the push has been applied — a repeat for the speaker already admitted is true too. Otherwise false: an unsigned push from elsewhere is still answered 200, so the status alone does not say the speaker was let in.
Force re-extract all embedded resources (pages and charms) to disk. Overwrites existing files.
// Response:
{
"success": true,
"data": {
"pagesUpdated": 8,
"charmsUpdated": 3,
"userModified": []
}
}
Accepts browser-side log events and writes them to mbxhub.log. Use this instead of console.log for operational telemetry from dashboard pages so problems in guest browsers land in the server-side log where the operator can see them. Accepts either a single event or a batch via events[]. Silent on empty body (returns {success:true, data:{accepted:0}}); accepted counts events that passed validation and reached the log. Invalid JSON returns 400 INVALID_REQUEST (not INVALID_JSON — this endpoint predates the topology handler’s specific code). Access: ActionCategory.System — admin-gated. PartyMode guests are rejected (they lack System) so guest-sourced log entries can’t mix into operator telemetry.
// Single event:
POST /system/client-log
{ "level": "warn", "msg": "Retry #3 on /queue/add" }
// With page context (prefixes the log line as "[page] msg"):
POST /system/client-log
{ "level": "info", "msg": "Charm bar rendered", "page": "dashboard" }
// Batch:
POST /system/client-log
{ "events": [
{ "level": "info", "msg": "Charm bar rendered" },
{ "level": "error", "msg": "ThemeChanged handler threw" }
] }
// Response:
{ "success": true, "data": { "accepted": 2 } }
Recognized fields: msg (required; empty or missing drops the event), level (optional, defaults to info; free-form string mapped to server log levels, unknown values log as info), and page (optional; wraps the output as [page] msg). Any other fields on the event object are silently dropped — if you need to capture a stack trace or structured data, concatenate it into msg. Both msg and page are sanitized: CR/LF/NUL stripped (blocks log-line forgery) and length-capped (4096 chars for msg, 256 for page).
Runtime SMTC target management — discover MBXHub endpoints on the network and switch which one the Shell mirrors to Windows Media Transport Controls (taskbar overlay, lock screen, Bluetooth headsets).
Served by the Shell (MBXHub.exe), not the plugin. Listens on smtc.port from mbxhub-shell.json, default 8081, the Shell’s own port. The listener emits Access-Control-Allow-Origin: * and handles OPTIONS preflight, so any browser page served by the plugin on 8080 can call this port directly cross-origin.
Who is listening on this port. Answers { "status": "ok", "service": "MBXHub.Shell", "version": "…", "restPort": 8080, "nodeId": "…", "name": "…" } — a bare object, like every other route on this listener, not the hub's {success, data} envelope. service is deliberately not MBXHub: that is what the hub answers on GET /ping, and every probe in the product matches it exactly, so a Shell claiming the hub's name would be mistaken for a hub by all of them. restPort names the hub this Shell belongs to — which is what turns ports are claimed as an even pair from a convention into something a caller can verify before it acts, whether that is a deploy script about to ask a port to exit or a probe that found an open odd port and needs to know what it is. A read, so no Origin is required: it reports what the process list already shows, and a Shell that will not say what it is turns a half-updated install into a mystery.
Current SMTC target (host:port) and connection state. Response shape: { "target": "127.0.0.1:8080", "state": "connected", "controller": { "nodeId": "...", "name": "...", "restPort": 8080 } }.
Switch SMTC target. Body: { "target": "host:port" }. Tears down the existing WS connection, updates Shell config, reconnects to the new endpoint. 422 if the target probe fails. 409 CASTING while this Shell is casting to a speaker and the target is a different hub (the speaker's files come from the current hub); the message names the speaker. The same host and port, or a cast that is paused or stopped, goes through.
Cached list of MBXHub endpoints discovered on the network. Each entry: address, name, active (boolean), capabilities (e.g. ["rest-api", "websocket", "player-control", "autoq", "discovery"]). Returns 503 if the SMTC bridge is unavailable.
Clear the cache and re-scan the network via SSDP for MBXHub endpoints. Returns the fresh endpoint list.
The one thing the plugin cannot do for itself: when MusicBee is closed, the thing that would answer a request is the thing you are trying to start. The Shell answers instead. Off by default — switch on allowRemoteStart in plugin Settings → API Access tab, beside Allow remote exit.
Can this Shell start MusicBee? Returns {"ok":true,"canStart":true,"reason":"ok"}. No side effects, so a caller can decide whether to offer the verb without firing it — the alternative would be starting MusicBee on somebody's machine as a side effect of drawing a list. canStart is the AND of the setting being on (or the caller being local) and MusicBee actually being installed there, because either one false means the POST would fail and the offer would have lied. The in-flight window is deliberately not consulted: it is a rate limit, not a capability. reason is for diagnosis from a curl, not for display.
Start MusicBee on that machine. No body, no parameters, ever — the verb starts MusicBee and cannot be pointed at another program, so it cannot become a remote shell. 200 {"ok":true,"result":"launched"} when started; 200 {"ok":true,"result":"alreadyRunning"} when it was already up, which is a success rather than an error: the caller's window is dark because the hub has not answered yet, and MusicBee having just started is the normal race. Refusals: 403 REMOTE_START_DISABLED, 403 ORIGIN_NOT_ALLOWED, 404 NO_LOCAL_MUSICBEE, 429 START_IN_FLIGHT (a 30-second window, cleared early once MusicBee reports up, so a page retrying every 5s cannot cause a process-start storm), 500 START_FAILED.
Close the Shell itself. Gated on the same setting as the hub's POST /app/exit — allowRemoteExit, plugin Settings → API Access tab → Allow remote exit — so remote shutdown is one decision, not two. Local callers are always allowed.
Close the MBXHub Shell on that machine: the tray icon goes away, and SMTC, the overlay and remote start stop answering until the Shell runs again. MusicBee and the hub are not touched. No body, no parameters. Answers 200 {"success":true,"data":{"exiting":true}} first and closes about a quarter second later through the Shell's normal shutdown, so a deploy script can tell success from a refusal. Refusals: 403 REMOTE_EXIT_DISABLED (remote caller, setting off), 403 ORIGIN_NOT_ALLOWED, 500 EXIT_FAILED.
PUT /meta/smtc/target, POST /meta/notify, POST /meta/app/start and POST /meta/app/exit are the Shell's state-changing routes, and each requires that a browser-supplied Origin be LAN-scoped — loopback, a private-range address, or a single-label / .local hostname. An off-LAN origin, meaning a public website that has learned the Shell's port, is refused 403 ORIGIN_NOT_ALLOWED. A request carrying no Origin at all is still served, because native callers (the plugin, a script, curl) do not send one and the listener binds wide on purpose so remote SMTC control keeps working. Read routes are unchanged — including GET /meta/app/start, which changes nothing and where a stricter gate than the write would buy nothing.
/meta/cast/* — GET receivers, GET status, POST start|join|stop|leave|play|pause|next|previous|seek|playnow|jump|enqueue, PUT volume|mute — is the hub’s line to the Shell’s Cast host, and only the paired hub may use it. It rides the SMTC listener, so these routes exist only while that listener is enabled. Every call must carry the hub’s Secure Access Channel signature (X-MBXHub-SAC) over the method, path and body, from any address including this machine; a call without one, with a bad one, or to an unpaired Shell is 401 SAC_REQUIRED. There is no Origin rule because a browser is never the caller. POST /meta/cast/queue-config is reserved for native MusicBee configuration through the paired hub; it has no public proxy route. Pages and scripts use the hub’s /cast/*, which applies the hub’s access rules and forwards here — see Cast.
How a hub page opens a real desktop window. Served by the Shell and loopback only: the listener binds http://+:<smtc.port>/overlay/ and refuses, per request, every caller that is not on this machine, plus any Origin that is not the hub this Shell is paired with. The prefix is not the gate: under a http://+:<port>/ reservation http.sys fails any request no + registration covers, so an explicit 127.0.0.1 prefix there is bound and unreachable. A page discovers the bridge from GET /system/features → shellBridge rather than guessing the port; its binding reads wildcard normally, or loopback on a box with no wildcard reservation for the port.
Bridge liveness plus the three gates: ok, overlayEnabled, setsEnabled, charmWindows.
Open a hub page or an approved pixel source as an overlay window. Body carries windowId and exactly one of page or source (for example spout:IKANDY), plus optional label and anchor. A source must match the current registered window member and its Video grant; otherwise the Shell returns 403. page must be a hub-relative path starting with / and carrying no authority — an absolute URL, a protocol-relative //host, or a backslash is refused 400, which is what stops a page pointing an overlay window somewhere off-box. Re-posting a live windowId focuses that window rather than opening a second one.
Close that overlay window. POST and DELETE both require an Origin; a request from a disallowed origin is refused outright rather than answered without CORS headers.
Three routes accept a second spelling, so a caller that guesses the hyphen is not met with a 404: /player/play-pause, /player/queuerandom and /player/show-equalizer are the same endpoints as playpause, queue-random and show-equaliser.
v0.5.3.6 — Setlist-aware skip. When the playing file carries a Comment-embedded setlist (the SL-TPS detector returns true on its Comment), /player/next and /player/previous navigate by setlist entry instead of by queue file. next seeks to the next entry's startMs; from the last entry it falls through to Player_PlayNextTrack. previous follows standard transport behavior: within 2 seconds of an entry's start it seeks to the previous entry's startMs; after 2 seconds it restarts the current entry. From the first entry within the 2 second grace it falls through to Player_PlayPreviousTrack. Response carries scope: "setlist" + entryIndex (1-based) on setlist hits; falls back to the original { result: bool } shape for queue-file skips. Lookup re-parses Comment on each press with a 2 second share-window so consecutive button mashes don't re-parse but a user edit to the Comment is visible within ~2 seconds; track changes invalidate immediately.
PUT /player/volume accepts {"volume":50} (absolute) or {"delta":-5} (relative — resolved server-side against the current volume, clamped 0-100).
/player/update-play-statistics applies a caller-chosen countType (IncreasePlayCount, IncreaseSkipCount, NoChange) to a track (body: { "url", "countType"?, "disableScrobble"? }). Prefer /player/report-play below, which judges played vs skipped server-side. Returns 403 FEATURE_DISABLED when apiDisablePlayCountUpdates is on; gated by player access during party mode; shares the per-IP play-stat rate limit with /player/report-play.
Report a client-side (Listen Here) playback exit. The server applies MusicBee's own play-count thresholds (PlayCountTriggerPercent / PlayCountTriggerSeconds) to decide played vs skipped, then updates play statistics — so browser-streamed plays count exactly like speaker plays. For CUE virtual sub-tracks, send the raw file position plus cueStartMs; the listened window is measured against durationMs (the sub-track duration).
POST /player/report-play
{ "url": "C:\\Music\\track.mp3", "positionMs": 185000, "durationMs": 240000, "cueStartMs": 0 }
// Response:
{ "success": true, "data": { "url": "...", "counted": true, "countType": "IncreasePlayCount",
"playedPercent": 77.1, "playedSeconds": 185, "result": true } }
countType is IncreasePlayCount (past threshold), IncreaseSkipCount (exited early), or NoChange (position 0 — nothing recorded), or native-owned (acknowledged, nothing marked: counted: false, result: false). native-owned answers only while all three hold: the hub setting cast.dedicatedAccounting is exactly native (default cast; any other value reads as cast), the current cast is MusicBee-owned (casting enabled, no queue-configuration change pending; Shared contributions may be enabled), and MusicBee's active output is MBXCast. It replaces marks only: a report that would record nothing still answers NoChange, refusals keep their answers, and an unreadable output marks as before. A native-owned answer holds the duplicate window like a counted play. Under the default nothing changes: the MBXHub Shell's per-item Cast reports mark as they always have. Optional abandoned: true marks a non-deliberate exit (tab close, output toggle, external track change): past threshold it still counts a play, below it nothing is recorded — never a fabricated skip. Optional cue: true marks a CUE-backed source — required for a sheet's FIRST sub-track, whose cueStartMs is 0. When durationMs is omitted or 0, the server derives it from the CUE sheet for sub-tracks (next track start − this start; the sheet's last track has no derivable end, so only the seconds rule can count it as a play and no skip is ever fabricated) or the library's duration for whole files, so the percent threshold still applies. Counted plays also feed the AutoQ session signal and, when TrueShuffle is enabled, the shuffle played-set and AutoReset completion check — neither feature is required for counting. Duplicate reports from the same client for the same track (keyed url + cueStartMs, so CUE sub-tracks never collide) are absorbed within an adaptive window (deduped: true), and play-statistic updates share a per-IP rate limit (rateLimitPlayStatsPerMinute, 429 when exceeded; loopback exempt). Optional disableScrobble: true suppresses Last.fm scrobbling for this report. 404 if the file is not in the library or apiDisablePlayReporting/apiDisablePlayCountUpdates is enabled.
Returns current track info with full metadata
// Response:
{
"success": true,
"data": {
"playing": true,
"url": "C:\\Music\\Artist\\Album\\track.mp3",
"title": "Love Don't Live Here",
"artist": "Breaking Rust",
"album": "Greatest Hits",
"albumArtist": "Breaking Rust",
"year": "2024",
"genre": "Rock",
"trackNo": "3",
"discNo": "1",
"rating": "4.5",
"love": true,
"duration": 234000,
"position": 45000
}
}
Tag fields: TrackTitle, Artist, Album, AlbumArtist, Year, Genre, Rating, RatingLove, Comment, Composer, Conductor, TrackNo, DiscNo, Lyrics, Publisher
Properties: Bitrate, SampleRate, Channels, Duration, Size, DateAdded, DateModified, PlayCount, SkipCount, LastPlayed
Examples:
GET /nowplaying/tag?field=Artist → {"value": "Breaking Rust"}
GET /nowplaying/tag?field=Album → {"value": "Love Don't Live Here"}
GET /nowplaying/tag?field=Genre → {"value": "Rock"}
GET /nowplaying/tag?field=TrackNo → {"value": "3"}
GET /nowplaying/property?type=Bitrate → {"value": "320"}
GET /nowplaying/property?type=Duration → {"value": "234000"} (ms)
GET /nowplaying/property?type=PlayCount → {"value": "42"}
Returns album artwork as binary image or URL
Returns the current track's lyrics. Four response shapes:
{ hasLyrics: false, lyrics: null } — no lyrics and no fallback.{ hasLyrics: true, lyrics, source: "lyrics" } — real MusicBee lyrics, no sibling comment.{ hasLyrics: true, lyrics, source: "lyrics", comment } — real MusicBee lyrics with a sibling Comment tag. Clients can render a Lyrics/Comment toggle.{ hasLyrics: true, lyrics, source: "comment", label } — fallback from the track Comment tag (great for concert setlists, album liner notes). label is the chip text shown above the body in the UI.Both the sibling comment field and the fallback path are configured via the LyricsFallback section (Enabled, MaxDisplayChars, Label) and can be hard-killed via ApiDisableLyricsFallback.
/nowplaying/artist-picture-urls — returns a pictures array with src URLs (serveable via /nowplaying/artist-pictures/{index}) in addition to raw file paths.
/nowplaying/artist-pictures/{index} — serve current artist’s Nth picture as binary image (0-based). Content-Type auto-detected; Cache-Control: max-age=3600. Query: ?localOnly=true (default). A remote (http/https) picture over 16 MiB answers 502 ARTWORK_TOO_LARGE.
FFT spectrum and waveform data for visualizations
Current stereo peak and RMS levels (0.0–1.0). Returns {peak: [L, R], rms: [L, R]}. Requires MusicBee 3.6+ (API rev 58+).
Returns the now playing list. Supports ?offset=0&limit=50; limit=0 returns every row from the offset. To read Up Next, start AT the cursor (GET /queue/current, then ?offset=<currentIndex>&limit=0) — a default page stops at row 50 while upcomingTotal counts the whole list, and past that point the two describe different things. MusicBee keeps ONE now-playing list and shows two views of it: Playing Tracks (every row) and Up Next (what will actually play, in play order). Removing a track from Up Next leaves its row in the list and does not mark it played, so the raw list contains rows that will never play. Each track therefore carries upcoming — true when MusicBee's own play order still includes it. Render Up Next by keeping upcoming: true rows; upcomingTotal is the count of those across the WHOLE list, which is not derivable from total - currentIndex - 1 (that counts removed rows) nor from a filtered page. Each track carries an optional provenance object — who filled it and why (driver: AutoQ | TrueShuffle | Journey | Station | Manual, a short reason, and whenUtc). Omitted for tracks with no stamp (older fills or direct MusicBee queue adds). playOrder lists the indices MusicBee will play in the order it will play them, shuffle and Up Next order included: upcoming says whether a row plays, playOrder says when. It always covers the whole queue whatever limit pages, because order cannot be read off one page, and it is the same walk as upcoming, so the two never disagree — a row whose file cannot be read still counts, because it is still queued. A Listen Here takeover copies it to carry the room’s queue onto this device.
// Response:
{
"success": true,
"data": {
"currentIndex": 5,
"total": 150,
"upcomingTotal": 142,
"playOrder": [6, 7, 8, 9],
"offset": 0,
"limit": 50,
"tracks": [
{
"index": 0,
"url": "C:\\Music\\track1.mp3",
"title": "First Track",
"artist": "Artist Name",
"album": "Album Name",
"duration": 234000,
"upcoming": true,
"provenance": {
"driver": "AutoQ",
"reason": "mood:Energetic magnet",
"whenUtc": "2026-07-26T12:00:00.0000000Z"
}
},
// ... more tracks (provenance omitted when unknown)
]
}
}
?view=play — the rows in MusicBee’s play order, opt-in. Only the exact value play selects it: no view, an empty one or any other value (Play included) is the list view above, byte for byte, and never carries view or viewTotal. The sequence is the current row, then each playOrder index once — indices are de-duplicated, never paths, so a file queued twice is two rows; currentIndex below 0 is an empty sequence. offset/limit page positions in that sequence by the list’s rules (default 50, limit=0 = every row from the offset, at most 10000 rows per page — the same cap; an offset past the end is an empty page). from=<index> instead of offset starts the page at that MusicBee row’s place in the sequence, that row first; from with offset is 400 INVALID_REQUEST. The answer adds view: "play" and viewTotal (the sequence’s length before paging); total stays the list’s length, offset is the position the page starts at, and currentIndex, playOrder, upcomingTotal and each row’s upcoming keep their meanings. Every row keeps its original list index (what /queue/play, /queue/move and DELETE /queue/{index} take) and, in this view only, carries kind (MusicBee’s codec name, e.g. ALAC audio file; "" when unknown). CUE rows keep their own cueTrack and cueStartMs in any order. 409 QUEUE_CHANGED when a native read is incomplete, its version/current track changes during capture or metadata collection, an index is outside the captured list, or from is not in the sequence: read it again — the answer is never shortened to hide it. 409 QUEUE_CURRENT_MISMATCH when MusicBee is playing a different file than its list’s current row (not while stopped): reading again does not clear it; it clears when MusicBee changes track. The MBXHub Shell’s cast reads MusicBee’s queue this way, so a Shell of this version needs a hub that has this view.
Complete native identity: GET /queue?view=play&identity=complete&limit=80 adds data.identity with schemaVersion: 1, complete: true, an opaque observation, nullable queueVersion, and rows containing every physical row's rawUrl and nullable rawTrackNo. Array position is the native index; duplicate paths remain distinct rows. The manifest is uncapped; only selected track metadata is paged. Start at offset zero. A later from or nonzero offset requires expectedObservation; it is also allowed at zero. Invalid combinations return 400 INVALID_REQUEST; changed context returns 409 QUEUE_CHANGED. Never combine pages from different observations. The token is equality context, not authorization or durable occurrence identity. Selected tracks echo both raw fields and explicitly include cueTrack and cueStartMs (both zero for ordinary files). CUE identity must resolve from exact source membership and native track number; ambiguity or invalid CUE metadata returns 409 CUE_IDENTITY_UNRESOLVED. The resolved url may differ from rawUrl. Native descriptors, current row and play order are rechecked after metadata hydration. Without this opt-in, existing response fields are unchanged.
// GET /queue?view=play&limit=3 - 30,001 rows, MusicBee at row 9999, shuffled (rows abbreviated)
{
"success": true,
"data": {
"view": "play",
"viewTotal": 4,
"currentIndex": 9999,
"total": 30001,
"upcomingTotal": 3,
"playOrder": [25000, 10000, 30000],
"offset": 0,
"limit": 3,
"tracks": [
{ "index": 9999, "url": "C:\\Music\\current.mp3", "upcoming": false, "kind": "MPEG audio file" },
{ "index": 25000, "url": "C:\\Music\\next.flac", "upcoming": true, "kind": "FLAC audio file" },
{ "index": 10000, "url": "C:\\Music\\after-next.m4a", "upcoming": true, "kind": "ALAC audio file" }
]
}
}
// POST /queue/add - Add tracks to queue (position: "next" or "last")
{"urls": ["C:\\Music\\song1.mp3", "C:\\Music\\song2.mp3"], "position": "last"}
// POST /queue/add - one track of a CUE album: pass the file url and cueTrack.
// The response's seekPosition is that track's start (ms); when the row comes up,
// playback starts there instead of at track 1. Single url only - a batch ignores cueTrack.
{"url": "C:\\Music\\live-show.flac", "cueTrack": 3, "position": "next"}
// or single track:
{"url": "C:\\Music\\song.mp3", "position": "next"}
// Response: {"success": true, "data": {"result": true, "added": 2, "position": "last"}}
// Every url has to be a library entry. A file MusicBee does not hold is 404 NOT_FOUND
// (File not in library: <url>) before anything is queued; a batch holding one is refused whole.
// The same boundary applies to /queue/playnow and /queue/play-list.
// POST /queue/playnow - Play track immediately
{"url": "C:\\Music\\song.mp3"}
// POST /queue/play-list - Play a whole list in ONE call, in order, atomically.
// position: "replace" (default) starts the first track now and queues the rest
// behind it in one array call; "next" and "last" leave playback alone.
// A first track MusicBee refuses is 500 PLAYBACK_REJECTED with nothing queued.
// Host only: a party guest gets 403 for every position; requestId is ignored.
// No cueTrack - it is meaningless for a list; a numeric #N fragment is stripped.
{"urls": ["C:\\Music\\a.flac", "C:\\Music\\b.flac", "C:\\Music\\c.flac"], "position": "replace"}
// Response: {"success": true, "data": {"result": true, "queued": 3, "position": "replace"}}
// POST /library/albums/{album}/play - the same, by NAME: the hub resolves the tracks
// (exact album name; albumArtist picks one of two albums sharing it, omit it for every
// album of that name), orders them disc then track, and plays them through the same
// path, so the response, the host-only gate and the refusals are the ones above.
// A name nothing matches is 404 NOT_FOUND and nothing changes. Path segment is percent-encoded.
// POST /library/albums/Tell%20Me%20I%27m%20Pretty/play
{"albumArtist": "Cage The Elephant", "position": "replace"}
// POST /library/genres/{genre}/play - every track of a genre, in the library's default order.
// POST /library/genres/Jazz/play
{"position": "last"}
// POST /queue/play - Play track at index
{"index": 3}
// POST /queue/move - Move track in queue
{"from": 5, "to": 2}
MBXHub settings → API Access → Disable Features → Undo History blocks GET /undo, POST /undo and GET /actions with 404 NOT_FOUND for all API clients, including localhost. The setting is apiDisableUndoHistory (default false); GET /system/features reports undoHistory. It hides the web Undo offer. The local journal keeps recording and Workbench Ctrl+Z still works through direct calls inside the plugin.
The hub keeps an action journal: every change it observes to the queue, volume, mute, bans, ratings, love and skips, newest first, with who asked for it. Undo reads that journal and goes one step back: it reverses the newest user action that has not already been undone. Changes the user did not ask for are never offered: AutoQ top-ups and fills, automatic skips, and the writes an undo itself makes are recorded as automatic, and playback advancing to the next track is not recorded at all. After an undo, nothing is offered until a new user action lands; Undo does not undo itself and does not walk back through history. There is no timer. The journal keeps the last 200 entries in memory only: a restart clears it.
Queue Undo is for the mistake you just made. It captures the complete upcoming queue, with no 500- or 700-track cutoff. There is one offer: playback advancing, another queue operation (including AutoQ changes), or turning shuffle or repeat on or off expires it. A new settled user edit creates a new offer. Native playback, Cast item changes and accepted Listen Here track changes invalidate it; ordinary position updates do not. Undo restores the complete snapshot only while it is valid, never replays already-played tracks or merges older edits into a changed queue. An incomplete queue read is refused before any restore writes.
Two doors reach the same record, so an action taken on one surface can be undone from the other: Ctrl+K on the web pages lists Undo: <what> as its first row when there is something to undo, and Ctrl+Z in the AutoQ Workbench. The dashboard's ban gesture is unchanged: ban a track (it skips), then press Previous within 10 seconds and the ban is lifted.
What one Undo would reverse right now. A read: needs no write permission and changes nothing; returns 404 when Undo History API access is disabled. Returns available; when it is true, also id, kind (queue | volume | mute | ban | rating | love | skip) and description. Send the id back as expectedId so the POST can only reverse what you showed.
// Nothing to undo:
{"success": true, "data": {"available": false}}
// Something to undo:
{
"success": true,
"data": {
"available": true,
"id": 42,
"kind": "volume",
"description": "volume 40 → 12"
}
}
Reverse the entry on offer. Optional body {"expectedId": n}: when present, only that entry may be reversed. No body means the newest entry on offer. Each kind is gated by the permissions needed for its reversal: queue requires all three Add, Remove and Reorder permissions because restoration rebuilds the upcoming queue; volume and mute require player volume, skip requires player playback, ban requires shuffle (as POST /banlist), and rating and love require the library tag-edit switch. Refusals leave the entry on offer.
// POST /undo
{"expectedId": 42}
// Response:
{"success": true, "data": {"message": "Volume back to 40", "kind": "volume"}}
| Status | Code | Meaning |
|---|---|---|
| 200 | — | Reversed. message says what was put back. |
| 409 | NOTHING_TO_UNDO | There is nothing to undo. |
| 409 | UNDO_CHANGED | The entry on offer is not expectedId, or a new action landed while the request was being checked. Nothing was touched: “Something else happened first — open Undo again”. |
| 403 | the gate’s own, e.g. ACCESS_DENIED | This kind’s permission refuses the caller — the same answer the action’s own route gives. |
| 404 | NOT_FOUND | Undo History API access is disabled, or love or ratings have been disabled since the action was taken. |
| 400 | INVALID_REQUEST | The body is not JSON, or expectedId is not a whole number. |
| 500 | UNDO_FAILED | MusicBee would not carry the reversal out. Retry is available only while the offer remains valid. A queue undo that MusicBee refuses after it has removed the upcoming rows puts those rows back and is not offered again. |
| 503 | NOT_READY | MBXHub is starting up. |
The action journal, newest first, automatic and undone entries included. A read: needs no write permission; returns 404 when Undo History API access is disabled. Query: ?limit= 1–200 (default 50; anything else answers 400 INVALID_LIMIT), ?address= only the entries made from that client address. Each entry: id, kind, origin (user | automatic), source (unknown | webApp | workbench | api | musicBee | autoQ | undo), clientAddress, clientName, description, undone, at (UTC). clientAddress and clientName are absent when the client is not known — changes made in MusicBee’s own window carry none, and a name is only ever one the hub has observed. Descriptions only: no track paths or file URLs. Answers 503 NOT_READY while MBXHub is starting up.
// GET /actions?limit=2
{
"success": true,
"data": {
"actions": [
{
"id": 43,
"kind": "queue",
"origin": "automatic",
"source": "autoQ",
"description": "AutoQ added 3 to the queue",
"undone": false,
"at": "2026-09-22T18:04:11Z"
},
{
"id": 42,
"kind": "volume",
"origin": "user",
"source": "webApp",
"clientAddress": "192.168.1.20",
"clientName": "Living room tablet",
"description": "volume 40 → 12",
"undone": true,
"at": "2026-09-22T18:04:02Z"
}
]
}
}
Query library with ?query=, ?artist=, ?albumArtist=, ?album=, ?genre=, ?people=, ?sort=, ?include=, ?roles=.
The url field is the track's identifier. It is the file path as MusicBee holds it on the host — a Windows path such as C:\Music\Artist\track.mp3, not a file:// URI — resolved on the host and never opened by the client. Pass it back verbatim wherever a track is named (/stream/, /queue/add, /playlists), percent-encoded in a path segment. total counts tracks after CUE expansion and is the same for every page size, ?limit=1 included.
v0.5.3.4 — ?people= on album expansion. When ?album=Y&people=X is set the post-filter probes MB's full role-union per track — Artists (144 — Artist + Performer + Guest + Remixer), Composer (43), and Conductor (45) — rather than relying on the ArtistPeople XmlFilter alone. The XmlFilter omits the GuestArtist role bucket, so a track tagged with the queried person as guest (e.g. David Guetta — The Whisperer (feat. Sia)) was previously dropped from expansion even though the album-by-artist count showed it. Whole-library ?people= (no ?album=) still uses the fast ArtistPeople XmlFilter path; pure-guest credits won't surface there but are reachable by drilling into the specific album.
v0.5.3.4 — ?include=<TagName>. Each returned track row carries extraField + extraValue with the per-track value of the requested tag. Accepts any tag from the 48-tag customSorts allowlist: Custom1..Custom16, Virtual1..Virtual25, Year, OriginalYear, SortAlbum, SortAlbumArtist, SortArtist, SortTitle, SortComposer. Unknown values are silently ignored. Used by browse.html to surface the active custom sort's per-track value when expanding an album under a custom-* sort.
v0.5.3.4 — ?roles=true. Adds every per-track role-credit field MB tracks separately: artistsRole (145), performers (146), guests (147), remixers (148), composers (43), conductors (45), artistsUnion (144 — the full role-union). Raw strings preserved (mixed separators across tag implementations). Default off so regular dashboard queries don't pay for the extra Library_GetFileTag reads; only paid for when explicitly requested.
// GET /library/files?artist=Breaking%20Rust&limit=10
{
"success": true,
"data": {
"total": 42,
"offset": 0,
"limit": 10,
"tracks": [
{
"url": "C:\\Music\\Breaking Rust\\track.mp3",
"title": "Love Don't Live Here",
"artist": "Breaking Rust",
"album": "Greatest Hits",
"duration": 234000
},
// ... more tracks
]
}
}
Full-text search: ?q=search+term&sort=alpha (searches title, artist, album, genre; diacritic and punctuation normalized so "cafe" matches "Café" and "acdc" matches "AC/DC"). Two matching modes:
The default mode is controlled by the Library.DisableStrictSearch setting (false by default = strict). Override per-call with ?substring=true|false. The response payload includes a mode field echoing which mode was used.
v0.5.3.0: DSL auto-detection is on by default (library.search.dsl.enabled = true). Qualifier syntax (artist:, album:, genre:, year:, rating:, fmt:, range / boolean / grouping operators) routes through SearchDslParser automatically when detected; plain free-text queries take the strict/substring path as before. Force per-call with ?dsl=true; set library.search.dsl.enabled = false to require the explicit opt-in. Cheat sheet at GET /library/search/syntax.
Unreleased — any-field search. The DSL field registry now covers every MusicBee custom slot: custom1:…custom20: translate to a Field="CustomN" condition (e.g. custom7:live; Custom17–20 arrived with MusicBee 3.5). Combine with existing qualifiers as usual (albumartist:"various" custom7:live). Mood-position filtering uses the first-class arousal:/valence: range qualifiers (0–1, e.g. arousal:>0.7) — see the qualifier table above.
v0.5.3.0 backstop: queries shorter than search.live.minQueryLength (default 2) return 400 QUERY_TOO_SHORT with body { "error": "Query must be at least N characters (got M)." }. Single-char walks visit every track on a 200k library (~4 s) and stall every other MB-API consumer for the duration of the cursor lock; the backstop protects fleet-wide responsiveness from runaway non-conforming clients (curl, scripts). Set search.live.minQueryLength = 0 to disable.
v0.5.3.0. Returns the DSL grammar as JSON (qualifiers, operators, worked examples). Authoritative source — the reference below is rendered from the same data shape. Used by Cmd+K's cheatsheet and any other client surfacing DSL syntax to users.
Response shape: { version, qualifiers: [{ name, type, allowsRange, allowsOperators, filterable, mapping, enumValues }], operators: [{ symbol, description }], examples: [{ query, description }] }. filterable (bool) marks the qualifiers the AutoQ candidate filter can evaluate — a filter-scoped client derives its allow-list from this flag rather than hardcoding one.
The DSL layers on top of free-text search: every plain word still matches normally, qualifiers narrow the result, operators compose. Auto-detected on /library/search and /search when library.search.dsl.enabled = true (default); force per-call with ?dsl=true.
| Qualifier | Type | Range / Ops | Maps to | Notes |
|---|---|---|---|---|
artist: | string | — | ArtistPeople | Includes featured / album artists. |
album: | string | — | Album | |
albumartist: | string | — | AlbumArtist | Album-level artist (compilation-aware). |
genre: | string | — | Genre | Multi-value genre tags split client-side. |
year: | int | range + ops | Year | e.g. year:1985, year:1985..1990, year:>=2000. |
decade: | enum | — | derived from Year | Values: 60s, 70s, 80s, 90s, 00s, 10s, 20s. |
rating: | int | range + ops | Rating (0–5) | e.g. rating:>=4, rating:3..5. Also takes the literal none: rating:none is every unrated track (no stars), -rating:none every track that has any rating. |
loved: | bool | — | Love (“L”) | loved:true / loved:false. Runs in the MusicBee query itself, so it composes inside an OR — loved:true OR rating:>=4 is one query, not two halves. |
bpm: | int | range + ops | Tempo (Truedat / Essentia) | Requires fingerprint or mood-cache data. |
mood: | string | — | AutoQ mood channel | Post-filter — exact, case-insensitive match on the track’s best mood channel from the mood cache (e.g. mood:chill). Scanned tracks only; a cold mood cache matches nothing rather than everything. Combine channels with OR. |
vibe: | float | range + ops | AutoQ vibe score | 0–1. e.g. vibe:>=0.7. |
source: | enum | — | MB Source Type | Values: library, inbox, audiobooks, videos, podcasts. Default from library.search.dsl.defaultSource. |
playlist: | string | — | playlist filter | Post-process — intersects with named playlist membership. |
added: | date | range + ops | DateAdded | Accepts absolute dates (2025-01-01) and relative (now-7d); user aliases via search.dsl.dateAliases. |
played: | date | range + ops | DateLastPlayed | Same date format as added:. |
playcount: | int | range + ops | PlayCount | e.g. playcount:>10, playcount:0 (never played). |
duration: | int (seconds) | range + ops | Duration | e.g. duration:>3600 (over an hour). |
path: | string | — | FilePath substring | Case-insensitive substring match on the full path. |
type: | string | — | file extension | Post-process — exact, case-insensitive match on the file extension (e.g. type:flac, type:mp3). Dot optional. Combine formats with OR. |
key: | string | — | Camelot mix key | Post-process — exact, case-insensitive match on the track’s Camelot code from the mood cache (e.g. key:9A, key:11B). Scanned tracks only — no key data never matches. Combine wheel-compatible codes with OR: key:9A OR key:9B OR key:8A OR key:10A. |
lyric: | string | — | lyrics body | Post-process — fetches lyrics per candidate. Expensive; gated on library.search.dsl.allowLyricSearch = true (default off). |
| Symbol | Meaning | Where it applies |
|---|---|---|
> | greater than | Qualifiers with allowsOperators. |
>= | greater than or equal | Same. |
< | less than | Same. |
<= | less than or equal | Same. |
.. | inclusive range | Qualifiers with allowsRange. low..high — both ends inclusive. |
- (prefix) | exclude | Negates a free-text term or qualifier (e.g. -genre:metal, -live). |
OR / or | boolean OR | Between sibling expressions. Default between terms is AND. |
( ... ) | grouping | Forces precedence inside a larger expression. |
| Query | Result |
|---|---|
artist:radiohead | Tracks by Radiohead. |
year:1985..1990 | Tracks released between 1985 and 1990 inclusive. |
year:1965 rating:>4 | 1965 tracks rated above 4 — combines two range/operator qualifiers (implicit AND). |
mood:chill rating:>=4 | Chill-mood tracks rated 4 or higher — mood is a post-filter applied after the rating cut. |
rock -genre:metal | Rock tracks excluding the metal genre — free-text plus exclusion. |
(rock or metal) -live | Rock or metal, but not live recordings — boolean OR plus exclusion. |
source:audiobooks duration:>3600 | Audiobooks longer than one hour — source scope plus duration operator. |
played:<now-30d playcount:>5 | Favorites you haven't played in the last 30 days — relative date plus playcount. |
decade:80s -genre:disco | 80s, no disco — decade enum plus genre exclusion. |
OR (or lowercase or) for disjunction; parentheses for grouping.genre:rock OR genre:metal — runs entirely in MusicBee's own engine. Put a group beside another condition — artist:davis (genre:rock OR genre:jazz) — and the group is evaluated here instead, because MusicBee silently drops the sibling condition when a group sits next to it (measured 2026-08-18: artist:davis matched nothing on its own, yet the combined query returned 3385 tracks). The results are the same either way; the difference is that the second shape is capped, and a query producing more candidates than library.search.dsl.maxPostFilterCandidates answers 413 POST_FILTER_CAP_EXCEEDED rather than quietly returning the wrong set. Add a selective condition, or write the group on its own.radiohead year:>=2000 filters Radiohead tracks from 2000 onward.mood, playlist, lyric) run after the candidate set is fetched, so they're capped by library.search.dsl.maxPostFilterCandidates (default 5000).422 INVALID_DSL with a parse position and Levenshtein-based “did you mean” suggestions.2025-01-01), relative (now-7d, now-30d), or named alias from search.dsl.dateAliases (defaults: lastweek = now-7d, lastmonth = now-30d, thisyear = now-365d; hand-editable, read on every query). Relative dates and aliases are resolved to a calendar date before MusicBee sees them. Any other spelling MusicBee reads as a date is passed through (2026/09/11, 9/11/2026, Sep 11 2026 — the machine’s own date format decides day-first or month-first). A value that is not a date (lastwek, 20260911) answers 422 INVALID_DSL naming it; before 2026-10-23 such a value, and every now-Nd or alias, silently matched the whole library.dateAdded and dateAddedSort — the raw MusicBee DateAdded string (the same value GET /library/file/{url} returns) and the same moment as yyyy-MM-ddTHH:mm:ss, or "" when it could not be read — so a client can order query-backed rows newest-first without a second call per track, and without running Date.parse over a locale string it cannot interpret the same way in every browser. On /library/search?dsl=true only; the non-DSL search rows and /library/files keep the shape they have.v0.5.3.0 federated search. Runs typed buckets (tracks / albums / artists / playlists / saved) in parallel and returns a unified response with cursor pagination on the tracks bucket, facet counts, and a top-hit. Backs the Cmd+K palette and (v0.5.3.0+) the dashboard search bar, player.html, browse.html, and explore.html.
DSL routing (v0.5.3.0 post-launch): /search auto-detects DSL queries the same way /library/search does — qualifier colons (year:, rating:, etc.), .. ranges, >/< comparisons, OR , leading - exclusion. When detected and Search.Dsl.Enabled=true (default), the query routes through the DSL pipeline and the response carries mode: "dsl". Per-call ?dsl=true still works as an explicit opt-in. Plain free-text queries continue to use strict/substring mode as before. Tracks bucket is sorted by match-strength score (title > album > artist, with prefix-match bonus) so the most-relevant result appears first regardless of mode.
Params: ?q=&buckets=tracks,albums,artists,playlists,saved&limit=N&cursor=opaque. Cursors are opaque and TTL-checked; stale → 410, malformed → 400, query-mismatch → 400. Opaque is not signed — the payload is base64url JSON any caller can read and mint, and nothing checks that it came from here. That is deliberate: the cursor is advisory, ?offset= reaches exactly the same rows, and pagination is not an access boundary. Treat a cursor as a position, never as a permission. Same QUERY_TOO_SHORT backstop as /library/search. v0.5.3.0: per-bucket ?limit= hard cap raised from 200 to 500 (browse.html aggregates client-side over the tracks bucket and needs the headroom).
Artists bucket (2026-10-29). The artists bucket is always one row per person: rows come from MusicBee’s artists collection (the artists key tag), split on ; and merged case-insensitively with trackCount summed — timbaland finds Timbaland on every install, whatever the Browse setting splitArtists says (that switch governs /library/artists and /library/genres only; a search names a term). A split part is kept only when its name matches the query by the same word-prefix rule as the rest of search — strict by default, so timb finds Timbaland and ranger does not find Stranger; ?substring=true loosens it — a track that matched by title does not put its people in this bucket, and nothing is regex-split; MusicBee’s Multiple Artist Splitter decides where a name ends. Every consumer of the bucket (the Cmd+K palette included) sees the same rows. The bucket has its own page size: ?artistsLimit=N (optional; default = limit; same clamp; artists bucket only), because each kept row costs one album lookup — browse.html asks artistsLimit=50 beside limit=500, and every bucket’s limit field echoes the page size it ran with. The envelope carries splitArtists (boolean, always true: the shape this payload carries, kept because clients read it), and every tracks-bucket row carries artists (string array: the people on the track per MusicBee’s own collection — MultiArtist, else Artists, else the displayed artist) beside the unchanged artist string. Both ride the DSL path too. browse.html builds its search Artists section as the artists bucket plus the people of the matched tracks (their artists), so a track that matched by title still credits its people, one row each — a search never shows a composite row beside a split one.
v0.5.3.0. Live engine + index state snapshot. Currently reports engine: "mb" (FTS5 substrate dropped during the post-decouple harvest; seam preserved for future engines).
Persist a query under a name and re-run on a schedule. Backed by mbxhub-search.json next to mbxhub.json. Disabled by default; enable via library.searchDsl.savedSearch.enabled. Disabled endpoints return 404 NOT_FOUND. Background SavedSearchScheduler ticks each saved search's interval, evaluates, diffs against last-match URLs, and broadcasts the SearchMatched WebSocket event when matches change.
Validation: name required (1–80 chars, unique per host case-insensitive → 409 on duplicate); query parsed via the DSL (422 INVALID_DSL on parse error with parse position).
Mutation routes (POST / PUT / DELETE / run) return 403 FORBIDDEN when ApiReadOnlyMode is set — kiosk deployments can’t have saved searches mutated by remote clients.
Server-side recent-search log shared by Cmd+K and any other search bar. Falls through to client localStorage if the endpoint is missing or returns 404.
GET ?limit=N — newest-first. limit defaults 20, caps at 200. POST body {query, source} — empty body clears (returns {cleared:true}). DELETE — wipe all entries.
POST and DELETE return 403 FORBIDDEN when ApiReadOnlyMode is set.
Every album row here — detailed, unheard, with-pdf, with-video, and the /library/slices previews — carries dateAddedSort beside dateAdded: the same moment as yyyy-MM-ddTHH:mm:ss, or "" when the hub could not read it. dateAdded is MusicBee’s LOCALE text, and Date.parse of a non-ISO string is implementation defined — on a day-first hub a client ordered rows differently in different browsers. Sort on dateAddedSort; fall back to parsing dateAdded only for an older hub that omits it.
/library/albums/detailed returns albums with firstTrackUrl, year, dateAdded, and virtualKind ("cue" | "setlist" | null) for artwork lookups and sorting. virtualKind identifies CUE-split or setlist virtual tracklists; drives the CUE / SET LIST badge in browse and on the dashboard now-playing card. Eliminates per-album /library/files?limit=1 round-trips. Params: ?offset=&limit=
/library/album-artists returns distinct album artists. Params: ?offset=&limit=
/library/artists and /library/genres list one row per distinct tag value as MusicBee stores it, so a track tagged A; B is its own row. With the Browse setting splitArtists on (off by default), the artist list reads MusicBee’s artists collection (the artists key tag, separators normalized by the Multiple Artist Splitter) and split on ;, and the genre list splits the same way — one row per person or genre, counts summed across every row a name appeared on, no row carrying a ;. The hub splits on nothing else: a name the Multiple Artist Splitter left whole stays one name. The ?artist= drilldown resolves through ArtistPeople either way, so a split list matches what a click finds. The switch governs the lists only: the /search artists bucket (and with it Cmd+K and browse.html’s search Artists section) is always one row per person — a search names a term (see /search).
/library/albums/unheard returns albums where all tracks have playCount=0. /library/albums/with-pdf returns albums containing PDF booklets. /library/albums/with-video returns albums whose folder contains a video file (derived from the Video library, Source Type 64, matched by folder). All support ?offset=&limit=
/library/inbox, /library/audiobooks, and /library/videos query MusicBee library categories (Source Types 4, 32, 64). Browse page shows these tabs only when non-empty (progressive reveal).
One row per value of a field, largest first — the view behind /pages/slices.html. Params: ?by=genre|year|decade|rating|mood (required; anything else is 400 BAD_FIELD), &top= how many values get a preview row (defaults to slices.topN, clamped to 1–200), &preview= covers per row (defaults to slices.rowLength, clamped to 0–200, where 0 means every album in the value). top and preview come back clamped, not as they were sent. total is the number of distinct values, i.e. values.length + more.length; everything past top lands in more as name+count for the page’s More… list. Each preview row is exactly a /library/albums/detailed row, so artwork and verbs are the existing ones. Computed over the in-memory album index.
Which row an album lands in. genre: every genre tagged on the album, so a multi-genre album is in several rows — and an album with no genre is in no row, because genre has no missing bucket. year: the first four digits of the album’s Year tag, or "Unknown". decade: that year floored to ten and suffixed, e.g. "1990s", or "Unknown". rating: the album-rating tag when it is set, otherwise the mean of the album’s rated tracks rounded half-up, otherwise "Unrated". mood: the mood channel most of the album’s analyzed tracks fall in (ties break by channel name), or "No mood data" when none is analyzed. Values sort by album count descending, then by name; the missing bucket ("Unknown" / "Unrated" / "No mood data") always sorts last. Preview albums are newest-added first, then by name.
by=mood is the one expensive field — one channel lookup per track — so the per-album tally is cached and thrown away when the album index publishes a new snapshot. A mood source that throws answers 500 rather than reporting the whole library as having no mood data.
// GET /library/slices?by=genre&top=2&preview=1
{
"success": true,
"data": {
"by": "genre",
"total": 87,
"top": 2,
"preview": 1,
"values": [
{
"value": "Rock",
"albums": 412,
"preview": [
{
"name": "The Dark Side of the Moon",
"count": 10,
"firstTrackUrl": "C:\\Music\\Pink Floyd\\Dark Side\\01.flac",
"year": "1973",
"dateAdded": "12/15/2025 2:32 PM",
"dateAddedSort": "2025-12-15T14:32:00",
"albumArtist": "Pink Floyd",
"sortAlbumArtist": "Pink Floyd",
"virtualKind": null
}
]
},
{ "value": "Jazz", "albums": 188, "preview": [ /* ... */ ] }
],
"more": [
{ "value": "Ska", "albums": 7 }
]
}
}
Evaluate a MusicBee expression: ?expression=<Artist> - <Title>&fileUrl=C:\Music\track.mp3. If fileUrl is omitted, evaluates against the currently playing track. Returns {expression, fileUrl, result}. Supports MusicBee template syntax (<Artist>, $If(), virtual tags).
MusicBee’s built-in placeholder image for tracks with no artwork. Returns binary image data with appropriate content type. Cache-Control: 24h.
Albums for an artist with year, track count, firstTrackUrl for artwork, and virtualKind ("cue" | "setlist" | null). Params: ?albumArtist= or ?artist= (one required), ?sort=alpha|year|year-asc. ?artist= queries ArtistPeople (broader match), ?albumArtist= queries AlbumArtist (exact album credit).
// GET /library/albums/by-artist?albumArtist=Pink%20Floyd&sort=year
{
"success": true,
"data": {
"albumArtist": "Pink Floyd",
"total": 3,
"sort": "year",
"albums": [
{
"name": "The Dark Side of the Moon",
"year": "1973",
"count": 10,
"firstTrackUrl": "C:\\Music\\Artist\\Album\\track.mp3"
}
]
}
}
Note: URL-encode the file path in the URL (e.g., /library/file/file%3A%2F%2F%2FC%3A%2FMusic%2Ftrack.mp3)
POST /library/files/batch returns the title, artist and album of up to 200 files in one call, in the order the urls were sent, each url echoed exactly as given; a file the library does not hold comes back with an empty title, so show its file name instead.
// GET /library/file/{url} - Extended track metadata (includes playCount, lastPlayed, etc.)
{
"success": true,
"data": {
"url": "C:\\Music\\Artist\\Album\\track.mp3",
"title": "Love Don't Live Here",
"artist": "Breaking Rust",
"album": "Greatest Hits",
"duration": 234000,
"albumArtist": "Breaking Rust",
"genre": "Rock",
"year": "2024",
"trackNo": "3",
"discNo": "1",
"rating": "4.5",
"composer": "J. Smith",
"bitrate": "320",
"format": "MPEG Audio",
"sampleRate": "44100",
"playCount": 42,
"dateAdded": "2024-01-15",
"lastPlayed": "2024-01-20"
}
}
// PUT /library/file/{url} - Update metadata (fields: title, artist, album, albumArtist, genre, year, trackNo, discNo, composer, comment, rating)
{"rating": "5", "comment": "Great track!"}
// Response: {"success": true, "data": {"result": true, "updated": ["rating", "comment"]}}
// POST /library/artwork/batch - Batch artwork fetch (max 50 URLs per request)
// Request: {"urls": ["D:\\Music\\track1.mp3", "D:\\Music\\track2.flac"]}
// Response: {"success": true, "data": {"D:\\Music\\track1.mp3": "data:image/jpeg;base64,/9j/...", "D:\\Music\\track2.flac": null}}
// POST /library/files/batch - Titles for many files (max 200 URLs per request), in request order
// Request: {"urls": ["D:\\Music\\track1.mp3", "D:\\Music\\not-in-library.mp3"]}
// Response: {"success": true, "data": {"tracks": [{"url": "D:\\Music\\track1.mp3", "title": "Love Don't Live Here", "artist": "Breaking Rust", "album": "Greatest Hits"}, {"url": "D:\\Music\\not-in-library.mp3", "title": "", "artist": "", "album": ""}]}}
// Errors: 400 INVALID_REQUEST "Missing 'urls' array" / "Maximum 200 URLs per batch"
// POST /library/commit - Commit pending tag changes to file
// Use after batching Library_SetFileTag RPC calls
// Request: {"file": "D:\\Music\\track.mp3"}
// Response: {"success": true, "data": {"result": true}}
/artwork-count returns 0 if the file has no embedded artwork, 1 otherwise. Internally probes up to 20 embedded-artwork locations (MusicBee returns the same cover at multiple locations — EmbedInFile, LinkToSource, FolderThumb —) and picks the largest byte-size as the canonical image; subsequent /artwork?index=0 requests serve that best variant. Cached per fileUrl. /pdf serves the PDF booklet from the track's album folder. /has-pdf checks existence without downloading.
v0.5.3.5. When a track's Comment tag contains a time-coded set list (≥3 monotonically increasing entries — common on bootlegs / live broadcasts / podcasts), these endpoints surface the parsed entries and let one click write a real CUE sidecar. Parsing follows a formal grammar (SL-TPS v1.0).
Accepted line formats. Each candidate line is tested independently (multiline-anchored regex, ^…$), with an optional leading bullet (- / • / *) and an optional decorative index prefix (digits, optional . or ), optional - / en-dash / em-dash). After those, the line must match one of two core shapes:
[HH:]MM:SS [optional separator] TITLETITLE [optional separator] [HH:]MM:SSThe optional separator between time and title accepts -, –, —, or : followed by whitespace (the colon variant is common on YouTube-comment paste-throughs, e.g. 02:46 : Last Train). The timestamp itself may also be wrapped in parens, e.g. (0:11) Live In The Moment — common on YouTube / Reddit comment setlists; title-internal parens (e.g. Don't Look Back In Anger (Oasis cover)) are preserved verbatim. Hours are optional; minutes 1-2 digits; seconds exactly 2 digits. Worked examples that all match: 0:40 - Catch These Fists, 0:00:00 Sweet Lies, 01 - The SoundMaker - 00:00, 1. Amyl And The Sniffers - Foo 00:00, Man Made Of Meat 01:09, 02:46 : Last Train, (0:11) Live In The Moment. Index prefixes in the text are decorative; the parser assigns sequential 1, 2, 3, … from match order (intrinsic ordering per spec §5). Lines that don't match (URLs, freeform notes, blank lines) are silently skipped — they don't kill detection, they just don't contribute to the count.
GET /library/file/{urlHash}/setlist returns { fileUrl, fileDurationMs, entries:[{index, startMs, title, rawTime, cueTime}], trailingNotes, parsedAt, extractedCueExists, extractedCuePath }. Each entry carries the raw matched timecode (rawTime, e.g. "0:40") and the CUE MM:SS:FF form (cueTime, e.g. "00:40:00", 75 fps) so consumers don't re-derive them. fileDurationMs is the parent file's length so the Tracklist UI can compute per-entry durations including the trailing entry — 0 when a paired CUE exists (signals: trailing-duration math unreliable). 404 NO_SETLIST if the Comment doesn't match the heuristic.
Computing {urlHash}. The input is the track's url — the url field returned by /library/files or /nowplaying. The hash is: SHA-1 of the UTF-8 bytes of that exact url string → keep the first 16 bytes → base64-encode → make URL-safe (+→-, /→_, strip trailing =). Result is a 22-char string. Example pseudocode: base64url(sha1(utf8(url))[0:16]). Browser clients can call MBXShared.urlHashOf(url) (shared.js), which returns the identical value on both secure and non-secure (LAN HTTP) contexts.
POST /library/file/{urlHash}/extract-setlist-cue, body { overwrite: bool } (default false): parses the Comment, builds a real CUE sidecar at <audio-basename>.cue (UTF-8 with BOM), written atomically (temp → replace → .bak). On success the album index is invalidated so the next browse / search / now-playing read returns the virtual tracks. Errors: 409 CUE_EXISTS when a sidecar already exists and overwrite=false (client switches CTA to “Replace existing CUE”), 400 NO_SETLIST, 500 WRITE_FAILED.
Consumers: ?setlist=hint on /library/files emits hasSetlist:bool per row (browse drilldowns surface the SET LIST badge). /nowplaying emits hasSetlist for the current track (drives the dashboard SET LIST chip, the same badge over the HUD's artwork — where cueTrack > 0 makes it read CUE instead, since a virtual track's Comment still carries the parent file's set list — the Tracklist tab on nowplaying.html, and the wavescrubber chapter ticks on play.html — the latter pulls entries from /library/file/{urlHash}/setlist when MB is still playing the parent file as a single item, before CUE extraction virtualizes it). After extraction the album's virtual tracks expose cueStartMs per row.
Duration sanity: a setlist's last timestamp must fall before the file's duration (catches Comments pasted from a different recording); otherwise detection returns false. Concurrent extracts can't race (per-call temp file) and failed writes don't leak it. Requires library-tag write access, so PartyMode guests cannot write.
Deep-link query params (no new endpoints): /pages/nowplaying.html?tab=tracklist opens the standalone page with the segmented control switched to Tracklist on first successful setlist load; used by the dashboard SET LIST badge. /pages/play.html?np-tab=tracklist opens the full-player chrome and propagates &tab=tracklist to the middle-pane NP iframe — used when DashboardLayout.liveSetBadgeTarget is set to "play" (default "nowplaying"). /pages/browse.html?album=&artist=&trackUrl=<hash> scrolls the matching row into view with a brief accent pulse after the drilldown renders; generated by the Now Playing right-click “Copy deep link”.
List all images in the track's album folder and one level of subfolders. Returns folder path, image paths, and count. Excludes: canonical primary-cover filenames (folder.jpg, cover.jpg, front.jpg, album.jpg + .jpeg/.png siblings — these are duplicates of the primary artwork served by /artwork); all Windows Media Player cache files (AlbumArtSmall*, AlbumArt_*); thumbnail artifacts (<5KB); Thumbs.db; desktop.ini.
Security: track must be in the MusicBee library.
Serve a single fan art image by absolute path (binary). Returns image with appropriate content-type. Cache-Control: max-age=3600.
Security: image must reside in a directory that contains at least one MusicBee library file.
v0.5.3.5. List video files in the track's album folder (+ one subdir level). Filters by extension: .mp4, .mkv, .webm, .mov, .m4v, .avi. Returns { folder, videos: [{ path, name, sizeBytes }], count }. Drives the Video tab inside the Extras panel on nowplaying.html and the Videos section on explore.html's expanded album view.
Security: track must be in the MusicBee library.
v0.5.3.5. Serve a single video file by absolute path (binary). Content-Type by extension: video/mp4, video/x-matroska, video/webm, video/quicktime, video/x-msvideo. Cache-Control: max-age=3600.
Security: same as /library/fan-art/{path} — the path's directory (or its parent) must contain at least one MusicBee library file.
/library/artist/{name}/pictures — returns a pictures array with src URLs (serveable via /library/artist/{name}/pictures/{index}) in addition to raw file paths. Query: ?localOnly=false.
/library/artist/{name}/pictures/{index} — serve artist picture by 0-based index as binary image. Content-Type auto-detected (falls back to image/jpeg for MusicBee cache files); Cache-Control: max-age=3600. Query: ?localOnly=true (default). A remote (http/https) picture over 16 MiB answers 502 ARTWORK_TOO_LARGE.
Recently played tracks sorted by last played descending. Params: limit (1–200, default 50), offset (default 0), days (1–365, default 30). Returns tracks with lastPlayed, playCount, skipCount fields.
Video files from MusicBee’s Video library node (Source Type 64). Returns {total, videos: [{url, title, artist, album, kind, duration}]}. Title falls back to filename when tag is empty. Stream video files via /stream/{url}.
/library/files/raw — raw MusicBee file data without CUE processing. Optional ?album= filter. Max 50 results.
/library/cuetest — test CUE track resolution for a query. Param: ?query=
List radio stations from MusicBee’s Radio node.
// Response:
{
"success": true,
"data": {
"total": 5,
"stations": [
{"url": "http://stream.example.com/radio", "name": "Jazz FM"},
// ...
]
}
}
// GET /playlists - List all playlists
{
"success": true,
"data": {
"total": 5,
"playlists": [
{"url": "playlist://Favorites", "name": "Favorites", "trackCount": 120},
{"url": "playlist://Workout", "name": "Workout", "trackCount": 45},
// ... more playlists
]
}
}
// POST /playlists - Create new playlist
{"name": "New Playlist", "folder": "", "files": ["C:\\Music\\song1.mp3"]}
// Response: {"success": true, "data": {"url": "playlist://New Playlist", "name": "New Playlist", "trackCount": 1}}
// PUT /playlists/{url} - Replace playlist contents
{"files": ["C:\\Music\\song1.mp3", "C:\\Music\\song2.mp3"]}
// GET /playlists/{url}/files - Get playlist tracks (supports pagination: ?offset=0&limit=50)
{
"success": true,
"data": {
"total": 120,
"offset": 0,
"limit": 50,
"tracks": [
{"index": 0, "url": "C:\\Music\\...", "title": "Song", "artist": "Artist", "album": "Album", "duration": 234000},
// ... more tracks
]
}
}
// POST /playlists/{url}/files - Add tracks to playlist
{"urls": ["C:\\Music\\song.mp3"]}
List podcast subscriptions. Query: ?query=
/podcasts/{id}/episodes/{index} — get a specific episode by numeric index.
Flat episode listing. Query: ?id= (feed URL). Use this when the subscription ID is a URL rather than a simple ID.
Read-only passthroughs to MusicBee’s own settings API. For MBXHub’s configurable hub settings, see /system/settings above.
Overview bundle of the most common settings in one response.
// Response:
{
"success": true,
"data": {
"storagePath": "C:\\Users\\...\\MusicBee",
"skin": "Default",
"windowBordersSkinned": false,
"lastFmUserId": "your-lastfm-user",
"webProxy": null
}
}
GET /settings/storage-path — Persistent storage pathGET /settings/skin — Current skin nameGET /settings/skin-element-color — Skin color. Query: ?element=SkinSubPanel&state=ElementStateDefault&component=ComponentBackgroundGET /settings/window-borders-skinned — Whether window borders are skinnedGET /settings/lastfm-user — Last.fm user IDGET /settings/web-proxy — Web proxy configurationGET /settings/field-name?field={MetaDataType} — Display name for a metadata field (e.g. TrackTitle, Custom1)GET /settings/data-type?field={MetaDataType} — Data type for a metadata fieldGET /settings/value?id={SettingId} — Raw MusicBee setting by ID (e.g. CompactPlayerFlickrEnabled). 400 with the full valid-ID list if unknown.GET /settings/convert-command?codec=Mp3&quality=HighQuality — File-conversion command line for a codec/quality pairGracefully close MusicBee. Requires allowRemoteExit=true in settings for remote callers; localhost (request.IsLocal) is exempt when the request carries the X-MBXHub-Local header (any non-empty value — a caller-id requirement, not a secret: it identifies deliberate local callers, keeps browser pages from firing this cross-origin (no CORS preflight is granted for it), and is the hook for future per-caller allow/deny lists). A local call without the header follows the remote rules (allowRemoteExit). The Disable Features App Restart toggle blocks this endpoint entirely, even from localhost. A paired Shell's valid X-MBXHub-SAC signature stands in for the X-MBXHub-Local header and for nothing else: a remote caller still needs allowRemoteExit, and a signature that is presented and does not hold up — including on a hub holding no secret to check it against — answers 403 SAC_INVALID rather than falling through to the rules above.
Optional body to schedule a restart via Windows Task Scheduler before closing:
// Request (optional):
{
"restart": true, // schedule restart before closing (default: false)
"delay": 22 // seconds to wait before restarting (default: 22, range: 1-300)
}
// Response:
{
"success": true,
"data": {
"message": "MusicBee restarting in 10s",
"restart": true,
"delay": 22
}
}
Restart MusicBee. Convenience alias for /app/exit with restart=true. Requires allowRemoteExit=true for remote callers; localhost (request.IsLocal) is exempt when the request carries the X-MBXHub-Local header (any non-empty value — a caller-id requirement, e.g. curl -X POST -H "X-MBXHub-Local: 1" http://localhost:8080/app/restart). A local call without the header follows the remote rules (allowRemoteExit). The Disable Features App Restart toggle blocks this endpoint entirely, even from localhost. A paired Shell's valid X-MBXHub-SAC signature stands in for the X-MBXHub-Local header and for nothing else: a remote caller still needs allowRemoteExit, and a signature that is presented and does not hold up — including on a hub holding no secret to check it against — answers 403 SAC_INVALID.
// Request (optional):
{
"delay": 22 // seconds to wait before restarting (default: 22, range: 1-300)
}
Turn MusicBee into a social jukebox. Guests scan a QR code, enter a PIN, and request songs from their phone. The DJ controls playback while a TV display shows artwork, lyrics, and a live feed of requests and joins.
Roles: Guest (browse/request), DJ (full control), Display (TV mode)
Kill switch: set ApiDisablePartyMode = true in mbxhub.json
(Features section) to disable party mode entirely. All /partymode/* routes return 404 NOT_FOUND
and /pages/partymode/* static pages return 404. Live state is exposed at
/system/features as partymode: false.
Streaming during a party: the raw-byte serving endpoints — GET /stream/*
(audio), GET /library/video/*, and GET /library/file/{url}/pdf (booklets) —
return 404 while a party is active unless partyAllowStreaming is enabled (default false;
PartyMode dialog or web settings). /system/features reports the effective audio state as
streaming. The one exception is a speaker the hub is casting to: the switch is about guests,
the speaker is the host’s own output, so it keeps its /stream audio through a party. The paired
Shell’s request for a converted track before a cast is admitted too, but only when its Secure Access Channel
signature checks out; disableStreaming still refuses both.
Get current party state (active, request count).
Start a party session. Host-only: caller must be on the host machine
(loopback / request.IsLocal). Tablets, phones, and other LAN devices become DJ via
/partymode/verify-dj after the host has started the party.
// Request body:
{"guestPin": "1234", "djPin": "5678"}
// djPin is optional - defaults to guestPin
// Errors:
// 403 PARTY_START_FORBIDDEN — caller is not on the host machine
// 409 PARTY_ALREADY_ACTIVE — a party is already running; stop it first
End the current party session.
Validate PIN and get role. If nickname provided, announces join in feed.
// Response:
{"success": true, "data": {"valid": true, "role": "guest"}}
// role: "guest" or "dj"
Verify if a PIN grants DJ access. Used by DJ page login.
// Request body:
{"pin": "5678"}
// Response:
{"success": true, "data": {"valid": true}}
// valid is true only if PIN matches DJ PIN
Submit a vote (thumbs up/down) with guest attribution. Records in feed and forwards to influences if AutoQ available. Network admission is asked again once the body has been read: a guest banned, or shut out by a switch to Managed Join, while the request was in flight gets 403 ACCESS_REVOKED and nothing is recorded.
// Request body:
{"type": "++", "target": "Artist", "value": "Daft Punk", "nickname": "Haro"}
// type: "++"=more, "--"=less; target: "Artist" or "Genre"
Submit a song request. It is recorded for the DJ to review — it is not queued by itself. Network admission is asked again once the body has been read: a guest banned, or shut out by a switch to Managed Join, while the request was in flight gets 403 ACCESS_REVOKED and nothing is recorded. 409 PARTY_CHANGED means the party ended or was replaced meanwhile; submit again.
// Request body:
{"url": "C:\\Music\\song.mp3", "nickname": "Haro"}
// Response includes requestId, title, artist
Get party feed (joins, requests, votes, reactions - newest first).
// Response:
{"success": true, "data": {
"items": [
{"type": "join", "nickname": "Haro", "timestamp": "..."},
{"type": "request", "nickname": "Haro", "title": "Song", "artist": "Artist", "timestamp": "..."},
{"type": "voteup", "nickname": "Haro", "artist": "Daft Punk", "timestamp": "..."},
{"type": "votedown", "nickname": "Haro", "artist": "Nickelback", "timestamp": "..."},
{"type": "reaction", "nickname": "Haro", "emoji": "🔥", "title": "Song", "artist": "Artist", "timestamp": "..."}
]
}}
Get recent song requests only (for DJ page).
Generate QR code PNG image. Auto-includes PIN if party is active.
Returns the caller's role based on IP: host (loopback), dj (verified DJ PIN), or guest.
Party Mode settings are configured in MusicBee via Settings → Network → Party Mode...
| Setting | Default | Description |
|---|---|---|
protectMetadata | true | Guards the RPC ActionCategory.Metadata path (the Pending_* tag-editor methods). Auto-Heart is independent of protectMetadata: it requires solo mode, autoQ.autoHeart.enabled and Loved enabled. Any of apiReadOnlyMode, apiReadOnlyLibrary or apiReadOnlyLibraryTags blocks its library write. Does NOT gate the manual love / rate / tag-edit endpoints — those are ActionCategory.LibraryTags, governed by the read-only flags (apiReadOnlyLibraryTags et al.). |
rateLimitEnabled | true | Master switch for per-IP rate limiting — party endpoints plus the reaction and client-log buckets. The play-statistic bucket is deliberately independent (disable via rateLimitPlayStatsPerMinute: 0) |
rateLimitRequestsPerMinute | 5 | Max song requests per minute per IP (when rate limiting enabled) |
rateLimitVotesPerMinute | 5 | Max votes per minute per IP (when rate limiting enabled) |
rateLimitPinAttemptsPerMinute | 5 | Max PIN validation attempts per minute per IP |
trustForwardedFor | false | Use forwarded client IPs only from explicitly trusted HTTP reverse proxies. Direct LAN clients use their connection address. |
trustedProxyIps | [] | Exact IP addresses of trusted HTTP reverse proxies; empty trusts none. No subnets or wildcards. Proxies must overwrite X-Forwarded-For or append the actual client address. Local configuration only. |
Protect Metadata guards the RPC Metadata path (the Pending_* tag-editor
methods), for everyone including the DJ. Auto-Heart is independent of this switch and requires solo mode, autoQ.autoHeart.enabled and Loved enabled; any of apiReadOnlyMode, apiReadOnlyLibrary or apiReadOnlyLibraryTags blocks its library write. Protect Metadata does not gate the manual controls: the stars
and the heart go through POST /dashboard/rate/{N}, POST /dashboard/love and PUT /library/file/*,
which are LibraryTags and answer to the read-only flags instead — apiReadOnlyLibraryTags (default
true, so a stock hub already refuses them), apiReadOnlyLibrary, apiReadOnlyMode — and are
refused for every role while a party is running. Those are the switches for stopping guests changing your ratings; ask
GET /system/features for libraryTagsWritable to see the answer they add up to.
(This paragraph previously said Protect Metadata blocked those three endpoints, contradicting the table above it. The table was right.)
Built-in pages at /pages/partymode/:
index.html - PIN + nickname entry (guest join page)guest.html - Browse 7 tabs (Albums, Artists, Genres, Playlists, Podcasts, Radio, Moods), fuzzy search, request songs, vote on vibesdj.html - Start/stop party, set PINs, manage queue, see requests & vibesdisplay.html - TV mode: artwork, lyrics, request feed, QR code, floating reactionsleaderboard.html - Party stats: guest activity, top tracks, reaction countsIntelligent queue system. AutoQ combines TrueShuffle rules, mood analysis, reactions,
and influences to automatically queue tracks that match the room's energy.
The native control surface is the AutoQ Workbench — MusicBee's AutoQ tab
(navigator entry under Services; also floating via Tools → MBXHub): list builder, mood tools,
programs, and behavior tuning in one place. The Queue tab's setup rows (station, flow, filter)
collapse behind a chevron — remembered across restarts — and the layout tracks the
tab's width, so it works docked narrow or wide. If MusicBee restores the AutoQ tab on launch, the Workbench appears a moment later, once the plugin's runtime has started (the tab is registered before the runtime exists; the dock is paid back when it is up, and mbxhub.log says so at Info).
TrueShuffle manages the shuffle cycle — play rules, cycle tracking, and queue constraints.
Returns 503 SERVICE_UNAVAILABLE if TrueShuffle/AutoQ not enabled.
Get shuffle cycle status
// Response:
{
"success": true,
"data": {
"enabled": true,
"totalTracks": 1000,
"playedCount": 250,
"remainingCount": 750,
"percentComplete": 25.0,
"cycleStarted": "2024-01-01T00:00:00Z"
}
}
Reset the shuffle cycle. All tracks become unplayed.
Tracks played/remaining in shuffle cycle. Query: ?offset=&limit=
Permanently excluded tracks. Banned tracks are never queued by AutoQ.
Returns 503 SERVICE_UNAVAILABLE if TrueShuffle/AutoQ not enabled.
Get list of banned tracks. Query: ?offset=&limit=
// Response:
{
"success": true,
"data": {
"total": 5,
"offset": 0,
"limit": 50,
"tracks": [
{
"url": "C:\\Music\\corrupted.mp3",
"reason": "Audio corruption detected at 2:30",
"bannedAt": "2024-01-01T12:00:00Z"
}
]
}
}
// POST /banlist - Ban a track
{"url": "C:\\Music\\track.mp3", "reason": "Corrupted audio at 2:30"}
// DELETE /banlist/{url} - Unban a track (URL-encode the file path)
Influence rules shape AutoQ scoring — Pandora-style thumbs up/down on artists and genres.
Unlike bans (permanent, track-specific), influences are resettable metadata preferences.
Returns 503 if TrueShuffle/AutoQ not enabled.
Negative (--): Hard exclude matching tracks. Positive (++): Preference boost (future).
Get list of all influences. Query: ?offset=&limit=
// Response:
{
"success": true,
"data": {
"total": 2,
"offset": 0,
"limit": 50,
"influences": [
{
"type": "--",
"target": "Genre",
"value": "Audiobook",
"timestamp": "2024-01-15T10:30:00Z"
},
{
"type": "++",
"target": "Artist",
"value": "The Beatles",
"timestamp": "2024-01-15T10:35:00Z"
}
]
}
}
Get current track's genre/artist and any matching influences (for UI state).
// Response:
{
"success": true,
"data": {
"genre": "Rock",
"artist": "Pink Floyd",
"genreInfluence": null,
"artistInfluence": "++"
}
}
// POST /influences - Add an influence
{"type": "--", "target": "Genre", "value": "Audiobook"}
// type: "++" (more) or "--" (less/exclude)
// target: "Genre" or "Artist"
// DELETE /influences/Genre/Audiobook - Remove specific influence
// POST /influences/clear - Clear all influences (reset preferences)
Get AutoQ status and configuration.
// Response:
{
"success": true,
"data": {
"enabled": true,
"mode": "autopilot",
"soloMode": false,
"vibeListCount": 10,
"vibeListPreview": [
{ "title": "Uptown Funk", "artist": "Mark Ronson", "score": 12.5 }
]
}
}
Start AutoQ. Begins monitoring queue and adding tracks when needed.
// Optional request body:
{ "mode": "autopilot" }
// Modes: "autopilot" (default), "djassist" (aliases: "dj", "assist", "auto")
Stop AutoQ. Queue continues playing but no new tracks are added automatically.
Reset AutoQ session state (clears reactions, taste vector, ban list). DJ-only in party mode.
Force refresh vibe list. Returns updated track count.
// Response:
{
"success": true,
"data": { "message": "Vibe list refreshed", "count": 100 }
}
Refresh the vibe list and immediately enqueue picked tracks in a single call (vibe-list/refresh + queue action combined). Used by the dashboard’s refresh button.
The dice: an empty-handed start. Rolls a random album from the library index and plays it, replacing the queue, so a second press changes the music rather than stacking another album behind it. The previous roll is excluded while another album exists. It is the one way to begin that needs nothing from you first — the radio needs a mood, a seeded start needs a track or a queue, refresh needs an existing vibe. Gated like /autoq/radio/start (a write; DJ-only during a party). Response: { rolls, track, album, artist, tracks } — track is named only on a track roll, and album is the context it came from (null when the index cannot name one). Callers: the in-box Dice charm (its page, /pages/dice.html, and its Roll button) and the web radio strip. The MusicBee AutoQ strip has a dice button too, but it rolls inside the plugin and does not call this route.
Optional body { scope, onPress, rolls }. scope: library or unheard, which rolls only an album none of whose tracks has a play count above zero. onPress: play (replaces the queue as above) or seed, which queues the roll next and starts the radio from it instead. rolls: album (the default) or track, which rolls ONE track drawn uniformly over the indexed tracks — it pairs naturally with seed, since one track on its own is three minutes and then silence. A track roll excludes the previous track roll AND the track playing now; unheard applies per track there rather than per album, so it will offer another song from a record you have heard one of (an album roll skips that record entirely). Case is ignored. Absent, null or empty is the hub’s own setting, autoQ.diceScope / autoQ.diceOnPress / autoQ.diceRolls (default library / play / album): the Dice charm’s Roll button and the web radio strip send no body, so they roll with the hub’s settings. The hub remembers the last roll in memory and shares it among every caller of this route, so the next roll differs whoever made the last one; the MusicBee strip keeps its own.
The dice page’s settings. Each device keeps its own pair in its browser (Apply to: This device), and the page sends that pair with a roll; or it follows the hub’s pair (All devices, saved with PUT /system/config, one key per change, and read from GET /system/settings as dice), and the page sends nothing. Follow the hub clears the device’s own pair.
| Status | Code | When |
|---|---|---|
400 | INVALID_REQUEST | Any other scope, onPress or rolls value, named (“scope must be library or unheard, not '…'”), or a body that is not JSON |
409 | AUTOQ_DISABLED | onPress: seed while AutoQ is off |
503 | PLAY_COUNTS_UNAVAILABLE | scope: unheard was asked and MusicBee did not answer the play-count query |
409 | NO_ALBUMS | An album roll: the album index is empty or still building, or nothing is unheard |
409 | NO_TRACKS | A track roll (rolls: track) with nothing to draw from, for the same two reasons |
409 | PLAY_REFUSED | MusicBee declined the play-now, or the queueing for a seed |
409 | AutoQ’s own code | A seeded start that AutoQ refused |
The same roll, selected but not played: nothing is queued and MusicBee’s queue is untouched. The dice page calls it when the device follows a shared Cast, so the page can admit the album to that cast itself. Same optional body { scope, onPress } and the same hub defaults as /autoq/dice; it takes the read-only and party gates but not the native-queue gate, since it does not touch the queue. The roll is remembered as the last roll, like a played one.
// Response:
{
"success": true,
"data": { "album": "Blue", "artist": "Joni Mitchell", "tracks": 10, "urls": ["C:\\Music\\..."], "onPress": "play" }
}
| Status | Code | When |
|---|---|---|
409 | DICE_STATION_NEEDS_MUSICBEE | onPress: seed — a station needs MusicBee to own the queue; refused before anything is chosen |
Otherwise the refusals of /autoq/dice above (400 INVALID_REQUEST, 503 PLAY_COUNTS_UNAVAILABLE, 409 NO_ALBUMS) | ||
Pick the next track from the vibe list without queueing it.
// Response:
{
"success": true,
"data": { "url": "C:\\Music\\track.mp3", "title": "Uptown Funk", "artist": "Mark Ronson" }
}
Unban a track, allowing it back into the vibe list.
// Request:
{ "url": "C:\\Music\\track.mp3" }
// Response:
{
"success": true,
"data": { "result": true, "url": "C:\\Music\\track.mp3" }
}
Check if the currently playing track is banned.
// Response:
{
"success": true,
"data": { "url": "C:\\Music\\track.mp3", "isBanned": false }
}
AutoQ-Radio run-state: running, pickMode, source (mood/seed), target mood, seed count, flow, tightness, queueThreshold (the cap a queue-ahead seed set is sized to, carried so a web strip sizes that aim the way the MusicBee strip does rather than restating a default). genreQuotaExempt is the run's resolved flag (spec 2026-07-09-genre-quota-per-stream.md) — a saved station's own flag on a station replay, true by default on a plain seeded start, the target mood channel's own flag on a mood run. tightness is the run's live Tight↔Loose dial (see below).
// Response:
{
"success": true,
"data": {
"running": true,
"pickMode": "weighted",
"source": "mood",
"mood": "Energetic",
"seedCount": 0,
"flow": "smooth",
"tightness": 0.38,
"startedUtc": "2026-07-07T12:00:00Z",
"genreQuotaExempt": false,
"runBudgetMinutes": 45,
"runMinutes": 12.3,
"recentlyPlayed": ["C:\\Music\\Artist\\Album\\earlier.mp3", "C:\\Music\\Artist\\Album\\track.mp3"]
}
}
recentlyPlayed is this session’s plays, oldest first, as many as the diversity window holds (diversityWindowSize, default 10) — the history AutoQ’s recency penalty and no-repeat rule read, and the music map’s trail. It counts every play since MusicBee started, radio or not; it is not MusicBee’s library-wide Last Played.
runBudgetMinutes is the run’s duration budget (null = unlimited) and runMinutes the minutes of music played against it — read-only here. The budget is armed from a station’s targetMinutes at play, or from the MusicBee-side doors: the panel’s ON-AIR menu Stop after… and the Workbench’s. When the budget is crossed the radio stops and the queue plays out.
Start continuous queue top-up. Body {"mode": "fresh"|"continue", "source": "mood"|"seed", "flow": "smooth"|"wave"|"build"|"winddown"} — fresh clears the queue first; source and flow are optional. seed seeds the run from the track playing now (the nearest queued-ahead tracks when nothing is playing); a station replay and an idle Send state their own seeds instead. seedUrls (optional array) STATES the seeds for this one start — the tracks are already where they are and nothing is re-queued — which is how a client seeds from the queue ahead without /autoq/send queuing those tracks next a second time. Consumed by the one start. 409 AUTOQ_DISABLED when AutoQ is disabled.
Stop the radio. The queue is left untouched.
Set the run’s flow live. Body {"flow": "smooth"|"wave"|"build"|"winddown"|<drop-in name>} — unknown names degrade to smooth. Applies to the next fill’s ordering (an already-spliced journey arc is static); persists with the run detail while on-air. GET /autoq/radio returns flows (built-ins + drop-ins) for pickers; the MusicBee panel and the web strip both carry the picker (chrome-sets-flow ruling).
Set the run’s Tightness live. Body {"tightness": 0.0–1.0} — 0 = Tight, 1 = Loose. 400 INVALID_REQUEST when the value is missing, non-numeric, NaN, or outside [0,1]. Applies to the next fill and persists with the run detail while on-air; GET /autoq/radio echoes the current tightness. Tightness is one dial that controls how adventurous a run is: it widens or narrows the candidate funnel reach and weights a continuity term that flows each pick from the previous track across acoustic timbre, harmonic key (Camelot) and half/double-aware tempo. Tight yields coherent, DJ-style harmonically-mixed flow; Loose roams wide for variety. Computed on the library’s own analysis — no tagging required. Surfaced as a Tight↔Loose slider on the radio strip (play / dashboard) and as granular Reach + Continuity sliders in the Workbench Tuning tab; persists into a saved Station.
Mode-C reviewable list: body {"seedUrls": [...], "flow": "smooth"|"wave"|"build"|"winddown", "count": 25} → ordered tracks like the seeds; never queues. 400 INVALID_REQUEST without seedUrls; 409 AUTOQ_DISABLED when AutoQ is off.
Journey generate: a finite arc from a start track to an end track (optional midpoint) through mood space, following the selected flow. Body {"waypoints": ["start", ("via",) "end"], "flow": "smooth"|"wave"|"build"|"winddown", "count": N}. Waypoints must have analyzed mood data (400 WAYPOINT_NOT_SCANNED otherwise). Count omitted = distance-derived default (15–30); explicit count clamped 5–50. Or size by time instead: "targetMinutes": M (5–600) fills until that much music is accumulated, overshooting by at most one track — mutually exclusive with count (400 INVALID_REQUEST when both are sent). Response matches /autoq/radio/generate. Never queues; never touches the radio run-state.
Connect-from-queue: the queue-ahead window (exactly 2–3 tracks) is the waypoint set — the generated arc replaces that window, so the user's own tracks still open and close the journey. Body optional: {"flow": "smooth"|"wave"|"build"|"winddown", "count": N, "targetMinutes": M}. Count omitted = distance-derived default (15–30); explicit count clamped 5–50; targetMinutes (5–600) sizes by time (overshoot ≤ 1 track), mutually exclusive with count. Any other queue shape (0, 1, or 4+ tracks ahead) returns 400 QUEUE_SHAPE carrying a teach message clients show verbatim (“Queue 2-3 tracks next, then Connect fills the journey between them.”). Also 400 QUEUE_READ_FAILED / WAYPOINT_NOT_SCANNED / GENERATE_EMPTY / QUEUE_WRITE_FAILED; 409 AUTOQ_DISABLED; party mode gates it like /autoq/radio/start. Never arms the radio; never starts playback. Response: {message, count, flow, replaced, tracks: [{url, title, artist, genre}]} — no score field.
Send to AutoQ: one verb, meaning derived from run state. Body {"files": ["url", ...], "as": "auto"|"start"|"seed"|"waypoint"|"destination"} — as optional, default auto. Nothing on air → starts a station seeded from the sent tracks (sendIdleStart=tray answers collect instead and leaves the tray to the surface). Radio on air → sent tracks queue next and become the run's new seed generation per the Default Send to Q Behavior mode (sendMode). A journey in flight (spliced via /autoq/radio/connect/queue) → sent tracks become waypoints on a re-routed remaining arc toward the same destination (cap sendWaypointCap); the destination itself never moves.
Where the verb lives: MusicBee’s own right-click menu (Send to AutoQ on any selection — outcome shown on MusicBee’s status bar), browse track rows (📨) and the multi-select batch bar, the Cmd+K palette (Alt+Enter on a track row), and this endpoint for scripts. All surfaces route through the same dispatch, so the behavior above is identical everywhere; each send announces what it did (started / steered / waypoint).
From the keyboard: Send to AutoQ is registered as a MusicBee command, so it is listed in MusicBee’s own Preferences → Hotkeys and the key is yours to choose — MBXHub claims no chord of its own, and MusicBee stops you binding one it already uses. It runs the same handler as the right-click item, on the same selection, with the outcome on MusicBee’s status bar.
Journey-detection scope: only arcs queued via POST /autoq/radio/connect/queue stamp a tracked destination — arcs generated via /autoq/radio/connect and queued by generic queue verbs are not tracked, so a send after one dispatches as steer/start instead of waypoint. A stamped destination that is no longer ahead in play order (the journey finished, or the queue moved past it) answers 409 NO_JOURNEY for both auto and explicit as=waypoint — the stamp is never lazily cleared on a miss, only the run lifecycle clears it. as=destination starts a fresh tracked journey from any state, including from a single sent track (the arc is prepended from now-playing or the nearest scanned queue-ahead track).
Response: {action: "started"|"steered"|"waypoint"|"collect", mode, queued, filled, seeds, waypoints}. mode is the steer variant that applied (refresh|reuse|restart, from sendMode) on steer and waypoint sends, null otherwise — the same value the pick journal logs for that send. Two counts, never one: queued is the caller's own tracks that reached the queue; filled is what AutoQ chose and added around them (the opening batch on a start, the top-up when a send finds the transport stopped, a sendRefill:"replace" re-pick, and the corridor tracks a journey arc adds around pinned waypoints). Send one track into a stopped transport and the answer is queued: 1, filled: 5 — one sent, five topped up behind it. filled counts tracks AutoQ added, which is not the same as how much the queue grew: under sendRefill:"replace" some of those replace fills AutoQ first removed, so filled can exceed the queue's net growth. Errors: 400 INVALID_REQUEST / WAYPOINT_NOT_SCANNED / JOURNEY_EMPTY; 409 NO_JOURNEY / AUTOQ_DISABLED; 403 in read-only mode, or when a party guest (non-DJ) sends.
Name an assembly (seeds + flow) and replay it later with one click. Backed by mbxhub-stations.json. GET list is summary-shaped (no seedUrls); GET one returns the full record (incl. seedUrls + timestamps); create returns the summary shape (id, name, flow, seedCount, genreQuotaExempt). All station shapes carry genreQuotaExempt (spec 2026-07-09-genre-quota-per-stream.md) — default true, so a saved station stays in its lane and skips the genre variety quota unless turned off — and filter, an optional per-station candidate filter in search-DSL syntax (e.g. year:1965..1979 -genre:Live). The station filter COMPOSES with the global autoQ.filter: global is the house rules, the station filter is the flavor, and a fill on that station must pass both. Applies to that station’s radio runs; it does not survive a MusicBee restart with the rest of the run detail.
Station duration (v0.5.5.0) — targetMinutes, optional run length in minutes (1–1440), a sibling of filter rather than part of journey. Omit for unlimited, which is how every station created before this deserializes. When set, the radio stops once that many minutes of music have played; the queue is left alone, so tracks already queued still play out. The clock is persisted with the rest of the run detail, so a MusicBee restart resumes it rather than handing back the whole budget. On PUT, 0 clears the duration back to unlimited and omitting the field leaves it unchanged — the same absent-versus-clear rule filter uses. Do not confuse it with journey.targetMinutes, which SIZES a generated arc; this one STOPS a running station. A journey needs neither: it ends when it reaches its destination. Where to set it: the station menu’s Duration… entry (panel and Workbench), targetMinutes on create/PUT, or save a station from a run whose budget was armed via ON-AIR Stop after… — the saved recipe carries the budget.
Station mood — mood, optional, a mood channel name (the same names as the AutoQ Mood dropdown). A station remembers the target mood, so a program of two stations can shift mood at the handoff. Playing a station with a mood sets it; with an empty string clears it; with no mood at all (every station saved before this) leaves the running mood alone. Every way of saving a station keeps the same settings — flow, genre-free, Tight/Loose, freshness, duration and mood: the AutoQ window’s save, the station menu’s Save station, and POST /autoq/stations/from-run. Playing a station, directly or as a program’s next entry, puts back flow, Tight/Loose and mood. A name that is not a mood channel returns 400 INVALID_MOOD. Journey stations ignore it. Get, list and create responses carry mood (null on a station saved before it existed).
Journey stations (spec 2026-07-25-bookended-fresh-journey) — a journey object replaces seedUrls on create: {"journey": {"waypoints": [2–5 track URLs], "fresh": true, "targetMinutes": 90}}. The first waypoint is the start and the last is the end (both pinned); any middle waypoints are pinned too. Every waypoint must have analyzed V/A mood data — an unknown or unscanned waypoint returns 400 WAYPOINT_NOT_SCANNED. Each play regenerates a fresh arc between the same bookends — a new random middle each time (when fresh, the default), sized to targetMinutes (works for long durations, hours). It queues that finite arc and does not arm the radio, so the end waypoint is the last track. When a journey IS running on air — a Connect while the radio is already on — it now ends on arrival: playing the destination stops the radio instead of handing the queue to an endless fill. That is the journey’s own stop condition, not a setting, and it is separate from a station’s targetMinutes. GET /autoq/stations/{id} returns a journey field ({waypoints, fresh, targetMinutes}), null on a seed station.
Ring stations (spec 2026-10-29-music-map-orbit) — a ring object replaces seedUrls on create: {"name": "...", "flow": "wave", "ring": {"v": 0.5, "a": 0.5, "radius": 0.11}} — a center on the music map in valence/arousal and a radius in the same units. The station stores the ring, not a track list: its seeds are the scanned tracks inside the ring, nearest the center first (at most 100), resolved again at every play, so a track scanned later joins it. v and a must be within 0–1 and radius above 0 and at most 1 — otherwise 400 INVALID_RING — and a ring alongside seedUrls or alongside a journey is refused the same way (400 INVALID_RING); seedUrls with a journey keeps its older behavior (the journey wins, 200). A ring station plays like a seed station, so filter, genreQuotaExempt, targetMinutes and mood are taken and validated on create exactly as they are for one. Create, get and list carry ring ({v, a, radius}, absent on any other station); a stored ring station has seedCount 0, and its play response counts the tracks found inside the ring. Playing a ring station with no scanned track inside the ring returns 409 RING_EMPTY.
Validation: create requires name and at least one seedUrls entry — or a journey or ring object instead (400 INVALID_REQUEST otherwise; genreQuotaExempt is optional, defaults true). PUT is a partial update — name, genreQuotaExempt, filter and/or targetMinutes, at least one required (400 INVALID_REQUEST otherwise). targetMinutes outside 1–1440 (0 aside, which clears) returns 400 INVALID_TARGET_MINUTES rather than being clamped, so a typo surfaces at the edge instead of stopping a run early. filter is validated strictly on create and update — a bad expression returns 400 INVALID_FILTER with the parse error; an empty string clears it. mood must name a mood channel (400 INVALID_MOOD). Unknown {id} on get/update/delete/play returns 404 NOT_FOUND.
play queues the station's seeds next, applies its flow, and starts the radio seeded (same one-action replay as RadioStart). A journey station instead queues a freshly-built finite arc between its bookends (see above) and does not arm the radio. Gated like /autoq/radio/start — party mode restricts it to the DJ, and 409 AUTOQ_DISABLED when AutoQ is off. Response: {message, name, flow, seedCount, queued}.
from-run captures the live seeded radio run (seeds, flow, and genre-exemption) as a new station in one call — the platform verb behind every surface's “Save current run as station…”. Body {"name": "..."}. 409 NO_ACTIVE_RUN when the radio is off or the run has no seed list to capture (mood runs carry no seeds). Response shape matches POST /autoq/stations.
influences is the station’s reaction memory — tracks upvoted (pick-boosted) or downvoted/banned (hard-excluded) during that station’s runs, persisted in mbxhub-station-influences.json. GET returns {stationId, upvoted:[{url, artist, title}], downvoted:[...]}; DELETE ?url= removes one track from the memory (the un-downvote verb; 404 NOT_FOUND when the track isn’t remembered, 400 INVALID_REQUEST without url). The browse station picker’s Memory button is the UI over these.
Mutation routes (POST / PUT / DELETE) return 403 FORBIDDEN when ApiReadOnlyMode is set — kiosk deployments can’t have stations mutated by remote clients.
500 STORE_WRITE_FAILED when mbxhub-stations.json could not be written — locked, read-only, or a full disk. Nothing changed: the store rolls the edit back rather than keeping it in memory, so a create answers with no record at all rather than an id that disappears at the next restart, and an update or delete leaves the station exactly as it was. Retry once whatever is holding the file lets go.
A program is a playlist of saved stations — an ordered sequence of entries the radio moves through, one station handing off to the next after its dwell. An entry is a saved station, a ring on the music map or a single track (see Entry kinds below). A station entry REFERENCES its station, never owns it: order and dwell are the program’s own settings, while flow (unless the entry sets its own), candidate filter, seeds and reaction memory all still come from the entry’s station. Backed by mbxhub-programs.json. GET list is summary-shaped (no entries); GET one and create/update return the full record incl. entries (stationId, ring, trackUrl, flow, dwellTracks, dwellMinutes; ring, trackUrl and flow only when set).
Entry kinds (spec 2026-10-29-music-map-orbit) — an entry is exactly one of stationId (a saved station), ring ({"v": 0.5, "a": 0.5, "radius": 0.11}, the tracks inside a ring on the music map, resolved when the entry starts) or trackUrl (one track); naming none or more than one returns 400 INVALID_ENTRY (entry 0: one of stationId, ring or trackUrl). Any entry may carry its own flow — smooth, wave, build or winddown, any case, stored lowercase; empty or absent means the station’s own, which for a ring or track entry is smooth — and an unknown flow returns 400 INVALID_ENTRY (entry 0: unknown flow '...'). A ring follows the ring-station range rule (entry 0: ring out of range). Dwell works the same for every kind. A track entry in the middle of a program with no dwell plays once and hands on (it is given a dwell of one track); a track entry last holds like any other last entry. Entries come back carrying only the fields they set: ring, trackUrl and flow are absent when unset, so a station-only program reads exactly as it did. A ring entry with nothing inside it when its turn comes is skipped.
Play without saving — POST /autoq/programs/play takes a create body ({"entries": [...]}, name optional, default “From the map”) and plays it at once; nothing is stored. Gated exactly like POST /autoq/programs/{id}/play (read-only mode, party DJ, 409 AUTOQ_DISABLED), and its entries are validated like a create’s; no entries returns 400 INVALID_ENTRY, and 409 PROGRAM_EMPTY when no entry has anything on the map. Response: {message, name, entryCount, queued, adHoc: true}. The hub holds the program while it runs and the next one replaces it; it does not survive a MusicBee restart — after a restart the run holds on its current station rather than advancing.
Dwell grammar: each entry sets at most one of dwellTracks (advance after N tracks played under that entry) or dwellMinutes (advance after N minutes of music); neither means hold — the radio keeps filling from that station until stopped. (The Workbench’s Programs tab labels dwell “Play for” — same values, friendlier name.) Dwell met on the last resolvable entry is a terminal hold too: the run continues as an ordinary station run rather than looping (whole-program loop is a reserved knob, no v1 behavior).
Hard boundary: the handoff re-binds the run to the next entry’s station — its flow, seeds (for centering), filter, and reaction memory — and takes effect on the next fill only; it never touches the queue, so tracks already queued from the outgoing station play out before the new station’s picks appear (eased boundaries are a reserved knob, no v1 behavior).
The dwell bounds the queue: an entry never has more music queued than its dwell has left — its seeds are cut to the dwell (a count for dwellTracks, a running length for dwellMinutes, overshoot by at most one track) and every fill under it is trimmed the same way, so the handoff is heard when the dwell ends. All of a station’s seeds still set its center; only what is queued is cut. A hold entry, and the last entry that resolves (a terminal hold), are open-ended station runs and are not bounded. A program is never stopped by its stations’ own targetMinutes: a dwell advances the run, it never ends it.
Starting a program replaces the upcoming queue — a program is a schedule. The playing track plays on; every track queued after it is removed before the first entry is laid down. Kept, the previous queue played between the first entry and every later one, and through their dwells.
Validation: create requires name and at least one entry (400 INVALID_REQUEST otherwise). Every entry’s stationId must resolve to an existing station at save time, and at most one dwell may be set, positive when present — a bad entry returns 400 INVALID_ENTRY naming the failing index (e.g. entry 1: unknown stationId '...'). An entry with no dwell that isn’t last is reachable but strands every entry after it — that’s a warnings array on the 200 response, not an error (the operator may be mid-edit). PUT is a partial update — name and/or entries, at least one required; a non-null entries replaces the list wholesale (re-validated the same way, same warnings shape). Unknown {id} on get/update/delete/play returns 404 NOT_FOUND.
play is the one-action replay: starts the radio on the program’s first resolvable entry exactly like POST /autoq/stations/{id}/play, then arms program mode on top. Gated like /autoq/radio/start — party mode restricts it to the DJ, 409 AUTOQ_DISABLED when AutoQ is off, and 409 PROGRAM_EMPTY (“No entry has anything on the map”) when no entry has anything to play — the stations it names are gone and its rings are empty. Play also counts as a write — it arms the radio and queues tracks — so it returns 403 FORBIDDEN when ApiReadOnlyMode is set, same as the mutation routes below. Response: {message, name, entryCount, queued}.
A playing program’s position (which entry, tracks/minutes accumulated under it) survives a MusicBee restart via the same run-detail store as the rest of the radio run — the run resumes at the same entry with dwell progress intact. If a station a program references is deleted while that program is on the air, advance skips the dangling entry (logged as a Warn) and moves to the next resolvable one; if the whole program is deleted mid-run, the run holds on its current station instead of erroring.
Mutation routes (POST / PUT / DELETE) return 403 FORBIDDEN when ApiReadOnlyMode is set — kiosk deployments can’t have programs mutated by remote clients.
Get available mood channels with arousal/valence coordinates. Channels are customizable via autoQ.moodChannels in mbxhub.json. genreQuotaExempt (spec 2026-07-09-genre-quota-per-stream.md) — default false — suspends genre-diversity quota for radio runs targeting that channel; set per channel in the mood editor at /pages/autoq.html or via PUT /autoq/settings.
// Response:
{
"success": true,
"data": {
"currentMood": "Energetic",
"channels": [
{ "name": "Energetic", "emoji": "🔥", "arousal": 0.90, "valence": 0.80, "genreQuotaExempt": false },
{ "name": "Chill", "emoji": "😌", "arousal": 0.35, "valence": 0.65, "genreQuotaExempt": false }
]
}
}
Browse tracks matching a mood channel. Returns scored tracks sorted by mood similarity. v0.5.3.3: drops federated mood-cache entries that the local MusicBee library can't resolve — peer-only URLs (replicated mood data for tracks that live on another machine) would otherwise render as “Unknown”. total reflects the filtered count, not the raw cache hits. Local-library-only display policy: federation is analytical-only.
channel | Mood channel name (e.g. "Energetic", "Chill") |
limit | Max results (default: 200, max: 500) |
// GET /autoq/moods/browse?channel=Energetic&limit=50
// Response:
{
"success": true,
"data": {
"channel": "Energetic",
"emoji": "🔥",
"total": 50,
"tracks": [
{ "url": "...", "title": "...", "artist": "...", "album": "...", "trackNo": "3", "moodMatch": 0.92 }
]
}
}
Seeding from the map: clicking a star seeds the map’s orbit on that track and offers a Ring slider and Seed a station. The slider is the orbit’s inner ring: its radius in valence/arousal units (0.01–0.30), with a live count of the tracks inside it, and a drag of the inner ring’s knob on the map shows in the same readout. The inner ring’s members ARE the station’s seed set, read from the map with no further request: the picked track first, because a station built from the ring without its center would be seeded by everything near the track except the track, then the rest nearest first, at most 100. It then calls POST /autoq/stations and POST /autoq/stations/{id}/play: it creates AND starts. The confirmation names how many seeds were sent.
The music map: every scanned track in the local library at its mood position. Each point is [url, valence, arousal, genreIndex]; genreIndex points into genres (-1 = no genre). A genre tag naming several genres (; or / separated, Rock / Classic Rock / General) is split: the track counts once toward each part and is colored by its highest-ranked one, and genreCounts is parallel to genres (so its sum can exceed count). Genres are case-folded (ROCK and Rock are one), shown with their commonest spelling, largest first. libraryCount is every track in the library; count is how many have a mood position. ready is false while the mood cache is still loading after MusicBee starts — every lookup misses then, so read an empty map with ready:false as “try again shortly”, not as “nothing is scanned”. Built from the mood cache with no per-track MusicBee reads. The genre is the one recorded when the track was scanned. moodIndex (fifth element) points into moods ({name, emoji}, every configured channel in configured order): the channel the track scores highest in, by the same match /autoq/moods/browse ranks by, when that score clears the AutoQ minimum; -1 = no mood. moodCounts is parallel to moods; a track files under at most one channel.
// GET /autoq/mood-map
// Response:
{
"success": true,
"data": {
"ready": true,
"libraryCount": 7661,
"count": 7012,
"genres": ["Rock", "Alternative", "Jazz"],
"genreCounts": [3120, 1402, 640],
"moods": [{"name": "Chill", "emoji": "😊"}, {"name": "Drive", "emoji": "🕺"}],
"moodCounts": [2210, 1890],
"points": [
["C:\\Music\\Artist\\Album\\track.mp3", 0.62, 0.71, 0, 1],
["C:\\Music\\Artist\\Album\\other.flac", 0.31, 0.24, -1, -1]
]
}
}
Hash-keyed counterpart to /autoq/track-mood for cross-system lookup — resolve mood data without knowing the local file URL. {hash} is hex (audioStreamSha256 = 64 chars, fileMd5 = 32 chars). Returns the same mood payload shape as /autoq/track-mood.
Raw mood data for the current track (or any track via ?url=). Returns file, album, Essentia features, percentile ranks, computed valence/arousal, and best mood channel match. Sony SensMe (SMFM)-derived valence/arousal is retired: the response carries no smfmArousal/smfmValence. effectiveArousal/effectiveValence are still returned for compatibility and equal the computed valence/arousal. Also carries schema ({have, total, measurable} — the field count computed server-side, beside the payload it counts) and bootSource (catalog, sidecar, or sidecar+catalog once the track's wide feature block has been hydrated on demand from the catalog via the span index — one seek per track, so the catalog is not held in memory). measurable:false means the count describes the fast startup cache, not the scan — which is why the AutoQ page says “Loaded from the fast cache” there instead of telling you to rescan a healthy library.
Key and harmonic mixing. raw.key is the key root and raw.camelot its Camelot wheel code (e.g. 8A) — the pair AutoQ mixes on. keyVotes carries Essentia's three key profiles (krumhansl, temperley, edma), each {key, scale, strength}; the object appears when any vote exists and each profile is omitted individually, so read what is present rather than assuming three. The top-level key comes from edma. Do not compare the three strengths — they come from different profiles and share no scale. Use keyAgreement instead: how many of the profiles present name the key the track is actually mixed on (the flat key/mode, which comes from edma) — not the size of the largest agreeing group, which can name a key nothing is mixed in when edma is the outlier. Read it against the number of profiles the scan carried: a track may legitimately carry one or two. AutoQ weights the harmonic term by that fraction — full when they are unanimous, half on a strict majority, dropped when the mixed key is outvoted, and half again when a single profile is all that was measured. No votes at all (a pre-wave scan) counts as trusted, so existing libraries keep mixing as before.
Tonal & rhythm wave (truedat 2026-07-22, all nullable). averageLoudness (0–1, not dB and not the same scale as loudness); the tempo histogram bpmFirstPeak/bpmFirstPeakWeight/bpmSecondPeak/bpmSecondPeakWeight/bpmSecondPeakSpread (the second peak near double or half the first is genuine half/double-time evidence; a spread of exactly 0 means unmeasured); chordsKey/chordsScale (the most frequent chord, routinely different from the track key) and chordsNumberRate; the tuning block tuningFrequency (Hz, ~440 nominal but genuinely spread), tuningEqualTemperedDeviation, tuningDiatonicStrength, tuningNontemperedEnergyRatio. Absent means not measured — these are Essentia-derived and not backfillable, so only a fresh analysis fills them. hasTonalWave answers that in one field.
Chord distribution. Essentia's 24-bin chordsHistogram is deliberately not returned: its values are percentages summing to ~100 (not 0–1) and bin 0 is not C — the bin order is Essentia's internal convention, so no bin can honestly be labeled with a chord name. Two order-independent summaries are returned instead: chordsConcentration (share of the distribution in its biggest bin) and chordsEntropy (0–1, evenness). High concentration / low entropy = harmonically focused; the reverse = roaming.
Contributions — what each measurement actually pushed. contributions carries the trained model's own arithmetic, decomposed per feature, for each axis: {head, intercept, raw, center, stretch, map, zBound, output, audioPush, smfmPush, features[]}. Read it as one sentence — raw = intercept + audioPush + smfmPush, then the output stage: center/stretch, clamped to 0–1, or, when map is quantile (a valence learned from comparison pairs), the position of raw within the library, where center and stretch play no part. zBound is the bound each input’s z was clipped to before combining (null for none); z and push report the clipped value. head is full or essentiaOnly: a track with no SMFM data is served by a different head with different coefficients, so which one ran is part of the answer. Each row in features is {name, value, imputed, used, z, coef, push} where push = coef × z and the pushes sum exactly to raw − intercept. value is null when the track carries no measurement for that feature — which is not the same as zero — and used then reports the model-carried impute mean that stood in, with imputed: true. On a PCA axis coef is the back-projected per-feature coefficient, not a raw JSON number. The key is omitted entirely (never null) when the trained model is not the path that produced the V/A above — autoQ.useTrainedModel off, no model loaded, or no cached entry for the track — because a decomposition of a model that did not run would describe the wrong arithmetic. Note the top-level valence/arousal may differ from output: a track you have rated serves its rating (ratedOverride: true); modelValence/modelArousal always carry the model’s own number.
Rated tracks. A track you rated on the annotation page plays at your rating; the model only fills in unrated tracks, and that rating is the point AutoQ matches, picks and maps the track by (serveRatedTracks, default on). The payload always carries modelValence/modelArousal — what the model said — beside valence/arousal, and ratedOverride is true when the served pair is your rating rather than the model’s number. The Gates report and the annotation card read the model fields, so a rated track is never measured against its own rating. One point per track: moodChannel, moodEmoji, moodLabel and moodMatches are ranked from the served pair (moodChannelSource: "served"), so the badge always names the channel nearest the point the meters and plots draw.
Edge transitions. With truedat’s mbxedges.v1.json beside the catalog, AutoQ orders each fill tail-to-head: the outgoing track’s last 30 s is compared with each candidate’s first 30 s (key, tempo, brightness, tension); everything that describes the whole track — its V/A position, the plot, the map, Like tracks — stays whole-track. A track with no row, or an edge that did not fold, contributes its whole-track point exactly as before. Edges are opt-in: disableEdgeTransitions is true by default and setting it to false turns them on; with them off, AutoQ still matches key and tempo on each track’s main key. edgeKeyMode chooses strict (edge key replaces) or blended (averaged with the whole-track key).
Edges — the start and end of the track. A song is three segments; the fields above describe the middle (the whole track). edges carries the other two from truedat’s edge data: state (on = shaping fills, off = the data is present but Edge transitions is switched off, none = no edge data for this track, building = the edge index is still loading), inUse, and start / end, each with status (ok, short for a track shorter than the window, unavailable, failed — only ok and short carry a reading), startMs/endMs (the window), camelot, key, keyAgreement/keyVotes, bpm, and loudness: startLufs, endLufs, fadeMs. A reading that was not measured is absent, never zero. The segments are returned whether or not Edge transitions is on; the switch only decides state.
Verdicts. verdicts carries truedat's write-time judgments when present: speechLikely, hiresGenuine, lossyTranscodeLikely. These re-derive on truedat's next save with no rescan, so they can change without the analysis changing. AutoQ keeps speechLikely: "yes" out of its auto-pick pool only (see autoQ.excludeSpeechTracks); tracks you queue yourself, journey waypoints and station seeds are unaffected.
// Response (Essentia-analyzed track):
{
"success": true,
"data": {
"file": "C:\\Music\\Artist\\Album\\Track.mp3",
"album": "Album",
"valence": 0.5003,
"arousal": 0.5794,
"effectiveArousal": 0.5794,
"effectiveValence": 0.5003,
"source": "essentia",
"hasEssentiaData": true,
"moodChannel": "Upbeat",
"moodEmoji": "😊",
"raw": {
"bpm": 119.95,
"mode": "major",
"loudness": -10.23,
"spectralCentroid": 1284.56,
"spectralFlux": 0.000342,
"spectralRms": 0.001245,
"spectralFlatness": 0.000089,
"danceability": 1.45,
"onsetRate": 2.87,
"zeroCrossingRate": 0.058,
"dissonance": 0.4123,
"pitchSalience": 0.3856,
"chordsChangesRate": 0.0312,
"mfcc": 142.56
},
"percentiles": {
"bpm": 0.52, "loudness": 0.61, "centroid": 0.48,
"flux": 0.35, "dance": 0.67, "onset": 0.55,
"zcr": 0.42, "rms": 0.58, "dissonance": 0.44,
"salience": 0.39, "chords": 0.51, "mfcc": 0.63
},
"confidence": 0.82,
"confidenceLabel": "high",
"genreProfile": "electronic"
}
}
// Response (fallback — no Essentia data):
{
"success": true,
"data": {
"file": "C:\\Music\\Artist\\Album\\Track.mp3",
"album": "Album",
"valence": 0.65,
"arousal": 0.70,
"source": "fallback",
"hasEssentiaData": false,
"moodChannel": "Energetic",
"moodEmoji": "🔥",
"confidence": 0.45,
"confidenceLabel": "medium",
"genreProfile": null
}
}
The speechLikely verdict above is produced by truedat (the analyzer that writes mbxmoods.json), not by the hub. It draws on three independent evidence sources; none of them decides anything on its own — they surface as reasons and feed the verdict, and the verdict only gates AutoQ's auto-pick pool (see autoQ.excludeSpeechTracks). Nothing here skips a scan or removes a catalog entry.
1. Library labels. Read from the iTunes XML, from exactly two markers: Podcast=true, or Genre equal to Podcast (exact match, case-insensitive). The old “Episode Date” heuristic was removed and must not return — MusicBee maps ID3v2.4 TDRL (a release date) into that key, so it rode on ordinary music. Publisher and long duration are not, on their own, speech.
2. Embedded file markers. A bounded header sniff (≤128 KB per file, run only over review candidates during a preview pass — never a full-library sweep), graded into three tiers rather than first-match-wins:
| Tier | Marker | Meaning |
|---|---|---|
strong | ID3 PCST / MP4 pcst | An app asserting “this is a podcast” |
provenance | WFED, TGID, purl | Came from a feed — says nothing about content. Music ships by RSS too (label feeds, session series, DJ mixes) |
genre-text | TCON exactly Podcast | Trimmed, case-insensitive, exact — not a substring, so “Comedy Podcast” or “Podcast Rock” no longer trip it |
3. Acoustic verdict (speechLikely). Computed at write time from stored Essentia features (danceability, chords strength, silence rate, zero-crossing rate, tempo-peak weight, key-vote strength), so a threshold change is retroactive across the whole catalog with no rescan. Two gates must both fire for a “yes”: the zero-crossing signal, and danceability under 0.50. That second gate is load-bearing — sparse, live, free-form instrumental music craters on every other signal exactly like talk does, and without it genuine music (live jazz, a live rock outro) was misclassified. Measured: genuine speech sits near 0.00; real-music false positives ran 0.66–1.10.
speechLikely="yes" from its own auto-pick pool only. The only thing that keeps a file out of a scan is a rule you wrote.
Scope note: this identifies the genus — speech-dominant. It deliberately does not try to tell podcast from audiobook from lecture: that is provenance, and the audio does not carry it.
Library tracks whose chosen-source mood read falls inside a V/A box — the read-only region filter behind the AutoCal anchor-curation page (find candidates for an empty cell of the coverage grid). source=autoq uses the computed AutoQ valence/arousal; source=smfm is still accepted but returns no candidates, since SMFM-derived valence/arousal is retired. Box bounds are inclusive; computes and mutates nothing.
vMin/vMax | Valence box bounds (defaults 0/1) |
aMin/aMax | Arousal box bounds (defaults 0/1) |
source | autoq (default) or smfm — which machine read to filter on |
limit | Max candidates returned (default: 50) |
// GET /autoq/autocal/candidates?vMin=0.4&vMax=0.6&aMin=0.4&aMax=0.6&source=autoq&limit=25
// Response:
{
"success": true,
"data": {
"source": "autoq",
"box": { "vMin": 0.4, "vMax": 0.6, "aMin": 0.4, "aMax": 0.6 },
"count": 2,
"candidates": [
{ "path": "C:\\Music\\Artist\\Album\\Track.mp3", "artist": "...", "title": "...", "valence": 0.52, "arousal": 0.47 }
]
}
}
Set target mood for Mood mode. Pass empty channel to disable mood mode.
// Request:
{ "channel": "Energetic" }
// Response:
{
"success": true,
"data": {
"message": "Mood set to Energetic",
"mode": "mood",
"mood": { "name": "Energetic", "emoji": "🔥", "arousal": 0.90, "valence": 0.80 }
}
}
Reload the mood channel cache from disk (re-reads Essentia data).
Export mood file (seed another node). Streams the raw mbxmoods.json with the same byte content as on disk so you can save it and drop it into another MBXHub's plugin data folder. Disabled by default. Returns 404 unless “Disable mood export” is unchecked in Settings → Configuration → Services. Also returns 404 when no moods file is resolved on this node, and 503 when the file is busy. Supports ETag + If-None-Match (weak validator) and Last-Modified for cheap re-pulls; response carries Content-Disposition: attachment; filename="mbxmoods.json". The response (200 and 304) also says how the file being sent stands against the analysis store: X-MBXHub-Export-Generation is the origin.generation read from the head of that file, X-MBXHub-Store-Generation is the store’s generation as last read (by the last load or the once-a-minute check, so up to about a minute old) and X-MBXHub-Export-Stale is true when the file is older than the store, false when it is not. Each header is sent only when its value is known; a catalog with no origin sends no export generation, and no stale header without both generations.
Local mood-cache totals, catalog-load timings and field coverage. warmupInProgress is always false and lastWarmup always null — that warm-up shape is preserved for client compatibility but no longer fires.
load reports what populating the cache actually cost: jsonMs/jsonCount for the canonical mbxmoods.json parse, sidecarMs/sidecarCount for truedat's .mbxs binary sidecar, storeMs/storeCount for a boot the analysis store served, plus speedup and savedMs. A figure that has not been measured in this process is null, never 0 — 0 ms would read as “instant” for something that never ran. A given boot takes ONE path, so the timings of two paths only appear together once the process has run both; speedup/savedMs compare the JSON and sidecar timings and appear only when both exist AND cover a similar row count (within 10%), because comparing a sidecar read of a near-empty catalog against a full JSON parse would flatter the ratio. source is the path that ACTUALLY built the cache — store (the analysis store, only with truedat.readStore on), sidecar, json (single file), multipart (merged mbxmoods.json.1..K), oversize-skipped (a >2 GB catalog file the JSON loader refused; oversizeBytes carries its size, null otherwise) or none — and is null while ready is false (cache mid-load or just invalidated), because the label describes the last completed load, not the current instant.
The sidecar verdict. sidecarVersion and sidecarStatus say why the fast path did or did not run: active (v3 + fresh — it built the cache), declined-version (v1/v2 carries no model scalars, so entries would be hollow), declined-stale (the sidecar does not belong to this catalog: when the catalog’s header names its sidecar by SHA-256 in origin.sidecarSha256, a sidecar whose bytes are not that one; when the catalog carries no origin block, a sidecar older than the catalog or with a different row count), unreadable (present but corrupt/foreign/locked) or absent (no file beside the catalog). sidecarVersion is null when no readable sidecar was found. Unlike source these are NOT gated on ready: they describe the resolution attempt, which is recorded at the START of a load, so they explain a slow catalog parse WHILE it is still running. headline and advice are the rendered forms — a one-line summary and the remedy — composed server-side so every surface states the same thing.
coverage is the fraction of cached rows carrying each auto-tuned continuity field — hpcp12 (harmonic texture), dynamicComplexity (dynamics) and averageLoudness (a v3-only scalar, so it reads near 0 on catalogs scanned by an older truedat even when hpcp12 is full). These are what the auto-weights ramp on: the fields only arrive on a truedat --refresh, so the weights follow coverage rather than waiting for the slider to be moved.
model names the trained model turning those features into valence/arousal — name (Model 296, Model 180 or a model file’s name), version, source (embedded or a file path), trainedAt, anchors, and the leave-one-out correlations valenceR/arousalR, with valenceTarget (labels or pairs) and valenceHeldOutPairAccuracy — a valence learned from comparison pairs has no leave-one-out r, so its valenceR reads 0 and the held-out pair accuracy is its validation. null when no model is loaded (the formula path), rather than a stub of zeroes that would read as a model scoring 0. files carries stat-only path/exists/bytes/modifiedUtc for the catalog and its sidecar — the evidence behind a freshness verdict, without opening either file.
store is what the analysis-store check found on the last load: path (the store file it opened, null when none resolved), state (absent, empty, older, ready, newer, unreadable; null before any load), version, reason, the notice for it ({id, level, text, toast} — the store message for that state, or null when there is nothing to say) and probeFailure (why an optional check could not answer, or null), served (whether the store built the cache and, if not, why: served from the store, store source off (truedat.readStore), store not served: {state} …, store not served: catalog root unresolved (…) - reading the files, store: empty (no present tracks) … or store load failed: …) and generation (the store generation the cache was served at; null when the store did not serve it, or when the store carries no generation yet). Both describe the PUBLISHED cache and are null before any load and while load.ready is false (a load in flight or just invalidated); they are read in the same snapshot as load, so the two blocks always describe the same load. latestGeneration is the store’s own generation as last read (by the load or the once-a-minute check), whichever source serves (null when unknown, never 0 — 0 is a real generation). stale: with the catalog files serving, true when the export the cache was built from is older than the store, false when it is not, null when either generation is unknown, the catalog is a legacy one or a torn multipart, or the store serves; exportGeneration is that export’s origin.generation when the files serve. While stale is true the AutoQ Workbench Mood-tab line and one Shell toast per export/store pair say so: “Catalog is behind the analysis store (export generation E, store generation S) — reading the older catalog. The next scan publishes the files from the store.” With truedat.readStore on (it is off by default) a Ready store builds the cache, and the catalog files build it whenever the store is anything else.
result is truedat’s last store-result.json beside the store. present is false with refusal null when there is no file, and false with refusal naming the problem when the file could not be read as a result record (wrong kind, a newer version, a malformed field). With a record: verb, outcome, reason, exitCode, startedUtc/finishedUtc/writtenUtc, store (schemaVersion, generation, truedatVersion), exported, exportGeneration, messages (every message the record carries, verbatim) and displayed (the ones shown as a notification). A fact truedat has not observed is null, never 0: exitCode and finishedUtc are null while a run has not reported its outcome, and store.generation is null when the store was not read — 0 is a real generation. knowledge says what the record means: pending (no outcome yet), died-before-finalizing (no outcome and the process has exited), completed, failed, interrupted or unknown-outcome; why is its sentence and process is running, exited or unknown. success is true only for an observed exit code 0 with outcome completed. Every key in store and result is always present, unknowns as an explicit null.
// Response:
{
"success": true,
"data": {
"total": 12480,
"essentia": 9812,
"fallback": 2668,
"warmupInProgress": false,
"lastWarmup": null,
"load": {
"ready": true,
"source": "sidecar",
"jsonMs": 877,
"jsonCount": 12480,
"sidecarMs": 9,
"sidecarCount": 12480,
"storeMs": null,
"storeCount": null,
"speedup": 97.4,
"savedMs": 868,
"oversizeBytes": null
},
"coverage": {
"hpcp12": 0.9984,
"dynamicComplexity": 1.0
}
}
}
Edge-transition coverage. What truedat’s mbxedges.v1.json beside the catalog gives the fill. present and state (nofile | building | ready | mismatch) — an absent file is a state, never an error; mismatch means the companion’s generation does not match the catalog’s, and edges are off until the next export. path, contract, producerVersion, extractor, generatedUtc and fileTrackCount come from the file header; indexed is the rows the index found. total is the mood-cache entries carrying an audio hash and withEdges those the file has a row for (computed when asked, held for 60 s). hydrated is the rows parsed this session: headOk/tailOk count ok edges among them, shortContext/unavailable/failed count edges in that status, malformed counts rows the parser refused. active is true when fills are ordering tail-to-head: whatever serves the edges — the analysis store at its selected version, or the companion file — is ready and disableEdgeTransitions is off (before 2026-10-23 this read the companion file’s state alone, so a store-served hub answered false while store edges shaped every fill); keyMode is the saved edgeKeyMode. notice is {id, level, text, toast} when the file is absent and edges are on (the message names the command that writes it), null otherwise. source is store or companion: when the mood cache was served from the analysis store (truedat.readStore on) and that store names a selected edge version (store_meta edges.selectedVersion, written by truedat), the edges come from the store at that version, and path is the store, producerVersion the selected version and indexed the tracks with an edge result at it; a track with no result at that version has no edges (the companion file is not consulted for it). A store with no selected version falls back to the companion, and reason says store unstamped - reading the companion. A store that moved after the cache loaded reads building until the cache reloads. reason also carries the generation mismatch text for mismatch.
// Response:
{
"success": true,
"data": {
"present": true,
"state": "ready",
"path": "C:\\Music\\mbxedges.v1.json",
"contract": "mbxedges.v1",
"producerVersion": "edges@v2;e30;p10",
"extractor": "...",
"generatedUtc": "2026-10-12T05:08:21Z",
"fileTrackCount": 12480,
"indexed": 12480,
"total": 12480,
"withEdges": 12011,
"hydrated": 240,
"headOk": 236,
"tailOk": 238,
"shortContext": 4,
"unavailable": 2,
"failed": 0,
"malformed": 0,
"active": true,
"keyMode": "strict"
}
}
Bulk write mood tags to a MusicBee tag field (the native Mood field or any Custom1–16). Only processes Essentia-analyzed tracks. Requires autoQ.moodTagField to be configured (e.g. "Custom1").
The written tag is a display export for MusicBee (e.g. a customized Now Playing panel showing the field) — MBXHub writes it but never consumes it; nothing in scoring or fills reads it back. There is no automatic bulk rewrite: after anything that changes classifications (model toggle, recording markers, channel edits), run Retag from Mood Tools on the AutoQ page to bring the whole library current. Individual tracks self-heal as you go — the now-playing track's tag refreshes on every track change, and AutoQ writes the tag on each track it queues.
// Response:
{
"success": true,
"data": { "updated": 342, "elapsedMs": 1302 }
}
// When a gate refuses the run (write toggle off, circuit breaker, field
// mismatch, no mood data), the response says why instead of a silent 0:
{
"success": true,
"data": { "updated": 0, "elapsedMs": 0, "blocked": "Mood tag writing is off — pick a mood tag field in Settings → AutoQ (writeMoodTags)" }
}
Clear the configured mood tag field (the native Mood field or any Custom1–16) on every cached track that has a value. Explicit cleanup — gated on library-tags read-only only, independent of the write circuit breaker. Targets the currently configured field, so clear before switching fields. Response: { "cleared": N, "elapsedMs": N }. Resumable — re-running skips already-empty tracks.
Live progress of an in-flight retag or clear. Response: { "running": bool, "op": "retag"|"clear", "processed": N, "total": N, "affected": N }. Poll while a bulk write/clear runs to show a track count. Both ops are resumable: a terminated run is resumed by simply re-triggering (retag skips unchanged tags, clear skips already-empty), so no checkpoint is needed.
Get current vibe list (candidate tracks with scores). Query: ?count=50
// Response:
{
"success": true,
"data": {
"count": 50,
"tracks": [
{
"url": "C:\\Music\\track.mp3",
"title": "Uptown Funk",
"artist": "Mark Ronson",
"genre": "Funk",
"score": 12.5
}
]
}
}
Discover tracks adjacent to the current taste profile, grouped by category. Query: ?limit=100&groupBy=auto|genre|artist|mood
// Response:
{
"success": true,
"data": {
"profile": {
"topGenres": [{ "name": "Rock", "weight": 1.0 }],
"topArtists": [{ "name": "Foo Fighters", "weight": 0.85 }],
"bpmRange": [90, 160],
"mood": "Energetic",
"influenceCount": 3,
"reactionCount": 12
},
"groupBy": "genre",
"groups": [{
"label": "Rock",
"reason": "genre",
"count": 15,
"tracks": [{
"url": "C:\\Music\\track.mp3",
"title": "Everlong",
"artist": "Foo Fighters",
"genre": "Rock",
"score": 8.2,
"reasons": ["genre match", "artist match"]
}]
}],
"total": 45
}
}
Find tracks similar to a seed track. Query: ?url={trackUrl}&limit=20&radius=5
When both tracks carry analysis, neighbors are ranked on the same continuity blend AutoQ picks with — mood position, continuous chroma, key, tempo, timbre and dynamics, at your configured continuity weights — so “similar” here means what it means in the queue. Tag affinity (same artist, same genre, close era) still refines the ranking, because it carries intent no measurement sees; it is also the whole score when either side has not been analyzed yet, so a library mid-scan degrades rather than misleads.
radius is optional and is a percentile, not a distance: radius=5 means “the closest 5% of the analyzed library”. It is a percentile because the blend distance is a normalized composite whose spread shifts as scan coverage grows — a fixed distance would quietly mean something different from one week to the next. Omit it for the historic behavior (plain top-limit); when both are given, the ring selects and limit caps what is returned.
Each track carries similarity (0–1, higher = closer), distance (the raw blend distance, null when the pair was scored on tags alone) and reasons — which dimensions actually pulled it close (chroma 92%, same key, close mood, similar timbre, similar dynamics), each omitted when either side lacks that data.
// Response:
{
"success": true,
"data": {
"seed": {
"url": "C:\\Music\\seed.mp3",
"title": "Everlong",
"artist": "Foo Fighters",
"genre": "Rock"
},
"tracks": [{
"url": "C:\\Music\\similar.mp3",
"title": "Learn to Fly",
"artist": "Foo Fighters",
"genre": "Rock",
"similarity": 0.6,
"reasons": ["same genre", "same artist", "similar BPM"]
}]
}
}
Tiered reactions for the now playing track. Each emoji has a different score weight.
| Emoji | Name | Score | Description |
|---|---|---|---|
| 🔥 | fire | +3 | This track is fire! (triggers queue refresh) |
| ❤️ | heart | +2 | Love this song |
| 👍 | like | +1 | Good choice |
| 👎 | dislike | -1 | Not feeling it |
| 🚫 | ban | -100 | Skip and exclude from queue |
Submit a reaction for a track. Reactions appear as floating emojis on the Display page. While the radio runs, a reaction to a scanned track also nudges the run’s center — positive pulls 20% toward that track’s mood point, negative pushes 10% away; persisted with the run detail.
// Request body (always reacts to currently playing track):
{
"emoji": "🔥",
"nickname": "Mike"
}
// emoji: "🔥", "❤️", "👍", "👎", "🚫" or "fire", "heart", "like", "dislike", "ban"
// Response:
{
"success": true,
"data": {
"recorded": true,
"emoji": "🔥",
"trackUrl": "C:\\Music\\track.mp3",
"trackTitle": "Uptown Funk",
"trackArtist": "Mark Ronson",
"nickname": "Mike"
}
}
Get reaction history. Query: ?trackUrl= for specific track, ?limit=100
// Response:
{
"success": true,
"data": {
"count": 25,
"trackUrl": null,
"reactions": [
{
"emoji": "🔥",
"type": "fire",
"trackUrl": "C:\\Music\\track.mp3",
"trackTitle": "Uptown Funk",
"trackArtist": "Mark Ronson",
"nickname": "Haro",
"timestamp": "2026-02-01T20:45:00Z"
}
]
}
}
Get party leaderboard data: guest activity, top tracks, reaction breakdown.
// Response:
{
"success": true,
"data": {
"totalReactions": 156,
"topTracks": [
{ "url": "...", "title": "Uptown Funk", "artist": "Mark Ronson", "score": 15 }
],
"guests": [
{ "nickname": "Haro", "totalReactions": 42, "fire": 10, "heart": 15, "like": 12, "dislike": 3, "ban": 2 }
]
}
}
Get all tunable AutoQ parameters: scoring weights, reaction scores, influence scores, estimation weights, and normalization ranges. Use with Tuning Console.
Partial update of AutoQ parameters. Only provided fields are changed. Changes take effect on the next scoring pass — no restart needed. Nested objects are merged, not replaced.
The tightness pair and slide pulls are settable here as flat fields: funnelReach01 (Reach) and glideTightness (Continuity), both 0–1, plus slideEvery (0–50, 0 = off) and slideReach (0–1). These are the stored defaults behind the radio strip's Tight↔Loose dial, which sets the pair live for the current run via POST /autoq/radio/tightness. Out-of-range values are ignored rather than clamped — the field simply does not appear in the response's updated list. excludeSpeechTracks (bool) and maxTrackLengthSeconds (int seconds, 0 = off) are settable here as well. Editing Reach or Continuity while the radio is running applies to that run from the next fill: the run normally derives both from the dial, so a hand edit would otherwise be inaudible until the next start. Moving the dial afterwards drops the override.
// Example: change just the BPM arousal weight
PUT /autoq/settings
{"estimation": {"arousalWeightBpm": 0.30}}
AutoQ settings in mbxhub.json under the autoQ section:
| Setting | Default | Description |
|---|---|---|
enabled | false | Enable AutoQ feature |
autoHeart.enabled | false | Auto-Heart: a Fire/Heart reaction on the now-playing track loves it in your MusicBee library (solo mode only). Off by default; opt-in. Respects the Loved feature toggle (apiDisableLoved) and the read-only tag flags; independent of protectMetadata. |
pickMode | "weighted" | How AutoQ selects candidates: off (AutoQ stops picking, and nothing fills the queue — playback runs out; TrueShuffle keeps its own skip rules but never queues), favorites (always pick highest-scored), weighted (score-proportional random from top candidates), random (uniform random, diversity caps still apply), fresh (play-recency: least-recently-played first, never-played is freshest). The effective queue driver is derived from this plus the AutoQ/TrueShuffle enable flags (AutoQ when enabled & pickMode ≠ off, else TrueShuffle if enabled, else Off) — surfaced in the MusicBee Settings dialog and the AutoQ Workbench so the active mode is unambiguous. |
freshStrict | false | Applies to fresh pick mode. true = strict freshest-first (never-played, then oldest-heard); false (default) = freshness-weighted random that still favors fresh tracks. Never-played composes with the playcount:0 filter, not a separate control. |
freshness | "Free" | Global default freshness for AutoQ picks: Free (no recency constraint), Fresh (least-recently-played first), NeverHeard (only 0-play tracks). A saved station's own freshness overrides this (unset = inherit global). NeverHeard composes with the playcount:0 gate; Fresh reuses the fresh pick-mode recency. Surfaced as the Freshness control (Never heard / Fresh / Free) in the AutoQ Workbench Builder's Filters section, and baked into a station by Save-as-station. |
queueThreshold | 3 | Add tracks when queue drops below this; also the fallback seed window — a seeded radio start seeds from the track playing now, and from the nearest this-many queued-ahead tracks only when nothing is playing |
batchSize | 5 | Tracks to add per batch |
maxTrackLengthSeconds | 0 | Longest track AutoQ will pick, in seconds (0 = no limit). Explicitly queued tracks are unaffected. Unknown-duration tracks are excluded while strictDurationLimit is on (the default), kept when off. |
model | Model 296 | Which mood model scores your tracks: Model 296 (the current model) or Model 180 (the July 2026 model), plus any mood-model.json or mood-model-*.json in the hub’s data folder. Switching reloads the model and rescores the library at once; it never rewrites mood tags in bulk. A new mood-model.json takes over at the next load (MusicBee start or POST /vam/model/reload). No effect while useTrainedModel is off. |
serveRatedTracks | true | Play a track you rated on the annotation page at your rating; the model only fills in unrated tracks, and that rating is the point AutoQ matches, picks and maps the track by. Off: every track plays at the model’s number. Ratings are kept, not applied. GET /autoq/track-mood reports ratedOverride either way. |
disableEdgeTransitions | true | Switch off the edge-aware transitions: with truedat's mbxedges.v1.json beside the catalog, AutoQ orders each fill tail-to-head (the outgoing track's last 30 s against each candidate's first 30 s). On (the default) = the whole-track point everywhere; transitions still match on each track's main key and tempo. Edges are opt-in: set it to false to use the edge file when it is present. |
edgeKeyMode | "strict" | How an edge's key is used at the join: strict (the edge key replaces the whole-track key) or blended (the harmonic term averages the edge pair and the whole-track pair). |
excludeSpeechTracks | true | Keep talk / podcast tracks out of AutoQ's auto-pick pool. A candidate whose Truedat verdict is speechLikely="yes" is dropped at the same candidate choke point as maxTrackLengthSeconds, so fills, generate, and journeys all inherit it; explicitly queued tracks, journey waypoints, and station seeds are unaffected. Only "yes" excludes — missing verdict, "unknown", "n/a", and "no" all stay playable, so coverage fills in gradually as the library is scanned. On by default; turn off if the (untuned) speech classifier shows early false positives. |
filter | "" | Candidate filter in search-DSL syntax. Works as include and exclude: a plain qualifier keeps only matches, a - prefix drops matches; terms AND together, OR and parentheses compose. Examples: include genre:rock, year:1980..1989, rating:>=4, genre:rock rating:>=3; exclude -genre:metal, -year:1990..1999, -genre:"Test Disc" -year:1990..1999 duration:<1200; compound (genre:rock OR genre:punk) -artist:"Nickelback" duration:<600. Empty = off. Saved strictly (PUT /system/config returns 400 INVALID_FILTER on a bad expression). Filterable keys: genre, year, duration, bpm, rating, playcount, artist, album. Missing data never matches a predicate (an include drops it, an exclude keeps it); unknown duration is excluded while strictDurationLimit is on (the default), kept when off. Explicitly queued tracks, journey waypoints, and station seeds are unaffected. A filter gates new fills only — it does not sweep tracks already in the upcoming queue; clear the queue to apply it right away. |
strictDurationLimit | true | Treat duration limits as hard gates. A track whose duration is unknown (unscanned, 0) can't be proven within a bound; on (the default) it is excluded by both a duration: filter bound and maxTrackLengthSeconds, so a limit means a limit; off it is kept (lenient). Only bites when a duration limit is set. Explicitly queued tracks, journey waypoints, and station seeds bypass either way. |
glideTightness | 0.6 | PFS glide / Continuity: weight of flow-from-previous (acoustic timbre + Camelot key + half/double tempo, now read from the tempo histogram's second peak where the scan found one + chord density) vs the flow target in each pick (0-1). Surfaced as the Workbench Tuning tab’s and the AutoQ console’s Continuity slider. Off-air this stored value is the glide. On-air the radio strip Tight↔Loose dial governs — the glide is derived from the dial and this field is left untouched (live per-run via POST /autoq/radio/tightness) — until you edit the slider, which overrides the dial for that run from the next fill on; moving the dial afterwards hands control back to it. The harmonic (Camelot) part of the term is scaled by keyAgreement, read as n-of-m over the key profiles a scan actually carried — full when two or more agree on the mixed key, half on a strict majority or a lone profile, dropped when the mixed key is outvoted; entries with no votes (pre-2026-07-22 scans) count as trusted. |
continuityKeyWeight | 0.25 | Key weight: how much of the continuity blend is Camelot key compatibility, relative to the fixed mood 0.30 / timbre 0.20 / tempo 0.25 weights (the sum renormalizes). A preference, never a gate: picks go to the lowest total distance, so a key clash still wins when the candidate fits better on mood, tempo and timbre, and raising this mostly dilutes those rather than tightening key. The term is also scaled by key-detection confidence, so past roughly 0.40 a raise amplifies detection error about as much as real clashes — for smoother harmony raise continuityHarmonicTextureWeight, which is measured rather than classified. 0 removes the key term entirely. Surfaced as the Workbench Tuning tab’s and the AutoQ console’s Key weight slider. Applies to radio fills, generate, and journeys alike. |
continuityPreferKeyCompatible | true | Prefer key-compatible picks: Prefer key-compatible tracks when choosing and ordering a run, within its candidate pool and existing rules. Uses available edge data when enabled; otherwise uses whole-track catalog data. Key weight controls the blend within that preference. When no compatible choice is available, the best eligible track still plays. Missing or distrusted keys give no harmonic preference. On by default. Harmonic & Dynamics group, under Key weight. |
continuityHarmonicTextureWeight | 0 | Harmonic texture weight: how much of the continuity blend is continuous-chroma (HPCP) similarity, a graded complement to the 1-of-24 Camelot continuityKeyWeight term beside it — Camelot owns key relation, this adds texture within and across it. Null on either side (unscanned, or a track scanned before the field shipped) skips the term and the remaining weights renormalize, exactly like a missing key. 0 = off (the shipped default); the operator raises it once the library carries the field. Surfaced as the Workbench Tuning tab’s and the AutoQ console’s Harmonic texture weight slider. Applies to radio fills, generate, and journeys alike. |
continuityDynamicsWeight | 0 | Dynamics weight: a small loudness/energy-side continuity term over Essentia dynamic_complexity — how close two neighbors sit in loudness dynamics. Same null-skip and renormalize behavior as the other continuity weights. 0 = off (the shipped default). Surfaced as the Workbench Tuning tab’s and the AutoQ console’s Dynamics weight slider. Applies to radio fills, generate, and journeys alike. |
continuityLevelStepWeight | 0.05 | Loudness Smoothing: a continuity term over the edge windows’ integrated loudness (LUFS): how close the outgoing track’s last 30 s and the incoming track’s first 30 s sit in level, a 6 LU jump or more reading as far. Needs edge data (a window with no level, the producer’s no-level floor, or edge transitions off skip the term and the rest renormalize). 0.05 = the shipped default, a light touch; 0 = off. Surfaced as the Workbench Tuning tab’s and the AutoQ console’s Loudness Smoothing slider. |
funnelReach01 | 0.246 | Reach: how wide the candidate funnel opens (0 = tight/near the run center, 1 = wide-roaming). Surfaced as the Workbench Tuning tab’s and the AutoQ console’s Reach slider. Same override rule as glideTightness: the strip Tight↔Loose dial governs a run by default and this field is left untouched; editing the slider overrides the dial for that run from the next fill on, and moving the dial clears the override. Applies to radio fills, generate, and journeys alike. |
slideEvery | 10 | PFS slide: every Kth pick steps outside the neighborhood (0 = off) |
slideReach | 0.35 | PFS slide: minimum mood-space distance from the previous track for a slide pick (0-1) |
autoq-flows.json | — | Drop-in flows (file beside mbxhub.json, not a setting): {"flows":[{"name":"gentle","kind":"wave","waveDepth":0.1},{"name":"sunrise","curve":[0.2,0.5,0.9]}]}. Named flows = base kind + knob overrides, or an absolute arousal curve lerped across each batch. Usable everywhere a flow name is accepted (radio start, generate/connect, saved stations, the Workbench picker); hand-edits live on next use; built-in names can’t be shadowed; deleted customs degrade to smooth. |
vibeListSize | 100 | Size of candidate track pool |
moodMatchWeight | 0.4 | Weight for mood matching in scoring (0-1) |
recencyDecayLambda | 0.1 | Decay rate for recency boost on reacted tracks |
recencyPenaltyDecay | 0.003 | Decay rate for recently-played penalty (about a four-hour half-life; was 0.1) |
minReplayMinutes | 30 | Minimum minutes before a track can be replayed |
diversityWindowSize | 10 | Recent tracks considered for diversity calculations |
minSessionEntropy | 0.5 | Entropy threshold before boosting diversity (0-5) |
vibeListRefreshMinutes | 30 | Minutes between automatic vibe list refreshes |
moodChannels | null | Custom mood channels (array). Uses defaults if null. Each channel carries its own genreQuotaExempt (default false) — spec 2026-07-09-genre-quota-per-stream.md. |
genreQuota | 3 | Max consecutive same-genre tracks (0=disabled). Suspended per-run when the run is genre-quota exempt (saved stations default exempt, plain seeded starts default exempt, mood channels default not-exempt) — spec 2026-07-09-genre-quota-per-stream.md. |
artistQuota | 1 | Max tracks from same artist in batch (0=disabled) |
moodTagField | "Custom1" | MusicBee tag field for mood labels — the native Mood field or any Custom1–16 (null=disabled) |
moodTagFieldName | "AutoQ Mood" | Expected display name for the tag field (must match MusicBee config) |
moodTagMultiValue | false | Save each mood as a separate tag value (Chill; Emotional). Off keeps the combined label (Chill / Emotional). Existing tags change on Retag; saving the option does not retag. |
moodTagIncludeEmojis | true | Include emojis in saved mood tags. Existing tags change on Retag; saving the option does not retag. |
sendMode | "refresh" | Default Send to Q Behavior: reuse (join seeds cumulatively), refresh (newest send leads, weighted), restart (replace seeds outright) |
sendRefreshWeight | 0.7 | Refresh weighting 0–1: how strongly the newest send dominates the recomputed run center (0 behaves like reuse, 1 like restart) |
sendWaypointCap | 3 | Max waypoints one send adds to an in-flight journey (1–4; engine hard cap is 4 plus the destination = 5 pinned) |
sendQueueCap | 0 | How many sent tracks queue immediately (0 = all). Steering always uses the full batch regardless of this cap |
sendBoostStrength | 1.0 | Station-memory write per send: 0 disables, above 0 records an upvote (the store is binary today — parity with a thumbs-up) |
sendIdleStart | "station" | Idle send behavior: station (start one from the sent tracks) or tray (collect only, surface-side) |
sendMaxSeeds | 12 | Seed-set bound for cumulative modes; the oldest send generation evicts first, the newest always survives |
sendMagnetPolicy | "keep" | keep = active mood magnet survives a send; clear = a send drops it so a re-anchor is not fought by a stale magnet |
sendWaypointOverflow | "queue" | Sent tracks beyond the waypoint cap: queue (play but do not bend the arc) or drop |
sendQueuePlacement | "next" | Where sent tracks land in the queue: next or end |
sendRefill | "keep" | keep = already-queued fills stay; replace = unplayed fills behind the sends are re-picked against the new center |
diceScope | "library" | What the dice rolls over for every device that follows the hub: library (any album) or unheard (only albums with no played tracks). A roll to POST /autoq/dice that states no scope uses it; a device can keep its own choice on the dice page |
diceRolls | "album" | What the dice rolls for every device that follows the hub: album (a whole album, the shape that shipped) or track (one track). A roll that states no rolls uses it; a device can keep its own choice on the dice page. A track roll pairs naturally with diceOnPress: seed |
diceOnPress | "play" | What a dice press does for every device that follows the hub: play (the album replaces the queue) or seed (the album starts a station). A roll that states no onPress uses it; a device can keep its own choice on the dice page |
Send to AutoQ (POST /autoq/send, above): sendMode, sendRefreshWeight, and sendWaypointCap are the three primaries surfaced on the generic settings page; the rest are secondary knobs (workbench + /autoq/settings only) whose defaults reproduce the ruled behavior exactly.
Scoring weights under autoQ.scoringWeights: featureSimilarity (0.5), trackSentiment (0.25), artistSentiment (0.15), recencyPenalty (0.3), diversityPenalty (0.6), explorationBonus (0.1), influenceWeight (0.3).
Reaction scores under autoQ.reactionScores: fire (3), heart (2), like (1), dislike (-1), ban (-100).
Estimation, valence/arousal weights, normalization ranges, confidence thresholds, and genre profiles — full field list with defaults and descriptions is in the config reference. Live values: GET /autoq/settings. Tune interactively at /pages/autoq.html.
Mood Quadrants — Arousal (energy) is the vertical axis, valence (positivity) is the horizontal. Each mood channel targets a point in this space.
| Quadrant | Profile | Typical Genres | Acoustic Traits |
|---|---|---|---|
| High arousal + high valence | Energetic, upbeat | EDM, pop, funk | Fast tempo, bright timbre, strong beats |
| High arousal + low valence | Tense, aggressive | Metal, hard rock, industrial | Distortion, high energy, dissonance |
| Low arousal + low valence | Sad, subdued | Ambient drone, slow blues, lo-fi | Slow tempo, dark timbre, soft dynamics |
| Low arousal + high valence | Calm, pleasant | Chillhop, acoustic folk, soft jazz | Warm timbre, consonance, smooth textures |
VAM (Valence / Arousal / Mood) is the lens AutoQ uses to score tracks. The calibration loop centers on Auto-Cal, a MusicBee playlist the user maintains by hand. It holds anchor tracks — songs picked as corner / archetype examples for the V/A axes (“this is maximum energy,” “this is deepest sad”). Everything in this section either feeds Auto-Cal, reads from it, or measures how well the current model agrees with it.
End-to-end:
vamAnchorPlaylist setting (default "Auto-Cal").GET /vam/gates/report. The Gates panel at the top of /pages/autoq.html joins the playlist with your annotations and shows per-track and overall fit between the current model and your taste (residual, strict-pass count, fail count, mean residual).tools/ (calibrate-valence-arousal.py + extract-sony-sensme.py) plus a requirements.txt for reproducibility — developer-use only. The user-facing retrainer is planned to move into the Truedat repo so it ships next to the analyzer that produces mbxmoods.json.POST /vam/model/reload. The loader prefers %AppData%\MBXHub\mood-model.json over the embedded copy in the DLL, so a fresh retrain swaps in without a rebuild.The pairwise tuning page (/pages/tune.html) feeds the same calibrator from the other side — A/B comparisons accumulate as Bradley-Terry signals that fit alongside the anchor labels.
The annotate page reaches the rating card via the random quadrant-balanced queue, or a deliberate search-and-pick of a known corner song to use as an anchor.
Pick the next track for the rating card: a honeypot re-serve of an already-rated track about 10% of the time (once at least 5 are rated), else the next unrated Auto-Cal anchor if one remains, else a fresh quadrant-balanced pick from the library. Query param mode=disagreement is still accepted, but SMFM-derived valence/arousal is retired, so no track carries a second opinion to disagree with: the mode always falls back to the standard pick. Card shape: {key, url, title, artist, album, isRetest, isAutoCal, rated, modelV, modelA, smfmV, smfmA} — modelV/modelA are the current model's prediction (opt-in pre-fill ghost on the plot), smfmV/smfmA remain in the shape for compatibility and are always null since SMFM-derived valence/arousal is retired. 503 NO_LIBRARY if the library has no tracks; 503 NO_ESSENTIA_DATA / 503 NO_TRACK if no unrated essentia-scanned candidate exists.
Search the library for a track to pick. Query param q — case-insensitive substring match on title + artist, filtered to essentia-scanned tracks with an audioStreamSha256 key. Returns up to 20 results; q shorter than 2 chars returns an empty list. rated is true for tracks already labeled.
GET /vam/annotate/search?q=miles
{"success":true,"data":{"results":[
{"key":"<sha256>","url":"file://...","title":"So What",
"artist":"Miles Davis","album":"Kind of Blue","rated":false}
]}}
Load a specific chosen track as a rating card. Query param url — must be in the library pool and essentia-scanned. Returns the same card shape as /vam/annotate/next. 404 NOT_PICKABLE if the url is unknown or not essentia-scanned. Picked tracks default the anchor flag on — picking is the deliberate-anchor path.
The rating endpoint is the one that mutates — everything else here is read-only. POST /vam/annotate/rating honors ApiReadOnlyMode (returns 403 API_READ_ONLY) so kiosk deployments can't accumulate stray labels.
Persist a V/A rating for one track. Body {key, valence, arousal, confidence ("sure"|"unsure"), isAnchor, path, prefillUsed?, ghostVisible?, leadInMs?}. Tracks in the configured vamAnchorPlaylist get isAnchor=true forced server-side, so the user can't accidentally unset the anchor flag on a curated calibration track. Response: {ok, total, file}.
Provenance — how the rating was made. prefillUsed says the marker was pre-seeded from the model's own prediction; ghostVisible says the model ghost or SMFM star was on screen while rating; leadInMs is milliseconds of actual listening — the accumulator the page's own commit gate used, not wall-clock time since the card loaded. Omit any of them and it stores as null, which reads as unknown and must never be read as "clean". projectorVersion and modelSource are stamped by the server, not accepted from the body: a client is not the authority on which model was in front of the rater. A rating with prefillUsed: true is stored and marked, never dropped — a trainer can only exclude what it can see — and is logged at WARN.
Why this exists. On 2026-09-11 the SMFM projector (v1-unsupervised-pca — PCA over Sony's vectors, with no human rating anywhere in its fit) was measured correlating +0.9629 with the operator's 180 hand ratings, which is higher than a person agrees with themselves on a re-rate. The suspected route is the annotation page's pre-fill mode, which started the marker on the model's own prediction and waived the listen gate — so a "label" could be the model's answer handed back to it as ground truth. Nothing in the stored record could say whether that had happened. Now it can.
Anchor editor: create or OVERWRITE a label's primary V/A by clicking the plot. Body {key, valence, arousal, confidence?, isAnchor?, path?, prefillUsed?, ghostVisible?, leadInMs?} (isAnchor defaults true — a placement is a deliberate anchor). Provenance behaves exactly as on /rating; because an upsert overwrites the primary V/A it overwrites the provenance with it, so the record always describes the placement it actually holds rather than an earlier one. Unlike /rating it overwrites the primary and does NOT append a retest; training input only, never overrides the deterministic model output. Honors ApiReadOnlyMode. Response: {ok, updated, total, file}.
Anchor editor: delete a label. Body {key}. Honors ApiReadOnlyMode. Response: {ok, removed, total, file}.
Counter snapshot for the annotation HUD: {rated, librarySize, remaining, anchors, unsure, quadrants:{q1angry, q2happy, q3sad, q4calm}, selfConsistency:{retested, meanValenceDelta, meanArousalDelta, score}}. selfConsistency.score is 1 - mean(|ΔV|, |ΔA|) across honeypot retests; null until at least one retest has happened.
Reference markers for the V/A scatter plot: the 12 fixed AutoQ mood channels ({name, emoji, valence, arousal}) plus the user's own anchor tracks ({key, valence, arousal, path}).
Full labels store in the shape the Phase V2 trainer consumes: {count, file, labels:[{key, valence, arousal, confidence, isAnchor, path, ratedAt, sessionId, prefillUsed, ghostVisible, leadInMs, projectorVersion, modelSource, retest:[…]}]}. The trainer reads this file via --labels; the endpoint is the canonical shape.
The five provenance fields ride on the retest entries too — a re-rate is a rating, and the honeypot retests are the only reliability signal a solo rater has, so a record describing only the primary could give a clean bill of health to a re-rate made with the model's answer on screen. null on any of them means the record predates provenance (the operator's original 180) and must be read as unknown, not as "no pre-fill was used" — they are deliberately not backfilled with a guess. A trainer excluding contaminated labels filters prefillUsed === true and treats the nulls as a separate cohort.
Coarse gross-error gate (spec §4). Compares each anchor's user-rated quadrant against a producer's V/A prediction with a center dead-zone so a near-neutral track doesn't fail for crossing a line by a hair. Default producer is mbxhub-formula (in-process). Query params: ?predictions=<path> points at an external V2 producer JSON, ?deadZone=0.15 overrides the default tolerance. 503 NO_ANCHORS if no anchors are designated yet.
Compare the embedded model's predictions against the user's anchor labels. The Gates panel at the top of /pages/autoq.html consumes this surface, and it's the sanity check after every retrain.
Enumerate the vamAnchorPlaylist (default "Auto-Cal") and join each track with its VAM anchor annotation. Returns per-track rows (label V/A, model V/A, residual, pass/fail vs configured tolerance) plus summary stats (strict pass count, fail count, mean residual). Resolved by playlist name on every request — rename in MusicBee and the endpoint reflects it without restart.
Coordinate-descent fit of the legacy formula weights against the rated anchors. Returns suggested values for every valenceWeight* / arousalWeight* / intercept. Only takes effect at scoring time when useTrainedModel=false — otherwise the formula path is bypassed and the Tuning Console badges this state.
Same fitting machinery as /vam/gates/suggest-weights; returns the full calibration response shape (initial / final RMSE, per-weight delta). Same caveat re useTrainedModel.
The trained model ships embedded in the DLL but a disk copy at %AppData%\MBXHub\mood-model.json supersedes it. Lets you iterate on retrained models without rebuilding: train → write to AppData → POST /vam/model/reload → loader picks up the new file.
Returns {loaded, source ("disk"|"embedded"|"none"), modelName, overridePath, useTrainedModel, modelUsable, active, activePath ("model"|"formula"), model:{version, trainedAt, anchorsUsed, featureCount, features, valenceLoocvR, arousalLoocvR, essentiaOnlyHead, essentiaOnlyValenceR, essentiaOnlyArousalR, essentiaOnlyValenceTarget, essentiaOnlyValenceHeldOutPairAccuracy, essentiaOnlyValenceOutput}}. Drives the model status in the AutoQ Tuning Console. modelName names the model running — Model 180, Model 296 or a model file’s name, as chosen by the autoQ.model setting; source reads embedded for either built-in model. When trained scoring is enabled, the valid essentiaOnly audio head takes precedence for all tracks; otherwise a main head with no Sony-derived features may be used. A model requiring SMFM without a usable audio head cannot score: modelUsable is false and scoring falls back to the formula. active requires both useTrainedModel and modelUsable; disabling trained scoring also selects the formula. Labs switches do not select a scoring head or trigger mood recomputation. features, featureCount and the LOO-CV r values describe the audio head when present, otherwise the main model. These r values are stored training-validation metadata, not confidence in an individual track's classification. essentiaOnlyHead reports whether the audio head is loaded; essentiaOnlyValenceR and essentiaOnlyArousalR are its stored LOO-CV r values (null when absent). A valence learned from comparison pairs carries no LOO-CV r (its r reads 0): essentiaOnlyValenceTarget is pairs for it (labels for a fit on absolute ratings), essentiaOnlyValenceHeldOutPairAccuracy is the share of held-out comparison pairs it orders correctly (null for a label fit), and essentiaOnlyValenceOutput is quantile when the served valence is the track’s position within the library on the learned score (0 at its lowest, 1 at its highest) or stretch for center/stretch. All three are null when there is no audio head.
Drop the cached model, re-read the model autoQ.model names (a new mood-model.json in the hub’s data folder takes over first), then recompute every cached per-track valence/arousal in place — the promoted model takes effect immediately, no MusicBee restart. Returns the same shape as /vam/model/info plus recomputed (entries swept; -1 when the mood cache wasn't loaded yet). The Workbench mood map and the Slices mood tally refresh with it. Honors ApiReadOnlyMode (returns 403 API_READ_ONLY).
Training-data export consumed by rebuild-mood-model.cmd / tools/calibrate-valence-arousal.py: a sha-keyed derived view of the in-memory mood cache carrying every trainer feature plus the C#-only modeMajor encoding (1.0 major / 0.0 minor / null unknown). Sony-derived smfmValence/smfmArousal are no longer exported, and projectorVersion/projectorHash are always null. Raw JSON, not the success envelope — streamed directly to the response, roughly 10–40 MB at 70K tracks. Shape: {generatedAt, projectorVersion, projectorHash, tracks:{<audioStreamSha256>:{path, audioStreamSha256, …essentia features…, modeMajor}}, count, skippedNoSha}. Entries without an audioStreamSha256 are skipped and counted in skippedNoSha (labels join by sha). mbxmoods.json itself is never modified — this is a read-only derived view.
Read-only observability for VAM tuning: the active model (modelName beside source) + its LOO metrics, which training inputs exist and their counts (mbxmoods / mbxvam-labels / mbxtune-pairs) plus mood-cache stats, the active-vs-suggest-weights distinction, a bakeEnv block ({pythonOnPath, pythonPath} — cheap PATH scan for a future retrain/bake; no process spawn or sklearn probe), and a knownGaps block flagging documented model-recording gaps (e.g. tune-pair usage not recorded in the model file; trainer joins labels to features by path, not audioStreamSha256). Answers "what is the model actually consuming?" Computes and mutates nothing.
The tuning page (/pages/tune.html) is the "second opinion" loop for V/A scoring. The user picks the higher-on-axis track from a pair (or marks them roughly equal); judgments accumulate as Bradley-Terry signals that the V2 trainer fits alongside the single-track anchor labels. Pair selection biases toward under-compared tracks and close-pair candidates — the model is most uncertain when scores are similar, so each judgment carries more signal than a random pick across 71k tracks where most pairs are trivially separable.
Build a fresh A/B pair. Query param axis = arousal or valence. Picks A as the least-compared candidate (random tie-break), then picks B as the candidate whose model score is closest to A. Response: {axis, a:{url, title, artist, score, comparisons}, b:{url, title, artist, score, comparisons}}. 503 NO_LIBRARY if the library has fewer than 2 essentia-scanned tracks.
Persist a pairwise judgment. Body {a, b, axis ("arousal"|"valence"), verdict ("a"|"b"|"tie")}. Append-only to mbxtune-pairs.json in the AppData folder. Honors ApiReadOnlyMode (returns 403 API_READ_ONLY). Response: {ok, total, axis, file}.
Per-track Bradley-Terry scores fitted from the accumulated judgment store. Query param axis. Response: {axis, pairCount, trackCount, tracks:[{url, score, comparisons}]}. Used by the tune page to show the score distribution + outliers.
File a misclassification flag against a single track. Body {url, observedChannel?, observedArousal?, observedValence?, expectedChannel?, notes?}. Separate store from judgments (mbxtune-flags.json); the trainer uses these as bias-correction signals. Honors ApiReadOnlyMode (returns 403 API_READ_ONLY). 503 FLAG_STORE_DISABLED if the optional flag store isn't wired. Response: {ok, total, file}.
All flags, or per-track aggregates with ?summary=true. Summary mode returns {trackCount, totalFlags, tracks:[…]}.
Retired. The SMFM-vs-AutoQ source duel ended with Sony-derived valence/arousal. Returns 410 SMFM_VA_RETIRED.
Retired. Source-duel votes are no longer accepted; returns 410 SMFM_VA_RETIRED. Votes already in mbxtune-source-duels.json stay on disk but are not read.
Retired. There is no SMFM blend any more: /autoq/track-mood's effectiveArousal/effectiveValence equal the computed valence/arousal. Returns 410 SMFM_VA_RETIRED.
Generic seam for computing and writing MusicBee Custom* fields from registered providers. Master flag: customFields.enabled (default false). When off, all /fields/* endpoints return 403 FIELDS_DISABLED. Enable from the Labs category in Settings; the Labs page (/pages/labs.html) is linked from the Configuration page.
First provider — SMFM (smfm): reads the 10 raw SMFM/STMO scores captured from the 12 TONE/SMFM block during the Truedat scan (stored in mbxmoods.json), and exposes them for inspection. Projecting them to an (arousal, valence) position, using them in AutoQ scoring, and writing them to Custom* fields are all retired. The raw 10 scores + BPM are kept as-is.
Matching: a track resolves to its SMFM data by absolute path, falling back to its path tail (artist\album\file.ext) when the path misses — so a mbxmoods.json scanned under a different drive root or category folder still matches the local library.
Settings (mbxhub.json): customFields.enabled (bool, default false, master flag); customFields.smfm.enabled (bool, default false, enables raw-score inspection through GET /fields/smfm/track; requires the master flag; no effect on AutoQ scoring). customFields.smfm.retagOnReload and customFields.smfm.fields are kept in config but unused, since SMFM field writing is retired.
List all registered providers. Response: {providers:[{id, displayName, enabled, targets:[{field,name}], coverage:{total,withData}}]}. total is library size; withData is tracks that have source data (e.g. the raw 10 SMFM/STMO scores for smfm). Returns 403 FIELDS_DISABLED when customFields.enabled is false.
Coverage detail for one provider. Response: {provider, total, withData} — total is library size, withData is the count of tracks with the raw 10 SMFM/STMO scores. The projected valence/arousal means are retired. 404 NO_PROVIDER for unknown provider ids. 403 FIELDS_DISABLED when facility off.
Raw SMFM scores for one track, for inspection. ?url= is the file path (UTF-8). Response: {url, found, scores} — scores is the raw 10-element SMFM/STMO array; found:false with an empty scores array when the track has no SMFM data. The projected valence/arousal position is retired. Errors: 404 NO_PROVIDER, 403 SMFM_DISABLED when customFields.smfm.enabled is false, 400 MISSING_URL if ?url= is omitted.
Retired for SMFM. SMFM field writing ended with Sony-derived valence/arousal, so smfm returns 410 SMFM_VA_RETIRED and its saved target fields are kept but unused. Any other provider id returns 400 UNSUPPORTED; no provider takes configuration today.
Retired for SMFM. smfm returns 410 SMFM_VA_RETIRED: its valence/arousal values are no longer written to Custom* fields. An unknown provider id returns 404 NO_PROVIDER.
Retired for SMFM. smfm returns 410 SMFM_VA_RETIRED. Values an earlier build wrote to Custom* fields stay in the files; clear them in MusicBee if you no longer want them. An unknown provider id returns 404 NO_PROVIDER.
List review manifests dropped into <AppData>\MBXHub\review\ by offline tools (tools/fleet/). Each entry: {id, kind, title, generated, classCount, hasVerdicts, savedUtc}. The id is the file's name stem — drop each manifest as <id>.json (that is what /review/manifest/{id} and the verdicts routes resolve); an internal id property, if present, is display-only and ignored for routing. The hub never computes diffs — it serves manifests and stores verdicts. Unreadable manifest files are skipped with a warning in the log.
Full manifest JSON. Classes are self-describing — each carries its own columns, rows (or rollup for huge classes), rulingOptions, and rowOptions, so new scenario kinds (diff, identity, coherence, dupes) need no API or page changes. Errors: 400 REVIEW_INVALID (bad id), 404 REVIEW_NOT_FOUND.
Operator winner rulings, stored beside the manifest as {id}.verdicts.json. POST body: {id, rulings:{<classKey>:{ruling, overrides:{rel:value}, excludes:[folder]}}}; the hub stamps savedUtc (authoritative) and keeps the prior file as a single .bak (atomic replace). GET returns the saved verdicts or 404 REVIEW_NO_VERDICTS. POST returns 403 API_READ_ONLY when ApiReadOnlyMode is set. Other errors: 400 REVIEW_INVALID (bad id, non-JSON body, missing rulings, or body id mismatch), 404 REVIEW_NOT_FOUND (no such manifest), 413 REVIEW_TOO_LARGE (> 4 MB — verdicts carry rulings, never row data). Review page: /pages/review.html renders any manifest generically; the same renderer ships in the standalone offline door emitted by tools/fleet/build-review-manifest.ps1 -Standalone.
Serve the interactive companion page a manifest points at via source.reviewHtml (e.g. truedat's co-emitted dupes.html), same-origin as text/html — a plain file:// link from the http page is browser-blocked. Only the manifest's own declared reviewHtml is served, constrained to a bare .html filename resolved strictly inside the review folder (no path component, no traversal). 404 REVIEW_NO_ASSET when the manifest declares no companion. The /pages/review.html view shows an “Open interactive review” link when this is present.
Apply a decisions delta by delegating to truedat --apply-exclusions. The hub writes the delta to a temp file unparsed and invokes truedat — truedat owns the merge and is the only writer of the exclusion file; the hub never writes it. On success (200) the response body is truedat's apply-result.json raw ({kind, ok, added, removed, alreadyPresent, notFound, changed, backupPath, error}) — read ok/added/removed at the top level; this endpoint is a passthrough shim, not the hub envelope. Rule kinds are the closed set folder/genre/file. Errors use {error:{code, message}}: 403 API_READ_ONLY (read-only mode), 400 REVIEW_INVALID (bad id or missing source.exclusionsPath), 404 REVIEW_NOT_FOUND (no such manifest), 409 REVIEW_TOOL_BUSY (truedat already running — single slot), 413 REVIEW_TOO_LARGE (body too large). This is a scan-cost exclusion — it stops truedat spending analysis time on the track and does not remove it from AutoQ picking. To keep a track out of AutoQ, use POST /banlist (reference §9a).
Manifest kinds. The page renders any manifest from its declared columns/options. kind:"dupes" gets a group-keeper affordance: member rows are grouped by duplicate set, each group shows its copies with the recommended keeper marked (a rec badge) and a radio to pick one. Display-only — Save is hidden and no verdicts are written. The dupes manifest is emitted directly by truedat --duplicates --manifest <path> (dropped as <AppData>\MBXHub\review\dupes.json); diff and other kinds come from tools/fleet/ producers.
Scanning is how tracks earn their intelligence. truedat — MBXHub’s companion audio analyzer — reads each file’s actual audio and writes the results to mbxmoods.json: mood position (valence/arousal), key and Camelot code, tempo detail, timbre, and the write-time verdicts (speechLikely, hiresGenuine, lossyTranscodeLikely). The hub consumes that catalog live — everything from AutoQ’s picks to key: search to the Camelot wheel reads it. No tags are written to your files; re-tuning a threshold re-derives verdicts across the whole catalog with no rescan.
Harmonic-texture wave (2026-08-09). Newer scans also record the track’s harmonic color and rhythmic feel: hpcp12 (a 12-bin pitch-class chroma profile — what drives the Harmonic texture weight slider), thpcp12 (the same profile made key-invariant; recorded, not yet used), dynamicComplexity, beatsInterval{Mean,Stdev,Min,Max} (a summary of the gaps between beats — how steady the beat is; note this is not the beat positions themselves, so it cannot align phrases), and a set of variability figures (spectralCentroidStdev, spectralEnergyStdev, dissonanceStdev, pitchSalienceStdev, hpcpEntropyStdev, zeroCrossingRateStdev, mfccStdev) describing how much each quality moves within the track. These fields cannot be filled in after the fact — truedat discards the raw analyzer output once parsed — so an existing library gets them from a fresh analysis or one --refresh-features pass. A track scanned before the wave simply reads as not measured (never zero), and any tuning weight over a missing field steps aside so the remaining weights still add up.
The hub launches and supervises external tools through a general-purpose Tool Runner, so a scan runs from a button instead of a stray console: tool discovery on your system, one-instance-at-a-time launching (a second start is refused with already running, never stacked), running/finished status with exit code and duration, and output capture into the hub’s log for hidden runs. It never kills a process — the hub shutting down leaves a scan running to completion. truedat is the first tool to ride it; nothing in the runner is truedat-specific.
Quick start. 1 — Drop truedat next to the plugin (Plugins\truedat) or anywhere on PATH, and turn on truedat.enabled; a blank truedat.path auto-discovers and writes back the found location. 2 — Open the AutoQ Workbench → Tuning tab → Scan. 3 — Let it run (a visible console by default; hidden routes output to the hub’s log instead). 4 — Results land in mbxmoods.json and the hub picks them up live — no restart. Re-run any time: already-analyzed tracks are skipped, so follow-up scans only cost the new files.
Running a scan. The Scan button on the AutoQ Workbench’s Tuning tab launches truedat against your library (discovery + prerequisites handled; the button explains any refusal). ARiA’s run(truedat) uses the same supervised path when the truedat.ariaWhitelist mirror is on. Behavior is governed by the truedat.* settings — enabled, path, args, hidden, and pauseOnExit (keep the console open on “Press any key…” after the run so the output can be read; the one-scan slot stays held until the window is dismissed). See the settings reference for the full table.
What a scan is not. POST /aria/scan (ARiA section) triggers MusicBee’s own scan-folders-for-new-files — it finds new files; it does not analyze audio. Scan-cost exclusions (POST /review/decisions/{id}, Review section) keep truedat from spending analysis time on a track — they do not affect AutoQ picking (that is POST /banlist). The data a scan yields is read back per-track via GET /autoq/track-mood (AutoQ section).
truedat can also keep an analysis store (truedat.db), a database of its scan results. truedat writes it only when the store is enabled (--store, or "store": true in the JSON file truedat.config.json); it is off by default. Reading the store is opt-in: with the truedat.readStore setting on (it is off by default) the hub reads a Ready store. Either way it looks for one, reports what it found in the store block of GET /autoq/mood-cache/status and on the AutoQ Workbench’s Mood tab, and where there is no Ready store, or the setting is off, it builds the cache from the catalog files instead: mbxmoods.json, its .mbxs sidecar and the mbxedges.v1.json edge file. With the setting on, a Ready store builds the cache instead and source reads store, serving only the library root the catalog’s header names (origin.rootPath and origin.rootRole, written when a scan publishes the files from the store): exactly one root in the store must have that path and role, compared exactly, or the catalog files serve. A store that is not Ready, holds no tracks or cannot be read, or a catalog that names no root (or a root the store does not hold exactly once), leaves the catalog files serving, and the store block’s served says why. Until truedat’s scans publish through the store, a catalog written by a scan with the store on names no root, so the store is read only after a scan has published the files from the store (truedat does this itself; --store-export is never the remedy). While a store is present the hub re-reads its generation once a minute: with the store serving, a new generation reloads the cache; with the files serving nothing reloads — a store ahead of the catalog’s export marks the load stale and the older catalog keeps serving. Changing it takes effect on the next mood-cache access, without a restart.
Where the hub looks. In one place, chosen the way truedat chooses its default: beside the catalog when the catalog’s folder is on a local drive, and at %LOCALAPPDATA%\truedat\<library id>\truedat.db when that folder is on a network share. The second is found only when truedat ran on the same machine, as the same user. The library id is a hash of the catalog folder’s full path, ignoring letter case, slash direction and a trailing separator, so Z:\Music and \\nas\music are different libraries. A store truedat was told to keep somewhere else — an explicit --store <path>, or --store-dir (which puts it at <store-dir>\<library id>\truedat.db) — is not found by this hub, and this hub does not read a store on a network share.
Getting a store. Where you start decides the step. A fresh install: a scan with the store enabled creates the store and writes the catalog files; the edge file appears only when edges are enabled too (--edges, which needs the store and the output-x64.5 extractor that ships with truedat). An existing catalog and no store: truedat --store-import builds the store from the catalog in seconds, without re-analysis; it does not create edge data, and from then on every scan uses that store (truedat opens a store it finds; --no-store / --no-edges skip it for one run). Edges are opt-in in truedat too: they are added only with --edges or edges: true in truedat.config.json. A store made by an older truedat: truedat migrates it the next time it opens it, after making a backup copy beside it; it refuses, changing nothing, when there is not enough free space (about twice the store’s size plus 10%). After that, every scan run with the store enabled writes the tracks it analyses into the store; tracks it skips as already scanned are not written, so an existing catalog reaches the store only through --store-import. truedat’s catalog-editing commands (--fixup, --remap, --merge-moods, --migrate and the like) do not update the store yet. In every case the hub falls back to the catalog files whenever the store is not Ready, or when truedat.readStore has been turned off.
What you will see. The hub’s store check reports one of the messages below in store.notice on the status endpoint, on the Workbench Mood tab and in the hub’s log; a store it can read, or a database file that is not a truedat store yet, carries no message. M14 is about the edge file and appears in notice on GET /autoq/edges/coverage. The ones marked notification are also shown once as a notification. The message text is quoted exactly as the hub writes it. M2 and M3 appear only with truedat.readStore on, and M14 only with edges on (disableEdgeTransitions set to false); with them off, the status still reports the store’s state and whether the edge file was found.
| id | When | Message | What to do |
|---|---|---|---|
| M2 | No store found, and the hub cannot tell whether your truedat keeps one | No TrueDat store yet — reading mbxmoods.json. To create it: truedat --store --edges | Nothing is required; enable the store in truedat if you want one. |
| M3 | No store found, and your truedat is known to keep one (notification) | TrueDat store available — to create it: truedat --store-import, then truedat --edges --refresh (full rescan, for transitions). | Run truedat --store-import, then truedat --edges --refresh for transitions; from then on every scan uses the store. |
| M5 | The store is newer than this hub reads (notification) | Store schema v{N} is newer than this hub reads (v{M}) — reading the catalog. Update MBXHub. | Update MBXHub. |
| M6 | The store was made by an older truedat | Pre-v4 store found; not read by this hub. truedat migrates it on its next open. | Run truedat; it migrates the store, or refuses without changes when free space is short. |
| M7 | The store could not be opened (notification) | Store at {path} unreadable ({reason}) — reading the catalog. A store on a network share is not supported; move it local. | Read the reason; keep the store on a local drive. |
| M9 | TrueDat is scanning, and the catalog it is writing is behind the store (expected; no notification) | TrueDat is updating your mood data. | Nothing. The scan writes the catalog as it goes; when it finishes, the hub reloads it. |
| M9 | The catalog is behind the store and no scan is running (notification, once per catalog/store pair) | TrueDat’s last scan didn’t finish. Run TrueDat again to finish updating your mood data. | Run truedat. It skips the tracks it already has and writes the catalog when it finishes. |
| — | TrueDat’s last run failed; its reason is in the hub’s log and in store.result on the status endpoint | TrueDat’s last scan stopped. Details are in the MBXHub log. | Read the reason in the log. |
| M14 | No edge file beside the catalog, shown once no TrueDat run is in progress | Transitions need TrueDat’s start-and-end-of-track data. To add it, run truedat --edges --refresh (rescans your library). With no store yet: … run truedat --store-import, then truedat --edges --refresh (rescans your library). | Edge data needs the store and the output-x64.5 extractor that ships with truedat. With a store: truedat --edges --refresh. With only a catalog: truedat --store-import first, then that command; without a store it selects nothing. Either way it re-analyses every track that has no edge data yet, a full analysis per track: hours to days on a large library. --budget caps one session, and the next run continues where it stopped. truedat --edges-export only re-exports edge data the store already holds; with none it writes a file that covers no tracks. |
An edge file from a different export than the catalog reads mismatch on GET /autoq/edges/coverage, and edge transitions stay off until the next export. Messages truedat writes into its own store-result.json are shown exactly as written, in result.messages; which of them also appear as a notification is decided by message id (M6 and M13), and the others stay on the status endpoint and in the log.
Simulate keyboard and mouse input to wake or control the host PC. Useful for remote wake scenarios. Full ARiA Documentation →
ariaEnabled: false). Actions return 403 ARIA_DISABLED when disabled. /aria/status, /aria/presets and /aria/programs remain readable under their normal access rules and report enabled:false.
API-key callers additionally need an explicit Execute grant for actions, including GET aliases for wake, scan and presets. Read-write alone is refused with 403 EXECUTE_REQUIRED. Status, preset-list and program-list reads remain Read.
Check if ARiA input simulation is enabled
Quick wake: move mouse + send Shift key to wake sleeping/locked PC
Trigger a MusicBee library scan (built-in, like Wake PC). Sends Insert to open the scan-folders dialog, then a focus-skipped Enter to confirm it. Assumes Insert is bound to scan-folders (MusicBee default).
Send keyboard input. Body: {"keys": "^a"} (Ctrl+A). Optional: {"keys": "%{F4}", "window": "Notepad"} to focus window first. Prefix keys with ! to send to the current foreground window without refocusing MusicBee.
SendKeys format: ^=Ctrl, %=Alt, +=Shift. Special keys: {ENTER}, {TAB}, {ESC}, {F1}-{F12}, {UP}, {DOWN}, etc.
DuckyScript format: CTRL ALT V, SHIFT F1, ALT TAB. Modifiers: CTRL, ALT, SHIFT. Special: WIN/GUI (opens Start Menu, standalone only - not a modifier).
Focus a window by title. Body: {"window": "Notepad"} (partial match, case-insensitive)
Move: {"x":100,"y":100} (absolute) or {"dx":10,"dy":0} (relative)
Click: {"button":"left"} or {"x":500,"y":300,"button":"right"}
List available presets
Execute a preset by name (e.g., /aria/preset/RIA3)
List allowed programs for the run() command. Returns names only (paths not exposed).
%APPDATA%\MusicBee\mbxhub.json to add/modify presets:
"ariaPresets": [
{"name": "RIA1", "script": "sndkeys(^%a)", "icon": "1"},
{"name": "DuckyDemo", "script": "sndkeys(CTRL ALT V)", "icon": "D"},
{"name": "Notify", "script": "toast(MBXHub,Hello World!)", "icon": "N"}
]
Script commands:sndkeys(keys) - SendKeys or DuckyScript: sndkeys(^a) or sndkeys(CTRL A). Prefix ! to skip refocus: sndkeys(!{ENTER})delay(ms) - Wait milliseconds (max 30000)click(x,y[,button]) - Mouse click: click(100,200) or click(100,200,right)volume(action) - Volume control: up, down, mute, or steps like +5/-3run(name[,extraArgs]) - Launch a pre-configured program: run(amp-on) or run(visualizer,--fullscreen). Programs must be defined in ariaAllowedPrograms in mbxhub.jsonwebhook(url[,method,body]) - HTTP request: webhook(http://example.com) or webhook(!http://...,POST,{}) (prefix ! for fire-and-forget)toast(msg) or toast(title,msg) - Show notificationrestart(target) - Restart: mb (MusicBee), system, or shutdownsndkeys(^a);delay(100);sndkeys(^c)
run() command only launches programs defined in ariaAllowedPrograms. Empty by default.
"ariaAllowedPrograms": [
{"name": "truedat", "path": "C:\\Program Files (x86)\\MusicBee\\Plugins\\truedat\\truedat.exe"},
{"name": "amp-on", "path": "C:\\Tools\\amp-control.exe", "args": "--power on", "hidden": true},
{"name": "visualizer", "path": "C:\\Program Files\\ProjectM\\projectm.exe"}
]
Each program has: name (used in scripts), path (executable), args (default arguments, optional), hidden (no console window, optional).
Extra arguments can be appended: run(visualizer,--fullscreen).
Publish MusicBee as a Windows RemoteApp, allowing the full desktop UI to be accessed from other machines via RDP. Requires Windows Pro, Enterprise, or Server — Home edition is not supported.
Check RemoteApp status. Always accessible.
Response:
{
"configured": false,
"supported": true,
"rdpEnabled": true,
"edition": "Professional",
"enabled": false,
"apiDisabled": false,
"message": "RemoteApp not configured. Run 'MBXHub.exe remoteapp setup' to configure."
}
Download a .rdp file for connecting to MusicBee as a RemoteApp. Blocked when remoteAppApiDisabled is true.
Query parameters:
hostname - Override the hostname in the .rdp file (defaults to request Host header)audioqualitymode=0, redirectprinters=1)Response: application/x-rdp file download (MusicBee.rdp)
403 when remoteAppApiDisabled is true.
Visibility: Dashboard footer link requires remoteAppEnabled and remoteAppApiDisabled: false. Footer links are configurable via dashboardFooterLinks in settings.
App program: On Windows Client (Pro/Enterprise), the .rdp file uses the full executable path from the registry.
On Windows Server, it uses the ||AppName alias for RDS published app lookup.
MBXHub.exe remoteapp setup --path "C:\MusicBee\MusicBee.exe" (requires elevation)MBXHub.exe remoteapp setup --detect (auto-detect MusicBee)MBXHub.exe remoteapp remove (remove configuration)MBXHub.exe remoteapp status (check current state)
Install-WindowsFeature RDS-Connection-Broker, RDS-Web-Access, RDS-RD-Server -IncludeManagementTools
Generic HTTP proxy for controlling LAN devices (speakers, receivers, home automation) from the browser.
Browsers enforce CORS on all cross-origin requests, and LAN devices don’t serve CORS headers —
direct fetch() from the dashboard to a device IP will silently fail. The proxy solves this by
forwarding requests server-side.
Forward an HTTP or HTTPS request to a LAN device. Only private IPs are allowed (RFC 1918).
Request body:
{
"method": "GET",
"url": "http://192.168.10.100/ipcontrol/v1/systems/current/sources",
"body": {}
}
method — HTTP method to use: GET, POST, or PUTurl — Full URL of the target device endpoint. Scheme must be http or https, and the host must be a private IP address.body — Optional JSON body to forward with POST/PUT requests.HTTPS targets: some LAN gear only exposes its control API over HTTPS with a
self-signed certificate — WiiM streamers serve httpapi.asp on port 443 that way.
Those targets work: https://192.168.10.100/httpapi.asp?command=getPlayerStatus is forwarded
like any other. The certificate is not validated for proxied requests — not the
chain, not the hostname, not the expiry. The connection is encrypted but unauthenticated, so anything
on the LAN path could impersonate the device. That is a deliberate trade: without it, self-signed
devices are simply unreachable. The relaxation applies to the proxied request only — every other
outbound connection MusicBee and MBXHub make still validates certificates normally.
Response: The target device’s response is passed through verbatim (status code and body).
Errors:
http/https502 BAD_GATEWAY
“Target device error: no complete reply within 10 s”.
https targets clear exactly the same address checks as http ones —
allowing the scheme does not make any additional host reachable — but their
certificates are not checked (see above).
POST /api/proxy → MBXHub → LAN deviceThe proxy can be disabled in Settings → API → Feature Toggles (apiDisableProxy).
When disabled, POST /api/proxy returns 404 and charm webapps that depend on it will not be able to reach LAN devices.
During an active party, the proxy is a Player-class action: guests without player permission are denied.
Serves audio files from the MusicBee library over HTTP with Range support.
Enables “Listen Here” mode in the player — the browser plays audio locally
via <audio> while MusicBee acts as the library manager.
Stream an audio or video file. The path must be URL-encoded and must be a file in the MusicBee library.
Path parameter: URL-encoded absolute file path (e.g. /stream/C%3A%5CMusic%5Csong.mp3).
Range support: Send Range: bytes=N-M header for partial content (required for seeking).
The server responds with 206 Partial Content and Content-Range header.
Supported formats: mp3, flac, m4a, mp4, ogg, oga, wav, opus, aac, wma, aiff, aif.
Actual browser playback depends on codec support — FLAC works in Firefox/Chrome/Edge,
WMA is not supported by any browser. The play page’s Listen Here mode probes
canPlayType, and for a format the browser reports it cannot decode it asks for
the converted stream below rather than refusing outright; when no conversion is available it
names the format on the output chip.
Query parameter — ?compat=1: a statement that the caller
cannot play the original, not a request for a particular format — the server chooses what
to convert to. When format conversion is enabled and ffmpeg is available, the file is converted
and the result served with Content-Length and range support, so seeking and CUE
offsets still work; the conversion is cached, so a file converts once. Otherwise the request is
refused and the caller falls back to reporting the format unplayable. Without the parameter,
behavior is unchanged. Requires ffmpeg — see docs/ffmpeg-and-transcoding.md.
DELETE /system/transcode-cache: drops every cached
conversion and returns { cleared: <count> }. The cache is derived
data that rebuilds on demand, so this is a rebuild trigger rather than destruction —
your library files are never involved. Two uses: evicting a conversion you suspect, which
previously meant finding the directory by hand; and forcing a cold conversion so
a test actually exercises ffmpeg, since a conversion check passes against a warm cache
even with ffmpeg uninstalled. Answers 200 even when conversion is switched off — the
cache can still hold files from when it was on, and refusing to tidy up because the
feature is off would be perverse. A file being streamed right now is left alone and
counted as not removed, rather than failing the sweep.
One page holds the audio — POST /listen-here/claim and
GET /listen-here/holder: every page that can play audio in the browser
has its own audio element, and two pages in two browsers cannot see each other. Without a
referee, one page takes the music, a second takes it again, and you are left with sound from
a page you are not looking at while the controls in front of you reach a different one. The
hub is the referee. A page that takes the audio posts { "holder": "<id>", "page": "<path>" }
— holder is an id the page makes up for itself, 1–64 characters of letters,
digits, dash or underscore (anything else is 400 INVALID_HOLDER, because the id is
echoed to every client). The answer is { holder, page, changed, previous }. When the holder
changes, the hub broadcasts the ListenHereClaimed WebSocket event and every
other page holding the audio lets go: it stops its stream and returns to speaker output
without resuming or seeking MusicBee, which the new holder has just taken over.
A page re-claiming what it already holds is not a change and broadcasts nothing, so claiming on
every track is free. A live hold is exclusive. While the holder is still there,
a claim from a DIFFERENT page is refused — nothing changes, and the answer names who
has it. The audio is held until it is released, yielded, or abandoned, and nothing else takes it.
A claim from the SAME holder is never refused, so a reload or a fresh activation of a page that
already has the audio keeps it. Abandoned means no proof of life for 30 seconds:
a claim and an accepted check-in both count, so a page that is sitting there with nothing playing
still holds it. That window is the only exit that is not a deliberate act — it is what stops a
crashed tab, a sleeping phone or a dropped wifi holding the audio for good. A page that closes
cleanly does not wait for it, because it posts a release on the way out. The window is listenHereAbandonAfterSeconds (advanced; default 30, range 10–600, clamped), read on every claim so a change applies to the next one without a restart. Too short and a phone that sleeps loses the audio while still in use; too long and a crashed tab keeps every other client locked out. GET /listen-here/holder answers { holder, page, since, url, positionMs, playing, ageMs }
(all absent when nobody has claimed) for a page that may have missed the event — a suspended
mobile socket, or the HUD, which has no socket at all and asks every few seconds while it holds
the audio. The holder is kept in memory only: a hub restart forgets it and nothing breaks.
The client id — mbxh_client: an id a browser client writes
for itself into a cookie, and the browser then sends on every request. Shape is
c- followed by 8–32 of [a-z0-9]; anything else reads as absent.
It does two jobs. It is the stable part of the Listen Here holder id, so a page that
reloads is still the same holder rather than a stranger asking for audio it already has —
which matters now that a live hold is exclusive. And it lets per-client activity be grouped by
client rather than by address, which conflates everything behind one NAT and changes with
DHCP. It is not a credential and nothing is gated on it: it is per browser
profile rather than per device, it is self-asserted, and clearing site data mints a new
one. A caller that never loads a page — curl, a partner integration — simply has none
and is identified by address exactly as before.
All three routes change nothing in MusicBee and write nothing to disk, so they are classified with
the reads, the same as /stream — whoever may play audio here may say that they are.
The hand-off — where the other page had got to: a claim may also say where
the claiming page is, by adding "url" (the library path it is streaming),
"positionMs" (file-absolute milliseconds) and "playing". When the holder
changes and another page had the audio, the answer’s previous is
{ holder, page, url, positionMs, playing, ageMs }: where that page was, captured under the
same lock that replaced it, so the page taking over can pick the track up there in the one round
trip it already makes — instead of restarting it, or asking a room that has been sitting paused the
whole time. ageMs is how old that report was when the claim was served; add it to
positionMs if playing. Act on it promptly or not at all: an answer that arrives
seconds late describes the moment of the press, and seeking a listener who has been playing since then
is a jump, not a correction (the built-in pages ignore it after 2.5 s). A report needs both a
url and a usable positionMs — a finite number, 0 or more. One without the
other is dropped whole: it is never stored as 0:00, which would restart the track for the next page to
take over, and it is never a reason to refuse the claim.
The check-in — POST /listen-here/progress: the holder says
“I am here” with { "holder": "<id>", "url": "<library path>", "positionMs": 61000, "playing": true }
and is answered { accepted, holder, page }. It is recorded only when the caller still
holds the audio, and it can never take it: a page with no socket learns it lost the audio a few seconds
late, and a check-in that could claim would steal the audio straight back from whoever just took it. A
caller that is not the holder is told who is, so this doubles as the holder check — a page that is
sounding sends it every few seconds in place of GET /listen-here/holder.
accepted: false alone does not mean the audio was lost (a report with no usable position is
not recorded either): let go only when holder names someone else.
Ownership and observation: claims may include an opaque activation
ID (letters, digits, underscore or hyphen; 1–64 characters). A new browser-output activation
receives an ownershipToken in its direct claim response; repeated claims for that
activation retain it. Modern progress reports carry the token. Public holder reads and events
never disclose it. POST /listen-here/release accepts
{ "holder": "<id>", "ownershipToken": "<token>" } and returns
{ released, reason }. Only the exact current pair releases ownership; a stale or
duplicate pair returns released: false, reason: "not-current".
Malformed bodies/IDs return 400. This uses the existing playback-access gate and neither ends a
transfer nor changes MusicBee or settings. Page-exit release is best effort.
Optional observation and sink (browser or cast)
in stream URLs and accepted claim/progress reports correlate a logical media load with its
validated original file. readiness and lanBase are controller reports,
not proof of reachability. The observation ID is not authorization. It survives sink readdress
and ranges, changes for a new logical load, and does not alter the server transcode cache key.
Local console: MusicBee’s Client Management window shows current/recent file transfers and reported playback separately. End transfer targets a live operation ID; it may leave buffered sound or a later receiver request. Conflicts remain ambiguous; stale reports do not prove orphanhood. A recorded release/displacement of an exact ownership instance can label an associated cast orphaned, but never cuts it automatically. Management stays in WinForms; no remote administration endpoint is added.
Security:
..) is blockedErrors:
Streaming can be disabled in Settings → API → Feature Toggles (disableStreaming).
When disabled, GET /stream/* returns 404 and the Listen Here button is hidden in the player.
While a party is active, streaming is additionally blocked by default (same 404) unless
partyAllowStreaming is enabled — see the PartyMode section.
Plays MusicBee’s queue on a Google Cast receiver on this network — a Chromecast, a Cast-enabled speaker or amplifier.
How casting works. The hub never talks to a speaker: each hub hands casting to its own paired
MBXHub Shell, which holds the one connection to the speaker, so a page or HUD pointed at another hub shows and
controls that hub’s cast, run by that hub’s Shell. A start runs in four phases, the ones
phase reports: Preparing (reading MusicBee’s queue), Converting (the hub converting the first
track, only when it goes out converted), Connecting (reaching and inspecting the speaker, then launching
after admission and telling the hub which speaker to admit) and Sending (loading the queue).
Conversion begins only after admission; it can overlap the remaining Connecting work. A track is skipped, and MusicBee’s list is never edited for it, when at the start the hub
proves it cannot convert it, or when during the cast the speaker cannot play it and it is not sent again converted.
The Shell’s side (the receiver scan, the start and its step times, the speaker’s answers, the queue
sync) is logged in mbxhub-shell.log, in %LOCALAPPDATA%\MBXHub on the Shell’s
computer; the hub’s side (the /cast answers, conversions, the speaker’s fetches) is in
mbxhub.log. For the architecture, playback states, queue ownership and recovery guidance, see the Cast engine reference.
Native queue integration. The controller reads complete physical identity with
GET /queue?view=play&identity=complete&limit=80. Further windows carry its
expectedObservation; window=physical uses physical offset,
never from. These reads use normal queue access; stale observations return
409 QUEUE_CHANGED and unresolved source identity is not an empty queue.
POST /queue/projection is reserved for local native callers or the SAC-authenticated
paired Shell; browser Origin and charm-ticket callers are refused. Guarded append/remove/move and
source resolution require expectedObservation. operation: resolve requests
a no-write barrier and fresh identity, invalidating late prepared writes before a new cast baseline.
Unknown edits are never resent. Definitive CAST_MODE_CHANGING, CAST_DEDICATED
and CAST_QUEUE_CHANGED refusals mean no edit started; recovery failure retains uncertainty.
The same private route accepts operation: stopPlacement with one url,
song-relative positionMs, cueTrack, cueStartMs and durationMs.
This permits the controller's post-stop source placement in Dedicated mode while retaining player,
read-only and party checks. Loading may settle for two seconds. Success confirms the same source/segment
paused at the returned positionMs (250 ms seek tolerance); otherwise it returns
503 NATIVE_PLACEMENT_UNCONFIRMED. Cleanup requests Stop only while the native source still matches.
A replacement source is left alone; a competing pending CUE seek refuses placement before any write.
If the cleanup Stop is itself refused, fails or stays busy, MusicBee keeps whatever state it reached
and the hub logs an error. The URL API cannot identify an exact repeated occurrence.
Companion playback uses a process-local bridge and signed Shell-only /meta/cast/native-play
and /meta/cast/native-transport; no public /cast/* aliases exist.
Signed POST /meta/cast/native-observe takes the expected sessionId and
itemId, returning exact native-row evidence without playback or queue writes.
A native play, attachment or transport verb that cannot get the controller’s queue gate within 8 seconds
answers as timed out and sends the speaker nothing.
Only the process-local companion bridge consumes this proof; it is not a public native-selection API.
Converting for a cast. Converting needs ffmpeg and is off by default: turn on Transcoding
in MBXHub’s settings page (transcode.enabled). MBXHub finds ffmpeg by itself, on the PATH or
beside truedat, so a machine running truedat already has it (truedat);
otherwise set transcode.ffmpegPath. With converting off, nothing is converted ahead and a track the
speaker cannot play cannot be sent again converted, so it is skipped. Converted copies are kept in a
transcode folder in MBXHub’s data folder — %AppData%\MusicBee\MBXHub\transcode\,
or <MusicBee folder>\AppData\MBXHub\transcode\ for a portable MusicBee — up to 2048 MB
by default, oldest removed first (transcode.cacheDir, transcode.cacheLimitMb). A file
converts once and is reused; library files are never changed; DELETE /system/transcode-cache
empties the folder.
Queue authority. MusicBee supplies the starting queue. In Shared mode
(default), the Shell maintains the intended order and MusicBee is its privileged editable mirror.
Native additions follow where they landed; the controller does not guess Next versus Last gestures.
Remote Next/Last requests use /cast/enqueue. One saved policy governs remote Next:
fifo (default), lifo or always-tail; changing it leaves accepted requests in place.
Dedicated makes MusicBee the queue authority and refuses new client queue writes,
including indirect native queue and feeder actions; Cast Control still handles speaker playback.
MusicBee ownership is separate from contribution mode. A verified MusicBee menu or
MBXCast panel start grants musicBeeOwned. The menu starts Dedicated; the panel’s
Let others add tracks toggle chooses Dedicated when Off and Shared when On.
Shared contributions do not surrender native playback, following or accounting authority.
Other client starts remain Shared without native ownership. The Hub derives ownership from its validated
one-shot native start intent (five-minute lifetime), never from a public request body. A refused preflight
does not spend the intent. Native controls read the running ownership flag; contribution gates read its mode.
A mode change preserves the queue and playback, or reports why it cannot apply. Pending changes keep
client admission closed and the panel reports that wait on its own status line. Public clients can read
the effective configuration, but cannot set it.
Connected idle. A MusicBee-owned destination can connect with an empty queue. Normal media exhaustion or a confirmed native queue clear retires media commands and item bindings while retaining the chosen destination and Cast output. Refill alone does not autoplay. Explicit Play captures MusicBee’s current occurrence, including duplicate/CUE identity, and proves a fresh load. If the receiver app expired, Play reinspects occupancy and launches only after admission; a foreign session still requires explicit replacement. There is no idle relaunch loop, silent track or extra keepalive. Stop casting or selecting another output releases destination intent and cancels pending activation. Ordinary network failure retains existing bounded recovery.
Optional PPS source now adds native Automatic connection and an explicitly saved preferred receiver per
MusicBee instance. Startup and initial enable leave it configured but inactive until playback. The panel says
Press Play to connect to the preferred speaker. There are no initial wake or connection retries while idle.
The first Play, including from the initial local output, checks wake permission and speaker state before
activating connection work; playback already running when PPS becomes available also activates it.
Once activated, an unreachable speaker is retried, backing off to once every 60 seconds.
Enabling PPS for the already-connected preferred speaker adopts it without reconnecting; connected idle
keeps that connection and recovery intent. A later output choice wins over pending automatic handoff.
occupied: the speaker is on but holds a session automatic connection will not replace; pick it
in Cast Control to replace it. Probed once a minute. Stop casting or
choosing another speaker yourself pauses automatic connection until you press Play with no cast running,
change the preferred speaker, turn Automatic connection off and on, or MusicBee restarts. Wake device when starting a
Cast (MBXCast settings, PPS group; off by default): when MusicBee starts playing to the preferred
speaker and the speaker is asleep, MBXHub sends Wake-on-LAN to it — and to its stereo partner if one
was seen — then the Cast engine retries every 5 s for 90 s. With Automatic connection on, if you press
Play while the speaker is not connected, MBXCast starts that play once the speaker connects, whether or not
it had to be woken, if MusicBee is still on MBXCast, within 90 seconds, and you have not changed track.
Before any wake, on play or when the connection drops, MBXHub checks the speaker’s
state: for a Devialet it reads the device’s own power state, for any other receiver it tries the
Cast port, and a speaker that is already awake is not sent a packet. Keep awake while
connected (MBXCast settings, PPS group; off by default) also wakes the speaker when the Cast
connection drops; without it, a speaker that goes to standby on its own stays asleep until you play. Both
toggles are disabled while Automatic connection is off or Device Wake is turned off in MBXHub settings.
Stop casting, or choosing another speaker yourself, overrides both toggles until you press Play with no cast
running, change the preferred speaker, turn Automatic connection off and on, or MusicBee restarts. Learned while the speaker is
connected (its address, MAC and, for Devialet, its pair), kept in device-wake.json. MBXHub
learns the speaker’s address only while one of the two toggles is on and Device Wake is not turned
off. A speaker is woken only after MBXHub has seen it connected while one of the two toggles was on. The
machine running MBXHub finds the speaker’s address and sends the wake, so it must be on the
speaker’s subnet, even when the Shell runs on another machine. Settings:
cast.preferredSpeakerWake and cast.preferredSpeakerKeepAwake (both set from
MBXCast). disableDeviceWake (MBXHub settings > API Access tab > API Access Control >
Disable Features > Device Wake; console only, never on the web settings page) turns it off for
everything.
Idle capability: connectedIdleVersion: 1 identifies the optional
destinationId and idlePlaybackAvailable fields. Destination identity survives media
end; sessionId is the command lifetime and rotates when media ends. Idle capability permits only
first Play without a current item. The private bridge’s playbackAvailable still requires
current-media proof for attach/follow/seek/pause. Old or malformed capability never grants idle selection.
Its optional release control uses expectedDestination on signed
/meta/cast/stop; a different destination is refused. Cast Control continues to send
expectedSession. A transient null renderer callback is not proof of another output selection.
The receiver fetches each track from the hub’s LAN address
(/stream, the same URL Listen Here uses), so remote connections must be on.
The paired Shell does the casting: /cast/* on the hub forwards to the
MBXHub Shell (MBXHub.exe) this hub is paired with, and the Shell holds the receiver
session, so a cast survives a MusicBee restart. Starting a cast pauses MusicBee and requests the Listen Here hold;
an existing browser holder keeps it. If local MusicBee playback is resumed, both outputs advance independently.
MusicBee’s native Now Playing describes local playback; Cast Control describes the speaker.
The Shell serves its side (/meta/cast/*) on its SMTC listener,
so casting works only while that listener is enabled in the Shell.
Cast Control display: the shared control updates visible progress locally once per second,
corrects from /cast/status every ten seconds while its panel is visible, and applies
CastStatus events as they arrive. Pause, buffering, idle, start phases and unavailable status do not
advance the local clock. Status updates, confirmed seeks and track changes reset its position; it never advances
past the known duration. Browser and Shell visibility suspend timers and trigger a fresh read on return.
These are display updates, not per-second receiver requests or audio synchronization. The speaker-selection
instruction is shown only for an inactive cast.
Expand Speaker queue to see confirmed receiver occurrences and the current row. Entries awaiting receiver identity are counted separately. Select a row to play that occurrence without adding a copy. Expansion, scroll and focus survive updates; unavailable status disables selection.
In MusicBee, use the cast icon on the MBXCast panel (the MBXCast companion plugin) or Tools > MBXHub > Cast Control. Both open the existing control for that MusicBee instance, even when the HUD targets another hub, and both respect the Cast setting. The AutoQ strip no longer carries Cast controls; web clients are unchanged. If a compatible Shell popup is unavailable, the local Cast page opens in your browser. An uncertain opening result asks you to retry the same window instead of opening a second control.
Native MusicBee volume/mute: with MBXCast's acknowledged output selected in a
MusicBee-owned session, including connected idle, the native controls first inherit fresh receiver
values and confirm host readback. A local/default level, including 100%, is never sent on connection.
Only later adjustments use the existing /cast/volume and /cast/mute routes
with expectedSession; no endpoint or request field changes are introduced.
Unknown values, failed reads/readback, output changes or reselection, lost ownership, reconnects
and receiver replacement reset the link and discard unsent changes. Programmatic echoes are suppressed.
Returning local preserves a new user adjustment; otherwise it uses the lower saved/managed level
and retains either mute state, without automatically raising volume or unmuting.
The panel is one row of Cast, speaker/status and borderless settings; Cast Control retains volume/mute.
Matching updated components are required; live callback and volume behavior remain unverified.
Compact HUD: the Cast button is available before or during a cast without unrolling the strip. Shell opens its Cast window when overlay windows are enabled; browser hosts open the same anchored control. Mixer content keeps clear of the bottom transport controls.
Cast queue reads reject incomplete native lists and changes detected during metadata collection instead of interpreting them as removals. The existing play-order endpoint returns 409 QUEUE_CHANGED for a fresh read; page limits do not truncate the captured membership.
Client Management: Cast playback is shown separately from request activity, using
trusted controller reports and preserving saved speaker names. The Shell refreshes active status about
every ten seconds, including while paused. A report thirty seconds old shows Unknown (stale).
This measures controller contact, not a fresh receiver probe; sender updatedUtc is not receipt time.
Implementation limits: Play Now is recorded in the controller's command ledger, so a
retry with the same session and request IDs never plays twice; it keeps its receiver interlude anchor by design and is not part of the
native mirror. MusicBee's current-row following
needs the MBXCast companion with its output selected in a MusicBee-owned cast. With the MBXCast plugin installed, automatic
output handoff on entering an active MusicBee-owned cast (cast.autoOutputHandoff, default on) and restoration of
the previous output on leaving it, only while the MBXCast output is still active, are supported. Play-count ownership during native following is an explicit switch,
cast.dedicatedAccounting (default cast, unchanged counting; see /player/report-play).
Receiver pause-state following, synchronized local audio and automatic attachment to another computer's
controller are not implemented.
Matching Hub and Shell versions are required.
Cast startup. The Shell reads GET /queue?view=play&identity=complete&limit=80 (see Queue).
It captures every native source row and the full upcoming order while hydrating a bounded metadata window.
If every track in that window fails preparation, later windows use the same observation guard and startup budget.
A changed observation refuses the start; uncertain preparation preserves the occurrence and uses the existing playback attempt.
Receiver bindings are registered for the actual dispatched LOAD batch, leaving the unsent tail pending.
A missing or unsupported complete-observation contract is refused with “The hub does not support complete Cast queue observations; update the hub.”
Incomplete observations never mean an empty replacement queue.
Reconciliation. The Shell feeds a bounded receiver window from its occurrence ledger. Shared changes project into MusicBee through guarded append, move and remove operations; exact readback suppresses echoes. Current/history rows and the local playback cursor are protected. Virtual CUE appends cannot be represented by MusicBee's file-only append API; the Cast request is retained with a mirror explanation. An uncertain native write is not resent, even across Stop/new Start, until readback resolves it. An ordinary receiver insertion with a lost reply likewise waits for confirmed membership before a retry. Read failures preserve the intended queue and report uncertainty; they do not prove playback stopped.
Every answer is the usual {success, data} or {success:false, error:{code, message}}.
data for status and successful verbs is the cast’s state; enqueue also includes a receipt:
{active, musicBeeOwned, connectedIdleVersion, destinationId, idlePlaybackAvailable, sessionId, queueMode, remoteQueuePolicy, queueModeChanging, queueConfiguration:{token, requestId, outcome, errorCode, error}, receiver:{id, name, address}, playerState, currentItem:{url, title, artist, album, cueStartMs, durationMs, itemId, streamUrl, mbIndex}, positionMs, durationMs, canSeek, items:[{url, title, artist, cueStartMs, itemId, streamUrl, mbIndex}], volume, muted, lastError, phase, skipped, updatedUtc} (phase and skipped: see GET /cast/status). volume (0 to 1) and muted are the speaker’s own volume as the speaker reports it, including changes made on the speaker or by another app. streamUrl is the URL the receiver fetches for that item. Treat it as opaque: a per-occurrence mbxOccurrence query marker distinguishes duplicate insertions, and conversion retries retain that marker. Source identity remains url plus cueStartMs; selection uses the session and receiver item ID. mbIndex is the MusicBee list index of the queue row the item was read from (a GET /queue row’s index); the key is absent when that row is unknown.
Formats the receiver cannot play. While /system/features reports transcode: true,
an item the hub converts (by extension: .wma, .aiff, .ogg, .opus and the others ?compat=1 covers)
is sent converted in advance: its streamUrl carries ?compat=1 and its content type is the
transcodeFormat’s (a format the Shell has no content type for is not sent converted, and the Shell logs a
warning once per cast). Each trusted status push from the Shell starts the conversion of the current item and
the next two rows, when they need conversion, current first, so the receiver’s fetch usually finds it ready; a push
that arrives while a conversion runs waits for it, and a file that fails to convert is not tried again while it stays among
those rows. An item the receiver refuses
(IDLE with idleReason: ERROR) that the hub would convert and that went out unconverted is sent again
converted, once, in the same place in the list, and played; the refused item is removed. When no retry is possible,
lastError names the track (“The speaker couldn't play <title>.”, adding
“Turn on conversion in MBXHub settings to play this format.” for a format the hub converts, while conversion is off),
and the track is skipped: it is listed in skipped (reason: "play"), the speaker moves on to the
track after it in its queue, and the next track to start playing clears lastError. A refused track with nothing
after it on the speaker ends the cast, and lastError then reads “The speaker couldn't play <title>.
Nothing is left to play.” A run of tracks the speaker cannot play is skipped one refusal at a time.
CUE tracks (cueStartMs) start at their own start and end at their own end, not the file's: when one has
played its durationMs, the speaker moves on to the next item in its queue, or playback ends when it is the
last, and the track counts as played to its end. The next track of the same file loads again at its start, so a short
gap between two CUE tracks of one file is expected.
The Cast receivers on this network: {receivers:[{id, name, address, model, castBuild}], scannedAt}.
The list is kept for 60 seconds; ?rescan=1 scans again (about 4 seconds).
The local controller's state, not arbitrary sessions on discovered speakers.
active can be true while a start is pending; it is not proof of loaded playback.
queueMode is shared or dedicated; remoteQueuePolicy
is fifo, lifo or always-tail. queueModeChanging and
queueConfiguration report native configuration progress; they grant no write authority.
phase is what a start is doing now: Preparing, Converting,
Connecting or Sending, with steps that run at the same time joined with “, ” in
that order ("Converting, Connecting"); null once the cast has started and whenever no start
is running. Each change of phase goes out as a CastStatus event. skipped lists the tracks
this cast passed over, oldest first, as [{url, title, reason}]: reason is
convert (the hub could not convert it at the start) or play (the speaker could not play
it); [] when there are none, and emptied when the next start begins.
Cast MusicBee’s queue, from the current track on, to one receiver. Body:
{"receiverId": "<id from /cast/receivers>"}. Answers the cast’s state once the
receiver has loaded the queue. An already loaded session owned by this controller is returned without reloading;
a pending start or another active receiver returns 409 CAST_ACTIVE.
The first song resumes at MusicBee’s current position. For CUE songs the receiver gets the file offset plus that track-relative position, with the song’s own playback duration. A changed source or unreadable position fails the start instead of silently starting at zero. If conversion skips the current song, the next playable song starts at its own beginning.
The guarded /queue?view=play&identity=complete&limit=1 read supplies
currentPositionMs from the same native snapshot as the source identity. A track change
during capture or metadata hydration returns 409 QUEUE_CHANGED.
Reconnect: after a dropped channel, select the same receiver within
two minutes to resume its retained controller queue. The Shell verifies the application, transport and
media-session identities before granting controls. It sends no launch or queue load and does not read
or pause MusicBee. A mismatch refuses with 409 CAST_RESUME_CHANGED or the occupied-session
confirmation below; it never replaces playback automatically. Expiry, a different receiver, changed
queue configuration or explicit replacement discards the retained model. This memory does not survive Shell exit.
resumeUntilUtc on an inactive status names that original deadline.
A trusted Shell report keeps only the already-admitted speaker's existing audio/artwork allowance,
capped at two minutes by the hub. It cannot admit another address or extend an earlier deadline;
normal trust, route and ban checks still apply. A transient reconnect failure preserves the original
queue and deadline for another attempt. Confirmed activity removes the temporary access deadline.
Occupied receiver: before conversion, launch or loading, the Shell
makes a read-only observation. An occupied or paused receiver returns 409 CAST_TAKEOVER_REQUIRED
with error.takeover:{receiverId, confirmation, expiresUtc, canJoin, applications:[{appId, displayName}]}.
Present Cancel and Replace session, plus Join existing queue
when canJoin is true. Keep Cancel as the keyboard default; never confirm automatically.
Explicit replacement repeats start with the same receiver ID and takeoverConfirmation
copied from the offer. The opaque value expires after 60 seconds, is single-use, and is bound to the
receiver, observed session and authenticated Hub/Shell pairing. Permissions and session identity are checked again.
Expired/replayed confirmation returns 409 CAST_TAKEOVER_INVALID; a changed session returns
409 CAST_TAKEOVER_CHANGED. Select again for a new decision. Malformed confirmation is
400 BAD_REQUEST; failed observation is 502 CAST_INSPECTION_FAILED and leaves the receiver alone.
Policy denial is 403 CAST_TAKEOVER_DENIED. No public force or policy switch exists.
Replacement interrupts the existing session and loads this hub's queue. To control another MBXHub controller's authored queue, point the page at that hub. Join controls the receiver queue without discovering its source hub. The fresh observation cannot reserve the speaker against independent senders. These protections require the updated Shell; an older Shell cannot enforce them.
Converting first. MusicBee’s queue is read first. An empty queue
can connect a MusicBee-owned destination without loading media. A nonnative empty start still fails
(503 QUEUE_EMPTY) before launch/load; occupancy is checked so an existing live queue can offer Join. When the first track goes out converted
(?compat=1, see Formats above), the hub converts it after receiver admission/launch, overlapping the hub announcement, at most 30 seconds
in all for the tracks the start converts; past that the track is loaded as it is, and the speaker’s own fetch
waits on the conversion. A track the hub proves it cannot convert (its /stream answers
503 TRANSCODE_FAILED) is skipped (skipped, reason: "convert") and the next
one is tried, until one converts or needs no conversion; any other outcome, such as no answer or a refusal, loads the
track as it is. Skipped tracks stay in MusicBee’s list. When none converts, the start fails with
500 CAST_FAILED “No track at the start of MusicBee's queue could be converted (N tried).”
During a party with partyAllowStreaming off the hub answers /stream only to the speaker and
to the paired Shell, whose request carries a Secure Access Channel signature the hub checks, so converting ahead works
during a party; a guest still cannot stream, and a Shell that is not paired is refused like a guest (every track then
loads as it is). With disableStreaming on, nothing is converted ahead, for the Shell included.
Time limit. The whole start has one 60-second ceiling in the Shell, counted from
when the Shell receives it, the receiver scan included; past it the start fails with 504 CAST_TIMEOUT
“the start did not finish within 60 s while <phase>” and is torn down. The hub waits 62 seconds for
the Shell’s answer, after a separate status/configuration pre-read of up to 10 seconds.
Cast Control waits 74 seconds for both steps and their response. A failed pre-read preserves
the Shell’s error and sends no start command.
The step breakdown of a failed start. A start that fails once it has begun (not a
refusal such as 409 CAST_ACTIVE or 404 RECEIVER_UNKNOWN) ends its
error.message with the step breakdown, whatever the code, 409 CAST_ENDED for a stop that
landed during the start included; the same text ends the Shell’s log line and, unless a stop ended the start,
lastError. The breakdown names the receiver scan (scan) and then each step the start
reached, in order, with its measured time; a step marked still running is where the start stalled:
(scan 12 ms, Preparing 40 ms, Connecting 15020 ms still running). Converting can overlap the remaining Connecting work, so their times can overlap.
Body {"receiverId":"..."}, with optional takeoverConfirmation
from the occupied-speaker offer. Attach to live Default Media Receiver playback without LAUNCH or QUEUE_LOAD.
The receipt binds the application and media session; expired/changed receipts require another decision.
409 CAST_JOIN_UNAVAILABLE means no matching live session could be confirmed.
Status includes joined:true, receiver queue metadata and currentItem.artworkUrl.
Transport, existing-row Jump, Stop and Leave work. Enqueue, Play Now and queue configuration refuse
with 409 CAST_JOINED_QUEUE. The joining MusicBee is not paused, mirrored or repositioned;
no Listen Here hold, play accounting or new media allowance is taken. Add tracks through the original hub.
Join uses the start ceiling and signed transport but does not apply this machine’s queue configuration.
Body: {"expectedSession":"..."}, the sessionId of the cast
the caller is looking at (from status). A destination-aware caller can instead supply
{"expectedDestination":"<32 lowercase hex characters>"} from status. At least one
identity is required; if both are supplied both must match. A stale page cannot end a different destination.
Without an identity the answer is 400;
when that cast is no longer the current one, 409 CAST_SESSION_CHANGED and nothing is stopped.
Stop media, tear down the owned receiver application, and close both Cast connections.
Pause keeps the session and queue; Seek preserves paused state. Listen Here is released, and MusicBee is left
paused on the source song and position the receiver was playing, including Dedicated mode. Placement verifies
the source/CUE segment, pause and seek readback but cannot prove which repeated URL occurrence MusicBee selected. During a Play Now track, which is not in MusicBee’s list,
MusicBee is left on the next MusicBee track after it (or the one before it when nothing follows), not on
the Play Now track, at zero within that return song.
Leave the Shell’s Cast controller without stopping the receiver or moving MusicBee. Both virtual connections close; the controller queue and reconnect state are discarded. Playback of the receiver’s loaded queue and other senders continue. Shell exit uses Leave. No new AutoQ items are supplied by the departed controller.
Signed mediaAccessUntilUtc retains only this speaker’s existing audio/artwork access
for the known remaining queue duration plus 20 minutes, capped at 24 hours by the hub. Repeated reports
cannot renew it or grant another address; an earlier reconnect deadline still wins. Unknown-duration songs
add no time to this allowance. The source hub must remain running. Updated hub and Shell versions are
required; long pauses or repeated playback past the deadline may require a new active controller.
Transport on the receiver. previous restarts the track when it is more
than 3 seconds in. Idle /cast/play captures the current MusicBee occurrence and performs
fresh admission/load. Send the displayed expectedSession. If occupancy requires replacement,
repeat Play with that same session and the offered takeoverConfirmation; do not start a second
destination or offer Join for this native queue activation. Stop/Leave remains available while activation waits.
Request IDs: /cast/jump, /cast/enqueue and
/cast/playnow each take the cast's sessionId and a requestId the caller
makes: <admissionSequence>:<unique>, 1–128 characters, where
admissionSequence is the whole number the cast status carried when the action was taken and
<unique> is any value unique to that action (a UUID). Example: status says
"admissionSequence": 14, so the ID is 14:6f1c0a52-.... Keep the same ID to retry the
same action; make a new one, from current status, for a new action. An ID without a leading sequence, or with
one the cast has not reached, is 409 CAST_REQUEST_INVALID (400 on
/cast/playnow, which checks the shape while reading the body). The cast keeps a bounded history of
settled requests; a retry of one old enough to have left it is 409 CAST_REQUEST_EXPIRED and is
not executed again — read status to see what happened. 429 CAST_REQUEST_LIMIT now means too
much work is still unresolved, not that the cast is used up: wait for it to settle and send again.
Select an existing receiver occurrence. Body:
{"sessionId":"...","requestId":"...","itemId":123}.
Use the current status's opaque sessionId and a positive receiver itemId from
items; never use a MusicBee index or title. IDs are scoped to this cast.
Supply a fresh request ID (see Request IDs above) per deliberate selection. The same request and target are
never sent twice; a changed target conflicts. Answers current Cast status, not a claim that playback
has advanced. The speaker's status determines the highlighted row.
400 BAD_REQUEST for invalid fields;
409 CAST_SESSION_CHANGED, CAST_ITEM_CHANGED or CAST_NOT_READY
for stale or uncertain identity; 409 CAST_REQUEST_CONFLICT for request reuse with another target;
429 CAST_REQUEST_LIMIT while too much earlier work is unresolved;
409 CAST_REQUEST_INVALID or CAST_REQUEST_EXPIRED as described under Request IDs.
A retry of an unconfirmed selection returns 502 CAST_SELECTION_UNCONFIRMED; a previously
refused selection returns 409 CAST_SELECTION_REFUSED. Refresh status after a timeout;
the controller reads the speaker back and does not automatically resend. The usual player, read-only,
party and signed-forwarding checks apply.
Admit an ordered batch to the Shared controller queue. Body:
{"sessionId":"...","requestId":"...","position":"next","tracks":[{"url":"<file path>"}]}.
Use the current status session and a fresh request ID (see Request IDs above) per deliberate addition.
position is next or last. The tracks array has 1–10,000 sources,
within the 64 KiB body limit; each uses Play Now's URL/CUE fields. Admission records order before metadata lookup.
The response adds receipt:{requestId, sequence, decision, occurrenceIds, effectivePosition, remotePolicy}
to status. decision is accepted or duplicate; receipt occurrence IDs
belong to the controller and are not receiver item IDs for /cast/jump.
An exact retry retains the same occurrences and applied policy. Reusing the request ID
with different sources, CUE options or placement returns 409 CAST_REQUEST_CONFLICT.
Stale sessions return 409 CAST_SESSION_CHANGED; a connecting controller can return
409 CAST_CONTROLLER_UNAVAILABLE; capacity is 429 CAST_REQUEST_LIMIT.
New requests in Dedicated return 409 CAST_DEDICATED, or 409 CAST_MODE_CHANGING
during a transfer. Exact accepted retries remain readable. A receipt is admission, not receiver playback
confirmation. Unavailable metadata retains the request's position; refresh status after uncertainty.
Interrupt the current speaker track and play one track now (Shared mode only). Body:
{"sessionId":"...","requestId":"...","url": "<file path>", "cueTrack": 2, "cueStartMs": 60000, "cueDurMs": 240000};
sessionId and requestId are both required: a body without both is
400 (“playnow needs both sessionId and requestId from the observed cast”), and a
requestId that does not start with the status's admissionSequence is
400 too. Use the current status's session and a fresh request ID (see Request IDs above)
per deliberate press. Nothing is made up for a caller that sends none: a retry has to be recognizable
as the same press, and only the caller knows that it is.
The status every verb answers carries sessionId and admissionSequence.
cueTrack, cueStartMs and cueDurMs are for a CUE track only. With
cueTrack above 0, cueStartMs is required (400 without it) and
cueDurMs is optional: 0 or absent plays to the file’s end, and a negative or non-integer
value is 400 (“playnow’s cueDurMs is a whole number >= 0”). The track is inserted after the speaker’s current item and started;
MusicBee’s list is not written; the receiver interlude keeps the existing upcoming speaker queue behind it. From then on the track is a member of the speaker’s queue:
next and previous move across it, and it leaves with the played history. Answers
the cast’s state, like every verb. A second Play Now while the first is playing goes after it. A format
the receiver refuses takes the refusal path above (sent converted once, else lastError).
400 without a url; 404 TRACK_NOT_FOUND for a file the hub does not
have; 409 NO_CAST with nothing cast; 409 CAST_NOT_READY while the speaker’s
current item is not known yet or the item after it has no receiver id yet (try again in a moment);
502 TRACK_UNREADABLE when the Shell could not read the track from the hub. Gated like the
other verbs (read-only mode, a party); Dedicated refuses new Play Now writes with 409 CAST_DEDICATED. Play Now on the HUD and the play page calls this while casting.
The response adds receipt:{requestId, sessionId, sequence, decision, acknowledged, itemId}
to status; every other field is unchanged. decision is accepted or
duplicate; itemId is the inserted receiver item and appears only once it is known.
A 200 can carry acknowledged:false: the insert's outcome is not confirmed yet, so
refresh status rather than sending a new request. The request is recorded in the cast's command ledger
before anything is sent to the speaker. A retry with the same IDs answers from the retained receipt and
sends nothing to the speaker; reusing the request ID with a different url, cueTrack,
cueStartMs or cueDurMs returns 409 CAST_REQUEST_CONFLICT. A retry of
a refused request returns 409 CAST_SELECTION_REFUSED; of an unconfirmed one,
502 CAST_SELECTION_UNCONFIRMED. A stale session returns 409 CAST_SESSION_CHANGED.
Every Play Now takes a place in the cast's bounded command ledger, shared with
enqueue, jump and native selections; settled requests leave it as new ones arrive, and
429 CAST_REQUEST_LIMIT is answered only while too much is still unresolved.
409 CAST_NOT_READY is answered before the request is recorded: it uses no
place, and the same IDs may be sent again once the speaker is ready.
Body: {"positionMs": 61000}, 0 or more, within the current track.
Clamped to the receiver-reported duration when known, always within the queued CUE segment.
Status canSeek requires the receiver's Seek command bit and, for Join, confirmed queue-item bounds.
Sparse queue replies retain previously confirmed bounds for the same item; false or unknown refuses with
409 CAST_SEEK_UNAVAILABLE. Seeking preserves playing or paused state.
The receiver’s own volume and mute. Bodies:
{"level": 0.25, "expectedSession": "..."} (0 to 1) and
{"muted": true, "expectedSession": "..."}. expectedSession is the
sessionId of the cast the caller is looking at and is required, as on
/cast/stop: 400 without it, 409 CAST_SESSION_CHANGED when that cast
is no longer the current one. The check is made again immediately before the command is written to the
speaker.
Access: the two reads are open to anyone who may read the hub. The verbs are gated like
/player/*: the player read-only switches and API keys; read-only mode refuses every one with
403 API_READ_ONLY. /cast/* is not available to remote callers during a party.
The speaker: a speaker cannot send a password or an API key, so while the paired Shell
casts to it, the hub admits that speaker’s address for its audio and artwork only
(GET /stream/... and GET /library/file/.../artwork), past the password, API keys and
the access profile. Every other route from that address is gated as usual, a banned address is still
refused, and the admission ends when the cast does. Only the paired Shell can name the speaker: its
CastStatus push counts when it comes from this machine or carries a valid Secure Access Channel
signature.
Errors:
| Status | Code | When |
|---|---|---|
409 | CAST_TAKEOVER_REQUIRED | An occupied speaker needs an explicit replacement decision; see the structured offer on start. Not offered for a session this hub left behind: a start past the receiver's launch that then fails sends no application stop, and the next start recognises its own leftover application and proceeds without asking. |
409 | CAST_TAKEOVER_INVALID | Confirmation expired, was used, or no longer matches its receiver/pairing. Select again. |
409 | CAST_TAKEOVER_CHANGED | The observed session changed. Select again for a new decision. |
403 | CAST_TAKEOVER_DENIED | Controller policy denied replacement. This is not a pairing failure. |
502 | CAST_INSPECTION_FAILED | The speaker state could not be verified. No launch or queue load was sent. |
409 | CAST_SESSION_CHANGED | The request named a cast that is no longer the current one (sessionId, or expectedSession on stop, volume and mute). Nothing was done. Read status and act on the cast it shows. |
409 | CAST_REQUEST_INVALID | The requestId does not begin with an admissionSequence this cast has reached. Read status and make a new ID from it. |
409 | CAST_REQUEST_EXPIRED | A retry of a settled request that has left the cast's bounded history. It was not executed again; read status to see its effect. |
429 | CAST_REQUEST_LIMIT | Too much queue work is still unresolved. Wait for it to settle, then send again. The cast is not used up. |
409 | CAST_START_INTENT_EXPIRED | /cast/start carried a startIntent from MusicBee's Cast picker that has expired, was used, or was replaced by a newer press. Press Cast in MusicBee again. A start with no startIntent is unaffected and starts a Shared cast. |
409 | CAST_DEDICATED | MusicBee owns the queue; new client queue writes are refused. |
409 | CAST_MODE_CHANGING | A native ownership change is unresolved; check MusicBee's Cast controls. |
409 | CAST_MODE_BUSY | A client queue edit is still running; the ownership change was not started. Retry once it settles. |
409 | CAST_OUTPUT_UNAVAILABLE | Answered by POST /autoq/radio/start, not by /cast/*: MusicBee is on the MBXCast output with no usable Dedicated cast, so no playback was requested. Start a Dedicated cast or select a local MusicBee output first. |
503 | CAST_MODES_UNAVAILABLE | The paired Shell cannot apply the selected native mode or policy; update/reconnect it. |
409 | CAST_NATIVE_UNCONFIRMED | A previous native queue write remains unresolved. Fresh Start or idle Play requests the host recovery fence before a new baseline; the old edit is never resent. |
503 | CAST_NATIVE_OWNERSHIP_UNAVAILABLE | A MusicBee Shared start needs a Shell that advertises native ownership. Its start intent is retained; update the matched components. |
503 | CAST_DISABLED | cast.enabled is off. No Shell or receiver is contacted. |
503 | CAST_NO_SHELL | No MBXHub Shell is paired with this hub, it is not running, or its SMTC listener is off. |
504 | CAST_TIMEOUT | Two sources. (1) The hub gave up waiting on the MBXHub Shell: 62 seconds on /cast/start, 15 on /cast/receivers, 10 on every other route. The message says what that means for the call: on /cast/start, “may still have started the cast”; on the other verbs, “the speaker may still have acted”; on a read (GET), only that the Shell did not answer in time. Read /cast/status before trying again. (2) /cast/start only: the Shell’s own 60-second ceiling on the whole start ran out. That start failed and was torn down; the message is “the start did not finish within 60 s while <phase>”, followed by the step breakdown (see POST /cast/start). |
503 | CAST_SHELL_UNPAIRED | The MBXHub Shell refused this hub’s signature: it is not paired with this hub. Pair it from MBXHub settings (Network Status tab) or restart MusicBee to auto-pair. |
404 | RECEIVER_UNKNOWN | No receiver has that id. Rescan and pick again. |
404 | TRACK_NOT_FOUND | /cast/playnow named a file the hub does not have. Nothing was sent to the speaker. |
409 | CAST_ACTIVE | A start is pending or a different receiver is active. Read status; do not stop a pending start simply to obtain a confirmation. |
409 | NO_CAST | A verb was sent with nothing cast. |
409 | CAST_ENDED | A stop arrived while the cast was starting. |
409 | CAST_NOT_READY | /cast/playnow arrived while the speaker’s current item is not known yet, or the item after it has no receiver id yet. Try again in a moment. |
503 | QUEUE_EMPTY | A nonnative start has no queue to load. MusicBee-owned starts support an empty idle destination. |
503 | CAST_NO_LAN_ADDRESS | Remote connections are off, so the receiver has no address to fetch tracks from. |
502 | HUB_UNREACHABLE | The Shell could not reach this hub. |
502 | QUEUE_UNREADABLE | The Shell could not read MusicBee’s queue from the hub. |
502 | TRACK_UNREADABLE | /cast/playnow: the Shell could not read the track from the hub. Nothing was sent to the speaker. |
502 | RECEIVER_REFUSED | The receiver refused. error.reason carries the receiver’s own reason. |
502 | RECEIVER_UNREACHABLE | The receiver did not answer, or the connection to it failed. |
502 | DISCOVERY_FAILED | The scan for receivers failed. |
503 | CAST_UNAVAILABLE | Casting did not start inside the Shell; its log says why. |
400 | BAD_REQUEST | A missing or out-of-range body field. |
413 | REQUEST_TOO_LARGE | A verb’s request body is over 64 KiB (“Request body exceeds 65536 bytes”). The hub refuses it; nothing reaches the Shell. |
408 | REQUEST_TIMEOUT | The request body did not finish arriving in time. The hub closes the connection, so the caller may see it drop instead of this answer. Nothing reaches the Shell. |
400 | INVALID_UTF8 | The request body is not valid UTF-8. Nothing reaches the Shell. |
400 | BODY_READ_FAILED | The request body ended before its declared length, or could not be read completely. Nothing reaches the Shell. |
503 | BODY_BUSY | The hub is holding too much request body data at once. Try again shortly (Retry-After: 1). |
500 | CAST_FAILED | Anything else the Shell’s cast host threw; error.message says what. |
Events: the cast’s state arrives as a SystemEvent with
name: "CastStatus"; its payload is what /cast/status answers as
data. Subscribe to SystemEvent and filter on the name. The plugin listens too:
while a cast plays, MusicBee’s status bar reads MBXHUB: casting to <receiver>,
cleared when the cast ends — without the MBXCast companion’s native following, MusicBee’s
own now-playing cursor does not follow the speaker (the speaker plays from the hub). The cast icon on the MBXCast panel or Tools > MBXHub > Cast Control opens its controls; queue mirroring does not move local playback.
Setting:
| Setting | Default | Description |
|---|---|---|
cast.enabled | true | Let the paired MBXHub Shell cast MusicBee’s queue to a Google Cast receiver on this network. The receiver fetches each track from this hub itself, so this needs a LAN address (remote connections on) and a running Shell. Off means /cast answers 503 and no receiver is ever contacted. |
Serves images and videos from configured root directories. Categories are subfolders under each root. No MusicBee library integration — purely a file-serving feature for slideshows and ambient display.
/media/* endpoint listed below is DJ-only. Guest and Anonymous callers get 403 PARTY_LOCKED with the message “Media browsing is locked during PartyMode (DJ-only).” The host’s Projector charm runs as DJ on localhost so the big-screen flow keeps working. Outside party mode, every caller is allowed (media is browse-only and not covered by the existing ApiReadOnly* gates).
Configure in mbxhub.json under media:
"media": {
"imageRoot": "Pictures;C:\Users\...\Pictures\Wallpapers",
"videoRoot": "Pictures;Videos;D:\Concerts",
"intervalSeconds": 30,
"shuffle": true
}
Each setting is a semicolon-separated list of folders, so one box can name more than one place — videos in the Videos folder and videos that live under the music library, without having to choose. Entries are walked in the order typed and duplicates are dropped.
An entry is either a path or one of three shorthand tokens — Pictures,
Videos, Music (case-insensitive) — for the Windows profile's
corresponding folders. Every token works in either setting: Videos is valid in
imageRoot. The actual location is looked up from the shell's known-folder
registry, so redirected or relocated profile folders resolve correctly; a token that cannot
be resolved is kept as typed rather than silently dropped.
Both settings feed every request — the file extension is what separates an image from a video, not which setting its folder was named in. A folder holding both kinds only needs naming once.
Each subfolder of a root becomes a category. Files sitting directly in the root (not in
any subfolder) are still included — they appear under a pseudo-category named
_root. Applies to both images and videos.
Returns timer config: {intervalSeconds, shuffle}
List subfolder names under imageRoot. Files directly in root appear as _root.
List subfolder names under videoRoot. Files directly in root appear as _root.
List filenames in category. Returns {name, files:[], count}. Filenames only, no paths.
List video filenames in category.
Serve the next image (rotation state per category, sequential or shuffled). Returns image binary.
Serve the next video with HTTP Range support for seeking.
Serve a specific image by filename.
Serve a specific video by filename. Supports HTTP Range requests (206 Partial Content) for seeking.
Images: .jpg, .jpeg, .png, .bmp, .webp, .gif
Videos: .mp4, .webm, .mkv, .avi, .mov, .asf, .wmv (.mp4/.webm play inline; others open externally)
Minimum file size: 5 KB (skips thumbnail artifacts).
All paths validated under configured root — no directory traversal. Category names mapped to actual subfolders. Filenames validated against directory contents. Unconfigured roots return empty categories.
/pages/media — Full-bleed media viewer with auto-rotation,
crossfade transitions, video playback, chrome overlay with category/mode selectors,
keyboard (arrows, space, F) and touch/swipe navigation. Deep-linkable: the address bar
carries ?mode=&cat=&file= for the file on screen, and the link button
copies the file's own URL.
Control audio volume across three layers: MusicBee player volume (via /player/volume),
the Windows audio device, and network endpoints (e.g. Devialet Phantom speakers).
The Mixer charm provides a unified fader mixing surface for all three.
Every write here that moves a level or a mute answers 403 when volume
is read-only — that is apiReadOnlyMode, apiReadOnlyPlayer or
apiReadOnlyPlayerVolume, the same three settings that gate PUT /player/volume, and
during a party the same DJ-only rule. One rule for all three faders, not just MusicBee’s:
a caller silencing the room through the Windows fader is not, to the person listening, doing something
different from silencing it through MusicBee’s. It also closes /mixer/volume’s
fall-back — when no speaker is configured, that route lands on the player instead, so a gate applied
per-fader would have had a hole in it. Reads are unaffected.
List all active Windows audio render devices (Core Audio ground truth)
// Response:
[
{ "name": "Speakers (HD Audio)", "id": "{0.0.0...}", "isDefault": true },
{ "name": "HDMI Output", "id": "{0.0.0...}", "isDefault": false }
]
Get Windows audio device volume and mute state
// Response:
{
"device": "Speakers (HD Audio)",
"volume": 75,
"muted": false
}
Set Windows audio device volume (0–100)
PUT /devices/audio/volume
Content-Type: application/json
{ "volume": 50 }
Set Windows audio device mute state
PUT /devices/audio/mute
Content-Type: application/json
{ "mute": true }
Manage network audio endpoints (speakers, receivers). Endpoints are saved in settings and
controlled via REST. Three families are supported, chosen by type:
devialet (Phantom), streamsdk (alias fosi —
StreamUnlimited StreamSDK streamers such as the Fosi Audio S3) and linkplay
(alias wiim). All three answer the same volume / mute / source surface;
where a device cannot answer part of it, it returns nothing rather than the contract
changing shape. An unknown type is refused with 400 UNKNOWN_TYPE
at add time rather than stored as an endpoint that can never be reached.
List all configured network endpoints
// Response:
[
{
"id": "devialet-1",
"name": "Living Room",
"type": "Devialet",
"ip": "192.168.1.50"
}
]
Add a new network endpoint
POST /devices/endpoints
Content-Type: application/json
{
"ip": "192.168.1.50",
"type": "devialet",
"name": "Living Room"
}
Scan the LAN for network audio devices. Bodyless POST
(send Content-Length: 0). ?protocol=mdns|ssdp|all selects
the discovery protocol: mdns (default) sweeps speaker service types via
mDNS/DNS-SD; ssdp sweeps UPnP MediaRenderer:1 via SSDP
M-SEARCH (WiiM/LinkPlay, Sonos, TVs...); all runs both concurrently.
Blocks for ~4 seconds while the multicast discovery completes. Each result carries
protocol; SSDP rows add model, manufacturer and
location (names come from the UPnP description XML).
A row whose manufacturer is recognized also carries kind —
devialet, streamsdk or linkplay, the wire
dialect that drives it — plus kindSource naming the rule that fired.
Derived from the description already fetched, so identification costs no extra
request and no probe. An unrecognized device omits both fields rather than
guessing: absent means the scan does not know, and a human still chooses.
Concurrent calls are rate-limited internally (429 with Retry-After).
// Response:
[
{
"name": "Living Room Phantom",
"ip": "192.168.1.50",
"port": 80,
"hostName": "phantom-abc123.local",
"serviceType": "_devialet._tcp.local.",
"alreadyConfigured": false,
"existingId": null,
"protocol": "mdns"
},
{
"name": "ultra",
"ip": "192.168.1.100",
"port": 49152,
"hostName": "",
"serviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
"alreadyConfigured": false,
"existingId": null,
"protocol": "ssdp",
"model": "WiiM Ultra Receiver",
"manufacturer": "Linkplay Technology Inc.",
"location": "http://192.168.1.100:49152/description.xml"
}
]
Remove a saved endpoint
Get endpoint volume and mute state
// Response:
{
"volume": 40,
"muted": false
}
Set endpoint volume (0–100). A speaker that does not answer is refused 502 ENDPOINT_UNREACHABLE rather than reported as set — this route acts on the named endpoint directly and does not soft-fall-back to the player the way a /mixer/volume read does
PUT /devices/endpoint/{id}/volume
Content-Type: application/json
{ "volume": 50 }
Set endpoint mute state
PUT /devices/endpoint/{id}/mute
Content-Type: application/json
{ "mute": true }
List available input sources on the endpoint
// Response:
[
{ "sourceId": "upnp", "name": "UPnP", "type": "upnp", "active": true },
{ "sourceId": "optical", "name": "Optical", "type": "optical", "active": false },
{ "sourceId": "analog", "name": "Analog", "type": "analog", "active": false }
]
Select an input source on the endpoint
PUT /devices/endpoint/{id}/source
Content-Type: application/json
{ "sourceId": "upnp" }
Configure which fader the dashboard volume controls target by default.
Get mixer settings. defaultEndpointId is what is stored; defaultEndpointResolved is the endpoint the endpoint fader would actually act on right now, with the fallback already applied, so a client never has to re-implement it. null when no endpoints are configured.
// Response:
{
"defaultFader": "player",
"pollIntervalMs": 22000,
"defaultEndpointId": "living-room",
"defaultEndpointResolved": { "id": "living-room", "name": "Living Room", "type": "wiim", "ip": "192.168.1.50" }
}
Set mixer settings. defaultFader controls which fader the dashboard volume slider and keyboard shortcuts target. defaultEndpointId names which configured endpoint the endpoint fader acts on — an empty string means “the first one in the list”, which is how it behaved when a Devialet was the only kind of endpoint there was. An id matching no configured endpoint is refused with 400 UNKNOWN_ENDPOINT rather than stored.
PUT /mixer/settings
Content-Type: application/json
// Values: "player", "device", "endpoint"
{ "defaultFader": "device" }
// Point the endpoint fader at a specific speaker (empty string = first in list)
{ "defaultEndpointId": "living-room" }
Get current volume from the active default fader (player, device, or endpoint). Returns fader type and volume (0-100). Endpoint mode resolves mixer.defaultEndpointId, falling back to the first entry in endpoints[] when it is unset or names an endpoint that is gone; soft-falls-back to player if none is configured or the speaker is unreachable, surfacing the actual fader hit in the response. In endpoint mode endpointActive says whether the speaker answered: true when it did, false when it did not and the level shown is the player’s.
// Response (player fader):
{ "fader": "player", "volume": 75 }
// Response (device fader):
{ "fader": "device", "volume": 80, "muted": false, "device": "Speakers" }
// Response (endpoint fader):
{ "fader": "endpoint", "volume": 60, "muted": false, "endpoint": "Phantom", "endpointActive": true }
// Response (endpoint fader, speaker not answering -- the player's level):
{ "fader": "player", "volume": 75, "endpointActive": false }
Set volume on the active default fader. Also accepts POST. Takes an absolute volume (0-100) or a relative delta, which is resolved server-side against the current level of whichever fader is active — the same contract as /player/volume, and for the same reason: a client that has to read-then-set has a dead first press and races itself on two quick presses. volume wins if both are sent. In endpoint mode no write moves another fader. When the speaker does not answer — the write, the read a delta resolves against, or a call that fails — nothing changes, and the answer is success: false with endpointActive: false. A speaker that answers but does not take the write is success: false with endpointActive: true, and one that takes it is success: true with endpointActive: true. Only when no endpoint is configured does the server fall back to Player_SetVolume, return fader: "player", and resolve a delta against the player.
PUT /mixer/volume
Content-Type: application/json
{ "volume": 80 }
// Relative, resolved against the active fader's current level
{ "delta": -5 }
// Response (player or device fader):
{ "fader": "device", "volume": 80, "success": true }
// Response (endpoint fader, speaker reached):
{ "fader": "endpoint", "volume": 80, "success": true, "endpoint": "Phantom", "endpointActive": true }
// Response (endpoint fader, speaker did not answer -- no other fader changed):
{ "fader": "endpoint", "volume": 80, "success": false, "endpoint": "Phantom", "endpointActive": false }
// Response (endpoint fader, a delta whose speaker read got no answer -- nothing changed, no level):
{ "fader": "endpoint", "success": false, "endpoint": "Phantom", "endpointActive": false }
// Response (endpoint fader, no endpoint configured -- fell back to player):
{ "fader": "player", "volume": 80, "success": true }
Mute state of the active default fader, resolved exactly as /mixer/volume is — so volume and mute always land on the same device. fader is the one actually hit, which is how a client can tell that an unreachable speaker sent it to the player instead.
// Response (endpoint fader):
{ "fader": "endpoint", "muted": false, "endpoint": "Living Room" }
Set mute on the active default fader. Also accepts POST. Send mute for an explicit state, or toggle to invert whatever the current fader reports — the toggle exists so one button does not have to read-then-write across two round trips and race whatever else is moving the same device.
PUT /mixer/mute
Content-Type: application/json
{ "mute": true }
{ "toggle": true }
// Response:
{ "fader": "endpoint", "muted": true, "success": true, "endpoint": "Living Room" }
Charms are configurable action buttons on the dashboard. They can open webapps, fire HTTP requests to LAN devices, or call MBXHub endpoints. The charm bar appears as a dashboard section and can be reordered/hidden like any other panel.
A registered charm can also run verbs, keep a store and offer settings — three capabilities the operator grants in the Charm Manager; see Verbs, the store and settings below. No charm MBXHub ships uses them: an in-box charm such as ARiA or the Dice (dice) opens its page or posts its route directly, with no registration.
Use ?placement=overlay for HUD views; the default list contains window members. Pixel members carry placement, charmId and sourceSpec. The existing source field still means list origin. Without a current Video grant, sourceSpec is withheld and blocked is render:spout. The Shell checks the declared source before opening and renews authorization every five seconds; loss of authorization or an unavailable hub stops rendering.
The window-able charms — the list the MBXHub Shell's overlay compositor reads to offer a charm as a desktop window. A charm is window-able when one of its actions is webapp <path>: that path is a hub page, so it can be a window. Charms whose actions are pure HTTP or exec have no page to host and are omitted, as is any page that resolves off this hub.
Which members, and in what order. Charms follow charmBar.order — the same list the dashboard bar uses, so a charm placed there arrives in that place here; anything unnamed keeps its load order after the named ones. Loose pages/*.html are opt-in: name a slug in charmBar.windowPages (tree.html is tree) and it appears as page.<slug>. Empty means charms only — sourcing every page put 26 members on a stock install, and a launcher that sizes to its content asked for more height than the screen. charmBar.hidden wins over an opt-in, and accepts either the bare slug or the page. id.
Visibility is the charm bar's, not this endpoint's: the same three filters the dashboard applies are applied here — nothing at all during an active party, ids in charmBar.hidden dropped, and aria dropped when ARiA is disabled — so the endpoint never publishes the id, label, icon or page of a charm the host chose to hide. Settings are read per request, so a toggle takes effect on the next call. A registration nobody has approved is not published here either, and that exclusion is not one of the three: it happens at the load, once, for every charm surface at the same time — see What is rendered, and when.
Disable with the apiDisableCharmPages setting (returns 404 when true); the compositor then falls back to its own built-in pages.
// Response:
{
"success": true,
"data": {
"members": [
{
"windowId": "charm.mixer",
"page": "/pages/mixer.html",
"label": "Mixer",
"icon": "<svg viewBox='0 0 16 16' …></svg>",
"source": "charm"
}
]
}
}
icon comes from the charm’s own value, or from the expand entry that supplied the page when the charm has none of its own — most shipped charms keep their icon only on the expand entry, so reading the top level alone answered null for almost all of them until 2026-08-18. The shipped charms carry inline SVG markup rather than an emoji — a consumer must RENDER it, not look up a glyph. (This example said "🎛" and showed a bare array until 2026-08-18; both were wrong against the live response.)
Top-level pages/*.html files are listed too, as windowId: "page.<slug>" with source: "page", NO icon and a label derived from the filename — so dropping an HTML file in makes it window-able. A page a charm already offers is skipped rather than listed twice. A missing icon is normal here, never an error.
HUD presentation: use GET /charms/services?for=hud to apply presentationDefaults.hud and the saved hudOrder/hudOverrides settings. The unqualified endpoint supplies desktop launchers independently. Both retain the existing eligibility and local-caller rules; Show never grants a scope or invents a missing action. An all-hidden HUD list returns no service tiles.
Manage these tiles in Settings → Charms → HUD Services, available in MusicBee and the web editor alongside Dashboard rail and Desktop charmbar. Choose Default/Show/Hide, reorder, or reset visibility and order separately. MusicBee saves with OK; web changes save immediately. Close and reopen Services in the HUD after saving. This controls charm tiles such as iKandy, not built-in station, program, or output controls.
GET /system/settings/schema includes charmPresentation.hud rows with defaults and availability. PUT /system/config accepts charmBar.presentation.hudOrder (typed-key array) and charmBar.presentation.hudOverrides (typed keys mapped to show/hide). Service keys use launch:<charm-id>, for example launch:app.ikandy. An empty array resets order; an empty map resets visibility. Older profiles inherit HUD defaults with empty preferences; legacy rail writes preserve HUD choices. The Charm Manager, where scopes are granted, remains MusicBee-only and Dev Mode-only.
The launchable list a HUD-like surface draws: charms that declare a launch or launchFallback target and claim the overlay placement. Answers { "local": true, "services": [ { "id", "label", "icon", "launchKind" } ] }, where launchKind is scheme or exe — enough for a client to say what pressing it does, and the target itself is never returned: that is the operator’s business on the approval row.
Off this machine it answers an empty list, with local: false — “this machine” is loopback or a request that arrived on the address it came from, so a page reaching the hub by the machine’s own LAN address counts (before 2026-09-08 only loopback did, and the charm bar drew no launch charms at all). The press is local-only, and a remote HUD drawing tiles that cannot work would be a lie.
The placement condition is load-bearing rather than decoration: without it every Tools-menu launch charm would appear in the HUD as a side effect of existing, reaching a surface its author never asked for and the operator never saw on the row. overlay is how a charm asks for the HUD. A charm with no registration (no record for it under charms/registrations/) is listed — a manifest in the charms folder was put there by the person whose machine this is, and the launcher reads the same rule. A charm on the revoked or ban list is not listed, record or no record.
A verb-only charm is listed too: one with no launch or launchFallback target that declares a verb (a post <path> entry), holds the verbs grant and claims overlay is a row with launchKind: "post", verbIndex: 0 and, when that verb declares one, confirm — the question a surface asks before it presses (the HUD, the Charm Bar and the dashboard ask it as a two-tap: the second tap within 3 seconds sends). verbIndex and confirm are absent from exe and scheme rows. See Verbs, the store and settings below.
Start it. Body-less, and there is no target parameter, ever: what runs is the manifest’s own launch target, or its optional launchFallback after an unavailable target or failed dispatch — both strings are shown on the approval row — so what was approved and what starts cannot come apart. Local callers only.
A second press RAISES rather than starting a second copy. When the charm’s executable is already running under the hub’s single-instance guard, its window is restored and brought to the front, and the answer says which happened: { "launched": false, "raised": true } — exactly one of the two is ever true, and launchKind rides along either way. Pressing an icon again is the clearest statement of what you want in front, so it is answered rather than ignored. Scheme launches are handed to the shell and whatever it does with a repeat is the handler’s business, not ours.
Refusals: 403 NOT_LOCAL (not on the machine MBXHub runs on); 403 API_READ_ONLY when apiReadOnlyMode is set — read-only is the API’s posture and being local does not exempt it, which is the same refusal /charms/register has always given; what stays unrestricted is the console, so the Tools-menu item and the Charm Manager still launch; 404 NOT_FOUND (no such charm); 403 LAUNCH_REFUSED, whose message is the launch policy’s own reason — not active, no target declared, a denied scheme, a scheme the operator’s allow-list does not name, or a registered charm’s executable that is not its observed image; a charm on the revoked or ban list is refused the same way, with a message naming which, rather than answered 404, and so is any registered charm while either list cannot be read; 409 LAUNCH_NOT_STARTED (allowed, and nothing happened). That last one no longer means “already running” — that case is the raise above. It now means the shell threw, the guard is tracking a process that has gone, or the app is running with no window to raise, which is the log line to read: a launch that produced no window is a wrong target, a scheme handler that swallowed it, or an app that exited its splash, and the hub says so by name.
A verb is a press too. With no launch target the press performs the charm’s first verb, and ?verb=<n> picks one by position — 0-based, counting only the post entries, in manifest order; it cannot name a path. Out of range or not a whole number is 400 VERB_INDEX_INVALID, answered after NOT_LOCAL, API_READ_ONLY and NOT_FOUND. A verb needs an active registration, the verbs grant and a path the hub allows, or the press is 403 LAUNCH_REFUSED with the reason. The answer is 200 whatever the route said — the press happened: { "id", "launchKind": "post", "status", "data" | "error", "say" }, where status is the route’s own HTTP status. Further refusals: 409 VERB_IN_FLIGHT, 502 VERB_UNREACHABLE, 503 VERB_UNAVAILABLE, 400 VERB_BODY_INVALID. The rules are under Verbs, the store and settings below.
The vocabulary a charm may ask in — every capability this build knows, in the words a person answers in. Ungated and read-only: it states the vocabulary, not anyone's grants. Read it before registering, because registration refuses a scope this build does not know, and being told the words only after being refused for using the wrong one is not a contract anyone can build against.
scope is the wire name (area:verb, except the three charm capabilities verbs, store and settings, which are one word each) and never appears in name — someone agreeing to something should not have to know the route table to know what they said yes to. name is what the operator sees, named for what they are exposing rather than for what the API does (“Remote control”, not “aria:input”). agreeing is the one line they are agreeing to. maturity is default or lab. reserved true means withheld from every caller, and reservedReason says what is missing — reserved entries are listed rather than hidden, because a capability nobody can have is still something an integrator needs to see, and hiding it only turns “reserved” into “undocumented”.
// Response:
{
"success": true,
"data": {
"apiVersion": "1",
"capabilities": [
{
"scope": "playback:control",
"name": "Playback control",
"agreeing": "play, pause, skip, seek and change the volume",
"maturity": "default",
"reserved": false
}
]
}
}
The fifteen, as shipped.
| Scope | Name | Agreeing to | Maturity |
|---|---|---|---|
playback:control | Playback control | play, pause, skip, seek and change the volume | default |
library:read | Library | browse your tracks, playlists and metadata | default |
library:write | Tags | change tags on your files | default |
proxy:lan | Network devices | make requests to devices on your network | default |
automation:status | Automation status | see whether automation is available | default |
automation:wake | Remote wake | wake this computer | default |
automation:scan | Library maintenance | trigger a library scan | default |
automation:presets | Saved automations | run a routine you wrote yourself | default |
aria:input | Remote control | type and click on this computer | default |
render:spout | Video | show a live picture inside MBXHub's surfaces | lab |
verbs | Verbs | post to hub routes its manifest names | lab |
store | Store | keep a small document on the hub | lab |
settings | Settings | offer knobs the hub renders | lab |
audio:tap | Audio | receive the audio being played | lab — reserved: no in-process audio source exists yet |
bridge:internet | Internet | reach the internet through MBXHub | lab — reserved: the bridge is not built |
Three of the fifteen are recorded but not enforced yet. The hub’s scope table has no row naming playback:control, library:read or bridge:internet, so no route demands one and the gate never consults them: granting or withholding one of those three changes nothing a caller can observe in this build, and playback and library reads answer a ticketed caller exactly as they answer an unregistered one. Ask for what you actually use anyway — the operator’s answer is recorded against the charm and is what a later build enforces — and expect a refusal to start being a refusal without the vocabulary changing. The Charm Manager marks those rows the same way.
render:spout was the fourth of those until 2026-09-07, and is enforced now — but not by a route. The scope table still has no row for it, and that is correct rather than an oversight: what it gates is not a request. /charms/pages withholds sourceSpec from a member whose registration does not hold the grant and sets blocked to render:spout, and the Shell re-checks authorization every five seconds while a source is hosted. So the capability is observable — a charm without it gets no picture — while ScopeTable.Gates still answers no, which is the honest answer to the narrower question that table asks.
The three charm capabilities are enforced, two of them outside the scope table. store has a scope-table row (/charms/{id}/store). verbs is enforced by the launcher — a verb is performed only for a charm holding it. settings is enforced where settings are offered — the settings schema, PUT /system/config and the Charm Manager show and write a charm’s settings only while it holds the grant. See Verbs, the store and settings below.
An application registers with the hub by sending its charm manifest. A charm can also arrive as a file: the hub reads the manifests in its own charms folder when it starts, which is how the charms shipped with it get there, and a manifest dropped in beside them is read the same way. What registering gets you that a file cannot is a registration — an identity the hub recorded, an approval a person gave it, and the ticket that follows. Neither route involves the hub going looking: it reads its own folder and listens on its own route, and nothing scans the machine for applications. A registration adds and never subtracts: every endpoint that answers an unregistered caller answers a registered one identically, and stays that way until the operator grants something.
Where a registration lives. A charm’s manifest file, charms/<id>.json, is its definition and nothing else. What the hub knows about the charm — its status, the scopes you grant in the Charm Manager, the hub id, the approval stamp and the manifest hash it covers, whether the ticket was collected, and the per-scope declines and bans — is kept beside it in charms/registrations/<id>.json, one record per id. Two lists sit in the same folder, revoked.json and banned.json, each keyed by publisher and id. A registration member in a manifest is ignored, whether a request sent it or somebody typed it into the file. Deleting a manifest file does not delete its record: while the file is gone nothing is drawn and no ticket resolves, and registering again picks the record up.
An approval is a stamp over the manifest file’s bytes. When the operator approves a charm, its record keeps the SHA-256 of the manifest file’s text and a stamp over it: an HMAC minted with a key this hub keeps beside its own secret, in the per-user store, created the first time anything is approved and never changed by pairing or unpairing the Shell. Every read checks the stamp against the file as it is on disk. Change one byte, a new version included, and the charm waits for Approve again; its ticket stops working until the operator approves the new bytes. License grants are kept out of the stamped text, so a renewed, added or dropped license never asks for Approve again. A charm that ships with MBXHub is approved by the signed plugin and never waits for anybody.
Upgrading from a build that kept the registration in the manifest file. The first time this build reads a charms folder it moves every registration block into its record, once, and rewrites the manifest file as a definition only; the stamp covers that rewritten text. An approval an older build made that names no hub cannot be stamped, so that charm waits for Approve once after the upgrade, and can be approved after it registers again. A file the move could not finish (no stamp key could be had for it) is left as it is and tried again for that id, at most every 30 seconds, until it moves. A manifest, record or list that will not parse is set aside beside itself as <name>.json.<yyyyMMddHHmmss>.bad (a manifest is copied and left in place; a record or list is moved), said once in the log, and waits for a person; the move goes on with everything else. One consequence, stated so it is a decision and not a surprise: the stamp covers the text exactly as this build writes it, so a later build that wrote manifests differently would make every approved third-party charm wait for Approve once.
Local callers only, and the body must be declared as JSON. Registration is accepted only from the machine MBXHub runs on (request.IsLocal) — a hub that could be enrolled from across the network is a hub anyone on the network can enroll themselves into, so this is not a policy that loosens. It is checked first, ahead of read-only mode and ahead of reading a byte of the body, so a remote caller is told NOT_LOCAL whatever it posted. The request must also carry Content-Type: application/json (parameters such as ; charset=utf-8 are fine); anything else is 415. That second requirement closes the browser-driven path a locality check cannot: a text/plain POST is a CORS-simple request, so a page the operator happens to be visiting could fire one at localhost with no preflight and have the write land, and application/json is not on that list.
Body is a charm manifest — the same JSON an installed charm file holds — read as UTF-8, at most 64 KB. id is required, must be a string, and must be publisher-namespaced: at least one dot, at most 128 characters, letters/digits/./-/_ only, no / \ : and no .. segment. The dot is not house style — the plain built-in charms have no dot in their ids, so a registered id can never collide with a built-in that the next upgrade re-seeds by name, and the id is safe to use as a file name.
// POST /charms/register
{
"id": "com.example.thing",
"schemaVersion": 2,
"label": "Thing",
"publisher": "Example Ltd",
"version": "1.0",
"kind": "proc",
"scopes": ["playback:control"],
"scopeReasons": {
"playback:control": "to pause the music when a call comes in"
}
}
// Response:
{
"success": true,
"data": {
"charmId": "6f1c2f42-1d0c-4d2a-9f5f-7c3f2b0a91de",
"status": "pending",
"restartRequired": [],
"apiVersion": "1"
}
}
Status. pending means registered, not yet approved: no capability is granted, and every open endpoint behaves for the charm exactly as it does for an unregistered caller. active means the operator has approved it. revoked means they took the approval back — the record stays, the credential is gone, and every grant is released. Nothing in a request can ask for active: an approval comes from the operator in the Charm Manager, or from the signed plugin for a charm MBXHub ships. GET /charms/{id} can also answer banned.
restartRequired — what is registered but not yet on screen. The placements this registration declared that MusicBee itself hosts (menu, node, tab, hotkey, status). They are registered at the plugin's Initialise, so they appear when MusicBee next starts, not now. [] when there are none, and present on every 200 — including a re-registration of the same entry, because the answer describes the MusicBee session that is running, not what changed since last time. It is absent from a refusal, where nothing was registered. Names are reported as the manifest spelled them, deduplicated case-insensitively with the first spelling kept; a placement nothing hosts (rail, rail-menu, window, overlay, or a value this build does not know) is not listed, because claiming it waits on MusicBee would be a promise that never comes true.
How a charm receives its ticket. Approval happens at the console (MBXHub settings → the Charm Manager tab — a dev-mode tab, hidden until seven clicks on the version label reveal it alongside the debug row) and no REST route approves anything. Registration is how the charm finds out: the next POST /charms/register from loopback carrying the same id, once the operator has approved, answers status: "active" with grantedScopes and a plaintext ticket — once. The parked plaintext is taken as it is handed over, so registering again answers active and grantedScopes with no ticket. Both fields are omitted from a pending answer, so the shape a not-yet-approved caller sees is byte-for-byte what it was. Store the ticket the moment you receive it — no route re-reads it.
A caller the hub could not resolve does not collect the ticket either. When the peer lookup runs and cannot say which process is calling, the answer is still active and still states grantedScopes, the plaintext stays parked, and the body carries "ticketWithheld": "caller-unresolved". Registering again from a connection the hub can resolve delivers it, exactly once as before. That field is what tells this apart from the ordinary “you already collected it” answer, which is otherwise byte-identical and has the opposite remedy; it is absent from every other answer rather than sent as null, so its presence is the whole signal. It applies to every kind, not only proc — what failed is who is calling, which is not a question about substrate.
// The registration AFTER the operator approves:
{
"success": true,
"data": {
"charmId": "6f1c2f42-1d0c-4d2a-9f5f-7c3f2b0a91de",
"status": "active",
"grantedScopes": ["playback:control"],
"ticket": "3Qk1x…43 characters of base64url…",
"restartRequired": [],
"apiVersion": "1"
}
}
A hub restart before delivery loses the parked ticket — and approving again is the way out. The plaintext is held in the hub's memory until it is claimed — only its sha256: digest is ever written to disk — so if MBXHub restarts between the approval and the charm's next registration, the minted ticket can never be delivered. The record is still active and still holds that ticket's digest; it just answers registrations without a ticket, and every gated call it makes answers 401 TICKET_UNKNOWN. The operator ticks the row and clicks Approve again, and a new credential is minted, which the charm collects on its next registration: an approval nothing ever collected has nothing running on it to log out, and the Charm Manager tells the two apart — “approved; waiting for the charm to register” against “running with a credential”. No revoke is needed for this. Approving again on a credential the charm HAS collected still adjusts its grants and keeps that credential rather than minting a second one, so a running charm is never logged out by a grant change; re-issuing to a running charm is revoke, then approve. A registration written by an older build carries no delivery marker at all, and its absence reads as collected — so upgrading never turns a charm that is running into one the next Approve would re-issue a credential to; only an approval this build minted and has not yet handed over is marked uncollected.
Registering again is one record, not two. The same id posted a second time answers 200 with the same charmId and the status the registration already had, and keeps its original registration time. The manifest half is the caller's to change — a later version, a new label, different scopes: the manifest file is rewritten with what was posted, and the record beside it keeps the hub-owned half — charmId, status, the registration time, the observed image and its digest, the tier, the ticket digest, the declined and banned lists, and the granted scopes — so a re-registration can never advance the operator's decision. A manifest whose bytes changed does send an approved charm back to waiting for Approve; see Any changed byte of the manifest below. An application that registers the same manifest on every launch therefore accumulates nothing. The hub id is re-written from the running install on every registration, and a record that named a different hub is severed by that registration — it waits for Approve again (pending), the ticket digest gone, every grant released, and an approval whose plaintext was never collected discarded with it. A reset network setup is therefore a real start-over: the operator approves again, deliberately, on the hub that exists now. Declines, bans and the observed image are not cleared by a severing — a reset is the operator's own act, and none of those is an approval to take back, so re-keying the hub cannot launder a ban away. A revoked id stays revoked through a reset, and stays claimed by its publisher. A record naming no hub at all is not a severing: there is nothing for it to have changed from.
Registering from a different executable takes the approval back. For a proc charm the hub resolves the image behind the registering connection on every registration. When an active record was approved for one image and the registration comes from another, the charm waits for Approve again (pending) and the ticket digest is cleared, so the old credential resolves to nothing and every gated call it makes answers 401 TICKET_UNKNOWN; an approval whose plaintext was never collected is discarded with it. The registration still succeeds — the answer is pending, with no grantedScopes and no ticket, and the launch action refuses for the same reason, so a charm cannot move its own gate and keep the yes. The grants stay on the record and are confirmed again when the operator approves, which is what mints a new ticket. A path that differs only in case, or only in .. segments that resolve to the same file, is not a move. A registration whose caller could not be resolved is not an observation at all: nothing is written — not the image path, not its digest, not the tier — so what was observed before stands, and a registration the hub cannot identify can neither move the gate nor erase what is on the record.
Any changed byte of the manifest takes the approval back. The approval is a stamp over the manifest file’s bytes, so a registration whose manifest text differs from the text that was approved sends an active record back to waiting for Approve (pending) with its ticket cleared, whatever moved: a new version, a label, an icon, a launch or launchFallback target, a placement, a sentence in scopeReasons, a scope added, a verb path, or a verb’s body, say or confirm. One rule replaces the separate per-field compares earlier builds made, and it is stricter than they were: a field dropped and the same field sent as "" are different bytes, and a version bump is a change. The grants stay on the record and are confirmed again when the operator approves the new bytes. The same happens with no registration at all when somebody edits the manifest file on disk: the stamp stops verifying, so the charm reads as waiting for Approve, is not drawn, and its ticket opens nothing until the operator approves the file as it now is. Ship a manifest change in a release you would expect a re-approval in.
Everything that sends an approval back to waiting for Approve, each with the same disposition (status pending, the ticket cleared, an approval nobody collected discarded) and the grants kept unless the item says otherwise:
proc charm), as above;Re-registration prunes grants to what is still asked for. On every registration the granted set is intersected with the incoming manifest's scopes, so a capability the publisher stopped asking for is released rather than held indefinitely. The sharp edge, stated so it is a decision and not a surprise: a manifest that stops declaring scopes at all releases everything. Declines and bans are not pruned — a grant is released when the publisher stops asking, but a NO is not the publisher's to withdraw by shipping a version that omits the scope.
An id is claimed by the publisher the operator approved. Once a record is active or revoked and names a publisher, that id is re-registered only by a caller whose publisher is non-empty and ordinal-equal to the one on file; an empty incoming publisher and a mismatch both answer 409 ID_CLAIMED. A record that names no publisher claims nothing: an empty stored publisher is a string no caller could ever equal, so such a record would have locked its id forever — for its own author most of all — and nothing is at stake in admitting it, because the hub refuses to approve a record that declares no publisher, so an anonymous record holds no grant and no credential. A pending record is still overwritable: nothing has been granted to it, so there is nothing to inherit, and refusing would let anyone park an id no one could then register. Revoking does not put the id back on the shelf.
The rule is enforced where the write happens, inside the registry's own lock, and the route checks it first only as a fast path. That matters because an approval landing between the route's read and its write would otherwise admit a second publisher against a state that no longer existed — and hand them the plaintext ticket parked for the first.
A registration record that cannot be read is never replaced. A record that will not parse, or that a newer build wrote, is refused: the registration answers 500 REGISTRY_RECORD_REFUSED, nothing is written, and the record waits for a person to repair or remove it. A record the disk would not hand over just now is 503 REGISTRY_BUSY: register again. A manifest file that will not parse is simply replaced by the one posted, because it is the charm’s to send again; an approval that stood beside it waits for Approve again, as above.
Every scope you ask for carries one sentence of your own saying why. scopeReasons is a top-level object mapping each scope name to a single sentence, and the person approving you reads that sentence verbatim, beside the capability’s name, on the Charm Manager row — and again on the runtime prompt, labeled They say: so they know which sentence is yours and which is ours. It is the only thing about your request they see in your words. Write it as the truth, for the person, not for us: a rationale that does not match what the application then does is what gets an extension revoked.
Declare schemaVersion: 2 and give every scope its sentence — that is how a charm written to this document registers. Version 1 (declared, or absent, which reads as 1) is tolerated for manifests that predate the field and may omit scopeReasons entirely; such a charm registers exactly as it always did, and its approval row says no reason given against every capability it asks for. The two lists are otherwise the same set: a sentence for a scope you did not request is refused, not ignored. Each sentence is one line of at most 200 characters, trimmed on the way in — it gets one row beside the capability’s name, and a sentence carrying its own line breaks is laying out somebody else’s console. It is stored on the record verbatim; nothing here re-encodes it, and no & or angle bracket in it is escaped away.
Rewording a sentence takes the approval back. A reworded sentence is changed bytes, so a re-registration that rewords one sends an approved charm back to waiting for Approve with its ticket cleared, like any other change to the manifest (above). A charm approved on “to show your now-playing artwork” cannot come back saying “to index your library for our servers” and carry on with the credential it was given. It also means the first registration in which an already-approved charm starts sending sentences costs it its approval once, so adopt scopeReasons in a release you would expect a re-approval in.
Refusals.
| Status | Code | When |
|---|---|---|
403 | NOT_LOCAL | The caller is not on the machine MBXHub runs on. Checked first, before read-only mode and before the body is read |
415 | UNSUPPORTED_MEDIA_TYPE | Content-Type is not application/json. Parameters are allowed; a request declaring no content type at all is refused too |
400 | INVALID_REQUEST | Empty body; a body that is not JSON; a JSON value that is not an object; or an object with no string id |
400 | INVALID_ID | id is not publisher-namespaced. Its own code, so a caller can tell “your id is not allowed” from “your body was not readable” |
400 | UNKNOWN_SCOPE | A scopes entry this build does not know. The offending scope is named in the message, and nothing is written — a manifest asking for something that can never be granted is not stored asking for it. A null entry in the array is refused the same way and named (null), rather than skipped as though it were not there. GET /charms/capabilities is the vocabulary |
400 | SCOPE_RATIONALE_MISSING | A scopes entry with no sentence in scopeReasons, or a blank one. The offending scope is named in the message, and nothing is written. Gated on the schema version: raised only for a manifest declaring schemaVersion 2 or higher, and the message says so — a version-1 manifest predates the field and may omit it entirely. Sending "" is the same fact as sending nothing and gets this code, not the next one, so nobody is sent looking for a formatting problem |
400 | SCOPE_RATIONALE_INVALID | A sentence that is not one line, or is longer than 200 characters once trimmed. The offending scope is named, and nothing is written. Refused at every schema version: a sentence that cannot be shown on the row is not made showable by the version it arrived under |
400 | SCOPE_RATIONALE_UNKNOWN | A scopeReasons key naming a scope this manifest does not ask for. Refused rather than dropped, for the reason UNKNOWN_SCOPE is: you wrote words nobody would ever read, and you are here now to fix it rather than discovering it from an approval screen months later that simply does not say what you wrote. The offending scope is named, nothing is written, and this too applies at every schema version |
400 | INVALID_ACTION | An expand[] entry wrote a target into the verb — "action": "launch thing.exe". The message names the entry: Entry '<label>' declares action '<action>'. The action is bare 'launch' — the target belongs in the manifest's 'launch' field; an optional second target belongs in 'launchFallback'. Both targets are shown before approval. Matched on the first token, case-insensitively, so launcher /x is a different verb and is untouched. Refused before anything is written, because the alternative is a stored manifest that looks configured and starts nothing at a click nobody is watching |
400 | VERB_SAY_INVALID, VERB_BODY_INVALID, VERB_CONFIRM_INVALID | A verb’s say or confirm that is blank, not one line or over 200 characters; a body that is not a JSON object. The verb is named, and nothing is written |
400 | SETTING_KEY_INVALID, SETTING_TYPE_UNKNOWN, SETTING_TIER_UNKNOWN, SETTING_RANGE_INVALID, SETTING_DEFAULT_INVALID | The first fault in settings[], judged per entry in that order and naming the key. SETTING_RANGE_INVALID is an int or double setting whose min, max or step is NaN, infinite or of magnitude 7.9e28 or more — more than the console’s number editor can hold. Nothing is written |
400 | SETTINGS_SCOPE_MISSING | The manifest declares settings[] but does not ask for the settings scope, so its settings could never be shown |
409 | ID_CLAIMED | The id already carries an active or revoked record, and the incoming publisher is empty or is not the one on file. Also the answer for an id MBXHub ships, whatever the publisher: the message says it ships with this hub and cannot be registered over |
403 | CHARM_BANNED | The publisher and id are on this hub’s ban list. Nothing is written, and a manifest file for it placed by hand is not loaded either. The operator lifts it with Unban in the Charm Manager |
500 | REGISTRY_RECORD_REFUSED | The registration record for that id, or the revoked or ban list, will not parse or was written by a newer build. Nothing is written; a person has to repair or remove the file before that id can register. While either list cannot be read, every registration is refused, because nothing can say the charm is not on it |
413 | REGISTER_TOO_LARGE | Body over 64 KB, whether declared by Content-Length or discovered while reading a chunked request |
403 | API_READ_ONLY | apiReadOnlyMode is on. Registration writes a file into the charms folder, and read-only mode is the operator's statement that a remote caller may not change this install. Checked before the body is read |
403 | REGISTRY_FULL | This hub already holds 500 registration records, the ceiling on distinct ids. An id already registered still updates at the ceiling, so a full folder cannot stop an established charm shipping a new version, and a charm MBXHub ships is never refused by it |
503 | REGISTRY_BUSY | Your registration record, or the revoked or ban list, exists and this hub could not read it just now — a scanner or an indexer holding the handle while the file is swapped. The read is retried once before this is answered, and nothing is written: the approval, its credential and its charmId all still stand. Register again. It is a distinct answer precisely so it is not the other one: reading “I could not read it” as “there is nothing there” would answer a charm approved months ago pending with no ticket, and log an approved partner out because of a virus scan |
404 | NOT_FOUND | The route is POST-only. A GET /charms/register is not a lookup of a charm named “register” |
Two gates sit in front of the route rather than in it. While kiosk mode is on, and while a party is active for a non-local caller, the router redirects (302) anything outside the allowed surface before a handler runs; registration is on neither allow-list, so it is refused with a redirect rather than an error body. Localhost is exempt from the party redirect. The Basic password gate, when enabled, applies as it does to any other remote request.
Read one registration back. It reports a decision the operator has already made and changes nothing, so it carries no gate of its own: read-only mode does not refuse it, and neither does apiDisableCharmPages. The id is a path segment, so percent-encode it.
The two front gates still apply. The kiosk and party redirects described above are path-based, not endpoint-based — /charms/ is on neither allow-list — so on a kiosk box, and for a non-local caller while a party is active, this read is redirected (302) exactly as registration is. The Basic password gate applies too. “No gate of its own” is a statement about the handler, not about what reaches it.
// GET /charms/com.example.thing
{
"success": true,
"data": {
"id": "com.example.thing",
"kind": "proc",
"publisher": "Example Ltd",
"version": "1.0",
"status": "active",
"tier": "verified",
"grantedScopes": ["playback:control"],
"requestedScopes": ["playback:control"]
}
}
id, publisher, version — as the manifest declared them. A field the manifest did not carry is omitted from the response rather than sent as null.kind — the declared kind, or page when the manifest declared none.status — pending, active, revoked or banned. pending means it waits for Approve, and so does an active record whose stamp does not verify over the manifest as it is on disk. revoked and banned come from the two lists, keyed by publisher and id, and are answered with or without a manifest on disk: a listed id with no manifest file answers 200 with the publisher on its list line and no scopes. A charm on either list is not loaded and is described with empty grantedScopes. A record with no status describes as pending, the status every registration starts at.tier — trust tier, and it is observed, not declared. For a proc charm the hub resolves the peer process behind the registering connection and verifies that image's signature at registration time, then states the rung the evidence earns:
halrad-signed — the signature verifies and chains to our pinned certificate.verified — it chains, but not to us.unsigned — no signature, an unreadable one, or any WinVerifyTrust failure. A tampered image and an expired certificate both land here deliberately: a patched binary must never be quieter than an honest unsigned build, and a publisher should not be punished a rung for a lapsed certificate.pinned — a name, fixed so the wire value cannot be argued about later. Nothing in this build produces it.page and endpoint charms carry no tier at all and the field is absent: a page is HTML this hub serves and an endpoint answers HTTP somewhere we cannot see, so calling either “unsigned” would be a checked negative nobody checked. A proc whose peer cannot be resolved is left untiered: that lookup writes nothing at all — not the image path, not its digest, not a tier — so a charm nothing has ever resolved carries no tier rather than unsigned, and one that was resolved before keeps what that lookup found. Failing to determine is not evidence in either direction; the one-shot credential is withheld on exactly that answer instead (see How a charm receives its ticket).
What a tier does: it sets how loudly a grant is asked for at the console. It is never an input to the capability gate and never decides what an issued ticket may do.grantedScopes — what the operator has granted. Empty until they do, and stated as empty rather than omitted, so a client can tell “nothing granted” from “field missing”.requestedScopes — what the manifest asked for. Asking is not being granted; these two lists are answers to different questions and a client must not read one for the other.It never returns the registry's private half. The observed image path and its signer digest, the ticket digest, and the declined, banned and licensed lists are recorded against a registration and are not on this response under any name — licensedScopes in particular is not surfaced here or on any other route. This endpoint is what a charm is told about itself, not the record kept about it.
404 NOT_FOUND covers every “no such registration”: an id that is not registered, an id that could never have been registered (no dot, a path separator, empty), and a manifest file with no registration record that is on neither list — one somebody hand-wrote is not something that registered itself. Behind a revoked or ban list that cannot be read, describe cannot say what the list would say, so for an id the list could name it answers 500 REGISTRY_RECORD_REFUSED (the list is damaged and a person repairs it) or 503 REGISTRY_BUSY (ask again), never a status. A registration record that cannot be read is answered the same way, with or without a manifest file on disk: 503 REGISTRY_BUSY while the record, or the manifest file, is held by another process (ask again), and 500 REGISTRY_RECORD_REFUSED when the record is damaged or a newer build wrote it (a person repairs or removes it). That is also what keeps this prefix off its neighbors: /charms/pages and /charms/register are matched as static routes first, and neither name could be a registered id anyway.
An expand[] entry carries three separate things. placement is where the entry appears, action is what activating it does, and source is what fills a pixel surface. display is the legacy name for the first of the three: an entry that declares no placement takes the charm's own — rail-menu when the charm's display is action-menu, rail otherwise — which is why every shipping manifest loads unchanged.
placement | Where the entry appears | This build |
|---|---|---|
rail | The charm bar. Every shipping entry | rendered |
rail-menu | The charm bar, as a popover menu — the legacy action-menu | rendered |
menu | An item under MusicBee's Tools → MBXHub menu | rendered |
node | A navigator node | named only |
tab | A MusicBee main-panel tab | named only |
window | A desktop window, via the Shell | named only |
overlay | A surface in an overlay page | named only |
hotkey | A global hotkey | named only |
status | A status-area item | named only |
Named only means nothing renders it. The values exist so the axis is closed and a manifest can declare one today; no surface in this build draws them. Placements are compared case-insensitively, like every other manifest word, and never coerced: a value that is not on this list comes back verbatim and is skipped by whatever reads it, so an older build meets a manifest written for a newer one and renders the entries it knows.
source means something on tab, window and overlay only — a scheme plus a name, e.g. spout:<sender>. It is parsed and carried; nothing in this build fills a surface from it.
The menu placement. An approved charm's menu entries are asked for at the plugin's Initialise, one submenu per charm under Tools → MBXHub, named after the charm's label — its id when the label is blank. Admission is the same rule the charm bar uses, not a stricter one: a registration that is present must say active — pending and revoked get no item — and a manifest with no registration record gets its item, because it is a file in the charms folder of somebody's own machine and there is nobody else to ask. A charm on the revoked or ban list is not loaded, so it gets no item either. Turning on requireCharmRegistration removes the second case: strict mode gives an item only to an active registration. The status is re-checked at the click, under the same policy, against the charm as it is on disk at that moment, so a charm revoked after the menu was built is refused when its item is clicked and the reason is logged. MusicBee has no API for removing a menu item, so the item stays visible until MusicBee next starts — the click is what is gated, not the drawing.
Delivery, by kind. Activating an entry sends the charm a CharmActivated event by the route its kind has:
page — into the dashboard frame showing that charm. MBXHub hosts the HTML, so there is no socket to bind and no URL to call.proc — the socket bound to that charm when one is connected (see Binding a charm's socket under WebSocket Events); otherwise the manifest's declared endpoint.endpoint — the declared endpoint, which is the only route it has.A proc with neither a bound socket nor an endpoint is the one case where nothing can be done, and it is logged as a warning naming the gap. A menu item whose click reaches nothing is worse than no menu item; one that reaches nothing silently is worse still, because the person has nowhere to look.
What the endpoint POST is allowed to be. http or https only, and the host must be a private or loopback address — checked as a string and never resolved, because a name whose owner controls the answer is not a boundary. The event is the JSON body. The whole call is given 2000 ms — short on purpose, since this runs on the click — redirects are not followed (a LAN endpoint answering 302 would otherwise walk the call past the check that let it through), and the response body is never read: the status code is all delivery needs.
The launch action. An entry whose action is the bare word launch asks the hub to start the charm. The verb takes no target — one written after it is 400 INVALID_ACTION at registration. The manifest's top-level launch field is the primary target. An optional launchFallback string declares one second target in the same format. Both are shown on the Charm Manager's approval detail, and callers cannot substitute either target.
launchFallback only if the primary is unavailable (a missing executable or uninstalled scheme handler), or dispatch fails. Successful dispatch never starts both targets. Both follow the same policy, and any policy refusal ends the attempt: a fallback cannot bypass registration, observed-image, deny-list or allow-list checks. For example, launch: steam://run/4813240 with launchFallback: C:\Apps\Thing\thing.exe --from-mbxhub tries Steam before the local executable. The hub does not wait for an application window or retry after Windows accepts a scheme dispatch.active. pending has not been approved and revoked was approved and taken back; neither launches. A manifest with no registration record was never registered with anybody — it is a file somebody put in the charms folder of their own machine — and it may launch its declared executable or an allowed URL scheme. Scheme deny-list and allow-list checks still apply, and strict registration mode still refuses unregistered manifests.requireCharmRegistration in mbxhub.json, shipped as false. On, it refuses every charm that has no registration record — launch, menu item and charm bar alike — so nothing runs that did not register itself and get approved. It is a Security setting, so it is absent from GET /system/settings/schema, absent from the web settings page, and PUT /system/config refuses it with 403 SETTING_CONSOLE_ONLY. It is answered at the console, in MBXHub settings → API Access tab → “Require registration for charms”. Default off, so an install that has always kept its charms as local files keeps working exactly as it did.steam://run/… needs — minus a deny-list: javascript, vbscript, data, file, http, https, ms-msdt, ms-appinstaller, shell, search-ms, res, about, microsoft-edge, and every scheme beginning ms-. Those are script hosts, document handlers and shell verbs rather than app launchers; http and https are refused because a page is a webapp and this keeps launch meaning one thing; microsoft-edge is the http door one hop along, since the scheme takes a URL and hands it to a browser. That list lives in code, not in a setting — there is nothing to turn off, and it is the floor on every install. No vendor scheme is on it: vendor schemes are the case launch exists for, and they are the ones nobody can enumerate in advance.charmLaunchAllowedSchemes in mbxhub.json, shipped as *. * allows every scheme the deny-list has not already refused, which is what every install has always done, so upgrading changes nothing. A list of names allows only those: comma-, semicolon- or whitespace-separated, entries trimmed, a trailing : tolerated, and matched case-insensitively and exactly. There are no globs — the single value * is the wildcard; no other pattern is — so a per-install scheme such as FirefoxURL-<hash> must be written out by its full name once it has been narrowed to. An empty value allows no scheme at all. The deny-list is evaluated first and wins: naming a denied scheme here does not enable it, so an allow-list narrows what may launch and can never widen it. It is about schemes only — an executable target is unaffected by this list; registered charms still pass the observed-image gate.* the deny-list is still the only thing standing, exactly as before. It makes a deliberate install lockable — an operator who wants to name the two or three schemes their charms actually use, or none, can now say so. It is a Security setting, so it is absent from GET /system/settings/schema, absent from the web settings page, and PUT /system/config refuses it with 403 SETTING_CONSOLE_ONLY. It is answered at the console, in MBXHub settings → API Access tab → Remote Control → “Launch allow-list (schemes)”. A scheme the list does not name is refused on the Charm Manager's approval row before the charm is approved, and again in the log at the click — both name the list that refused it, so a denied scheme still says deny-list rather than sending somebody to edit a setting that would not help.Launch: steam://run/4813240 is approving that string. The Charm Manager's approval detail says so on the row — and says the target is refused instead when it is on the deny-list, so approving it starts nothing.A registration is not on a surface until the operator approves it. Registering writes a manifest into the same charms/ folder the charm surfaces read, so without this rule registering would put a button on somebody's dashboard that nobody said yes to. One list is built from that folder and every person-facing surface reads it — the dashboard charm bar, GET /charms/pages (the rail, the overlay compositor, the pop-out and the set editor) and the dashboard's extra-transport button — so what it excludes is excluded from all of them at once.
active renders, and only while its stamp verifies over the manifest file as it is on disk. A registration that waits for Approve is not drawn, and neither is one carrying a status nobody issued: an unrecognized value on a hand-editable record is not an approval either, and the open reading of it renders exactly the button this rule exists to keep off the screen.Warn. While either list cannot be read, no charm that has a record, a publisher or a dotted id is loaded, because nothing can say it is not on that list; the built-in charms still load.GET /charms/{id} still answers, and the Charm Manager still lists it — which is where the operator sees what is waiting on them. Visibility is not capability: nothing here decides what a charm may do, and nothing here deletes anything.charmBar.hidden and the ARiA toggle are decided against live settings each time a surface renders; registration status is the one decided at the load, because approval is not a toggle somebody flips mid-session.Every rebuild logs both counts at Debug — how many manifests are on disk and how many are renderable — so “my charm never showed up” is answered by the gap between them.
No ticket means today's behavior, on every route. This is the headline promise and it is the first thing the gate checks: a request carrying no X-MBXHub-Ticket header is dispatched exactly as it was before any of this existed. The scope table is not even consulted, so an unregistered caller cannot tell from any response that a gate exists. A ticket only ever adds — holding one never makes a route harder to reach than it is without one, and a route the table does not name answers a ticket holder exactly as it answers anybody else.
That holds for a ticket the hub no longer recognizes. The route is looked up before the credential is, so an ungated route is allowed without the ticket being resolved at all: a stale, revoked or simply wrong header answers exactly what no header answers. It is what keeps POST /charms/register reachable — that route is ungated, it is the documented way back after a revoke or a hub identity reset, and nobody should have to know to drop a header to use it. A gated route still refuses a ticket it does not recognize, so a junk header maps nothing.
How to present one. The header X-MBXHub-Ticket: <ticket>, and nowhere else. A ticket is 32 bytes from the OS CSPRNG in base64url with no padding — 43 characters. The hub stores only sha256: plus 64 hex digits of it, so a leaked registry yields nothing usable.
A ticket in the query string is refused even when it is valid, on every route gated or not, and refused before anything else looks at it: ?ticket=… answers 400 TICKET_IN_QUERY. Query strings land in server logs, browser history and Referer headers, and accepting one once teaches an integrator to keep doing it. There is no “we tolerated it this time”.
Tickets are loopback-scoped. A ticket presented from anywhere but the machine MBXHub runs on is 401 TICKET_NOT_LOCAL — answered before the ticket is resolved, so presenting a stolen ticket from across the network cannot even confirm that it is real. Checked where a decision is made from the ticket, which is on a gated route: on an ungated one a remote caller gets what it would get with no header, because that is what the route answers anyway.
The decision order, exactly as coded.
X-MBXHub-Verb header → the hub’s own verb request, judged as the charm that was pressed and not by the steps below. The value is spent first, whatever the route; a route not in the scope table is allowed; not loopback is 401 VERB_NOT_LOCAL; a value the hub did not mint, or already spent, or a charm that is no longer active on this hub, is 401 VERB_UNKNOWN; otherwise the banned-to-not-granted steps below, for that charm. A client never sends this header — see Verbs, the store and settings.active, or it was issued by a different hub install. An approval whose stamp does not verify over the manifest file as it is on disk is not active here, a charm on the revoked or ban list holds no ticket, and while either list cannot be read no ticket resolves.What the gate returns. state and capability are extra fields on the standard error envelope, and both are omitted from every response but a capability refusal, so no existing error shape moved. capability is always the scope the route required, never the list the charm holds — a refusal says what was needed, not what the record contains.
// 403 on a route the ticket holder has not been granted:
{
"success": false,
"error": {
"code": "CAPABILITY_NOT_GRANTED",
"message": "Capability 'aria:input' is not granted",
"state": "not-granted",
"capability": "aria:input"
}
}
| Status | Code | state | When |
|---|---|---|---|
400 | TICKET_IN_QUERY | — | A ticket parameter was in the URL. Send it in the header, never in the URL |
401 | TICKET_NOT_LOCAL | — | Tickets are valid from this machine only |
401 | TICKET_UNKNOWN | — | Unknown or revoked ticket. Unknown, not-yet-approved, revoked and issued-by-a-hub-that-no-longer-exists are one answer on purpose: each means “this credential opens nothing here”, and telling them apart would reveal which registrations exist on a machine the caller has not been enrolled into |
401 | VERB_NOT_LOCAL | — | A verb nonce is valid from this machine only. Only on a gated route reached with an X-MBXHub-Verb header |
401 | VERB_UNKNOWN | — | Unknown or spent verb nonce, or its charm is no longer active on this hub. Only on a gated route reached with an X-MBXHub-Verb header; the hub’s own verb request is the only one that carries a value it will honor |
403 | CAPABILITY_BANNED | banned | The operator banned this capability for this charm |
403 | CAPABILITY_RESERVED | reserved | The capability is reserved in this build; the message carries the reason |
403 | CAPABILITY_DECLINED | declined | The operator was asked and said no. A new version may ask again — which is the difference from never having asked |
403 | CAPABILITY_NOT_GRANTED | not-granted | Nothing has been issued for it. Some of these ask a person first — see The runtime prompt. When nobody could be asked, the message says so and names the Charm Manager |
A reset invalidates every ticket. Each registration is stamped with the hub install's id and the gate compares it against the running hub's, so a reset network setup — which mints a new hub id — means a ticket issued by the hub that used to live here stops opening doors. It is answered TICKET_UNKNOWN, along with everything else that opens nothing. Revoke stops a ticket by construction rather than by a check: revoking clears the stored digest, so the ticket resolves to nothing. Re-approval mints a new ticket; the revoked one is not recoverable. The way back is to be approved again, and the registration that collects the new credential — POST /charms/register — is ungated, so a revoked charm re-registers without dropping its old header. Keep sending it: it opens nothing and it costs nothing.
Some not-granted refusals ask a person before they are sent. The refusal is the default and the prompt is the exception, so build a client for the 403 and treat a Trust as a bonus.
When a prompt is raised. All four must hold:
proc. A page and an endpoint are never prompted over — a page is a file somebody with access to that machine put there, which is the same boundary registration draws, and an endpoint has no local image to have evidence about.unsigned or pinned, or it has no tier at all. Being signed and known buys quiet: verified and halrad-signed are refused silently. No tier is not a quiet answer, it is no answer — nothing observed that charm's image — so a proc carrying no recorded tier is asked about, exactly as an unsigned one is, for every outward-acting call. An unresolved caller is never trusted. A page carries no tier either and is still never prompted, because the kind above settles that before the tier is looked at.proxy:lan, bridge:internet, aria:input, automation:wake, automation:presets: the ones whose effect leaves MBXHub, by reaching another machine or by driving this one. Everything else is refused without interrupting anybody, because a modal over “this charm cannot change tags” is a nag, not consent.One capability per prompt, and the words are fixed: the tier leads the title, the sentence a person reads is the capability's own agreeing line verbatim — the same sentence they answer at the console — and the buttons are Not now (first, and the default) then Trust. A person should have to travel to grant something, never to decline it.
The call waits while the prompt is on screen, up to 30 seconds. That is deliberate: the person is being asked about this call, so this call is what their answer decides. Design for it — a POST /api/proxy from an unsigned charm can take half a minute the first time and milliseconds afterwards.
What Trust means, exactly. An in-memory allowance for the rest of this run of MusicBee, remembered against (charm id, capability, target). It is not a grant. Nothing is written to the registration, grantedScopes does not change, GET /charms/{id} looks identical afterwards, and it is gone when MusicBee closes. The durable answer is the operator's, in MBXHub settings → Charm Manager. It is also bound to the credential the record held when it was given — the key is (charm id, capability, target, ticket digest). Re-registering changes nothing by itself: the same id posted again keeps its record and its ticket digest, so the session's Trust still matches. What drops it is anything that replaces the credential — a revoke, a re-approval after one, a registration severed by a different hub, or a registration from a different executable. Each clears the ticket digest and mints a new one at the next approval, so an allowance given for the old credential stops matching. Keyed on the id alone, a Trust given before a revoke would silently re-grant the capability after a narrower re-approval that never mentioned it — a grant made by nobody.
A Trust cannot buy past a refusal. It is the weakest of the three authorities and sits below banned, reserved and declined in the order above. When somebody clicks Trust the gate runs the whole table again from scratch, re-reading the registration — so a ban, a decline or a revoke the operator records while the prompt is on screen is honored, and the answer to that call is the ban.
Not now is remembered wider than Trust. A decline is held per (charm, capability) across every target for the rest of the session, so a charm cannot re-ask by retrying against a different address. A Trust is held only for what was actually asked about.
For proxy:lan the answer covers the capability, not the device. A proxied call carries its destination in the request body, and the gate does not read bodies — the body belongs to the handler. So the prompt names the capability, the person answers about the capability, and one Trust covers every device that charm proxies to for the session. No prompt ever names a device it had to guess at.
Absence is never a yes. A timeout, a dismissal, a Shell that is not running, a prompt that could not be drawn — every one of them refuses. Only a click on Trust is a yes.
The prompt reaches one Shell, and by default it is the one on the hub's own machine. It travels plugin → Shell over loopback unless shellAddress names another machine and this hub is paired with the Shell at that host — in which case it is signed on the way out, and the answer has to be signed coming back or it is not a grant (see Signed pushes below). The rule this was written for is unchanged where it matters: a consent dialog is never relayed to a host this hub cannot authenticate, because a consent dialog that can be routed across a network is one that can be aimed at the wrong person. What changed is that “cannot authenticate” now has an answer other than “everything but loopback”. On a headless box with no Shell to reach — a jukebox running MusicBee with nobody in front of it and no paired Shell elsewhere — there is nobody to ask: that call is answered as though the person had said Not now, and the log says so at Info, naming the Charm Manager. Outward capabilities for unsigned charms on such a box are granted at the console, or on a paired Shell somewhere a person actually is.
Being unable to ask is not a decline — the two are the same answer only to the one call that could not be asked about, and nothing beyond it. When no Shell could show the prompt the question is held for sixty seconds and may then be asked again — a person saw nothing, so nothing was learned about what they want. Only an explicit Not now lasts the session. This is what stops MusicBee starting a few seconds before the Shell from refusing a capability for the rest of the day.
One prompt per question at a time. Concurrent refused calls from the same charm for the same capability raise one toast; the others wait for that answer and take it. N calls never means N dialogs.
The answer never arrives from the network. The plugin pushes the question to the Shell and reads the answer off the response to its own push, matched by a nonce it minted for that one prompt and accepts exactly once. There is no inbound route that grants anything. The Shell refuses a grant-prompt from anywhere but loopback with 403 PROMPT_NOT_LOCAL, one carrying no nonce with 400 PROMPT_NO_NONCE, and one naming no capability with 400 PROMPT_NO_CAPABILITY. Those live on the Shell's /meta/notify, not on this API, and no ordinary caller ever sees them.
Signed pushes (Secure Access Channel). A Shell that has been paired with a hub holds a shared secret, and will additionally accept a grant-prompt from off its machine — but only one carrying a valid X-MBXHub-SAC header: a nonce plus an HMAC-SHA256 over the exact request bytes, single-use inside a 30-second window. Every other way that can go — no header, a malformed one, a MAC under a retired secret, a nonce that is stale or already spent — is one refusal, 401 PROMPT_UNAUTHENTICATED; the log line says which, the wire does not. The MAC is verified before the nonce is spent, so a caller with no secret cannot burn a nonce a real push is about to present. A Shell that has never paired is unchanged and still answers 403 PROMPT_NOT_LOCAL, and a push from the local machine needs no signature either way. The nonce and capability checks above still run for every caller, signed or not.
On the hub's own machine there is nothing to pair. A Shell running beside the hub is paired at startup by the hub itself — nothing to type and no code to carry — and each install on the machine keeps its own pairing, kept by the hub’s REST port, so a second MusicBee under the same account pairs itself too and neither touches the other’s. A record the hub wrote itself that names this hub’s previous identity (the install folder was reset and the hub minted a new id; same port, same machine) is re-paired at the next start, not treated as another hub. A pairing a person made by hand is never overwritten by an automatic one. Pairing by hand is for a Shell somewhere else. Same machine means the same Windows account: the shared secret is kept in that account's own profile, so a Shell started under a different user — Run as different user, a service or kiosk login — is not covered and has to be paired by hand. Running elevated is fine; that keeps the same profile.
And the answer is signed too. A paired Shell puts a mac on the answer it sends back — an HMAC-SHA256 over exactly the prompt’s nonce and the answer word, under the same shared secret. Nothing else on that body is covered: status and reason are diagnostic, the plugin reads neither when it decides, and a field a decision cannot be built from must not be able to change the signature. The plugin requires that signature when its push left the machine, and accepts an unsigned answer over loopback — which is byte-for-byte what an unpaired install has always sent. An answer that is unsigned where one was required, signed under a retired key, signed for another prompt, or signed for a different word (a not-now replayed as a trust) is neither a refusal nor a grant: it counts as nobody was asked, and is held for sixty seconds rather than for the session. The signature is checked before the prompt’s nonce is spent, so a forged answer cannot burn the nonce of a prompt somebody is looking at.
Signed calls from a paired Shell (the other direction). The same secret lets a paired Shell act on three of this API's routes from off the hub's machine: POST /system/open-settings, POST /app/exit and POST /app/restart. The header is the same X-MBXHub-SAC: a nonce plus an HMAC-SHA256 over the request as sent — the method, the path exactly as it appears in the URL, and the body, which on all three of these is empty — single-use inside a 30-second window, and the MAC is verified before the nonce is spent. A signature that is presented and does not hold up is 403 SAC_INVALID — one code for every way that can happen: malformed, wrong key, stale nonce, replay, or a hub that holds no secret to check it against. Presenting the header is a claim to be the paired Shell, and a claim this hub cannot make good on is refused rather than handed on to the route's ordinary rules. On a paired hub, presenting nothing where nothing else lets you in is 403 SAC_REQUIRED.
A caller that sends no signature to a hub that has never paired is untouched by any of this. It gets each route's own rule, exactly as it did before the Secure Access Channel existed — including the 403 FORBIDDEN a remote /system/open-settings has always answered, and the optional REST password challenge on all three. Once a hub holds a pairing (a stock install pairs its own Shell at startup), a signature-less remote /system/open-settings is 403 SAC_REQUIRED instead; /app/exit and /app/restart keep their own rules. That is what “an unpaired install behaves as it always did” means, and it is the promise the paragraph below is scoped by.
What a paired Shell may do, and what it still may not. On /app/exit and /app/restart a valid signature stands in for the X-MBXHub-Local handshake header and for nothing else: a Shell on another machine still needs allowRemoteExit, which is the operator's switch and is not widened by pairing, and the Disable Features App Restart toggle still blocks every caller. Pairing authenticates who is calling; it does not enlarge what callers may do.
Basic auth and a signature. On those three routes and on the Shell’s cast status push (POST /system/events/CastStatus, see Cast) — and only those four — a request carrying an X-MBXHub-SAC header is not challenged for the optional REST password. The signature is judged instead, by the route's own gate, and anything short of a valid one is 403 SAC_INVALID rather than a 401: presenting the header replaces the password check, so it must satisfy something. (The cast status push has one difference: on a hub that would have let the push in with no header at all, a signature that does not verify is fanned out like an unsigned push and changes nothing, since a refusal there protects nothing.) The party redirect stands aside for one of them only: during a party, a remote cast status push carrying the header reaches its handler instead of the guest page, so a Shell on another machine can still report a cast; the other three stay behind the redirect, as they always have. A valid MAC is the stronger of the two credentials this product issues — a 256-bit shared secret proving possession per request, against a password typed into a browser. It widens nothing else: on every other route a signature is ignored and the password gate answers as it always has. The pairing route is not one of these — its caller does not hold the secret yet, which is what it is asking for, so nothing there could verify a header. What opens that route is a pairing window a person armed at the console; outside one it is challenged like any other non-local call.
How a Shell becomes paired. Somebody clicks Pair a Shell in the MBXHub settings dialog on the hub's own machine, and the dialog shows a ten-character code in two groups of five. Typed into the Shell within five minutes, that code is redeemed at POST /system/sac/pair and the hub answers with the shared secret and its hubId, once. That click is the whole of the console requirement — the endpoint is closed by default, on every install, and nothing on this API can open it: no route arms a window, and none will be added. The window is good for five minutes, for one redemption, and for five wrong codes before it closes itself. SAC_PAIRING_CLOSED means no window is open, SAC_PAIRING_REFUSED means a window is open and that was not the code, and when no window is armed the optional REST password stands in front of the route like any other non-local call. The 32-byte secret crosses the network exactly once, in that one response, in the clear. There is no TLS here and the design says so: a five-minute window a person opened by hand, one use, five attempts, and somebody standing at the dialog while it happens is what makes the trade honest. Every attempt is logged with the caller's address — a pairing from an address the operator does not recognize is the line worth finding — and neither the code nor the secret is written to the log. A Shell on the hub's own machine never uses any of this: it is paired without anybody typing anything. One secret per hub. Pairing replaces any previous pairing — a Shell paired earlier will need to be paired again (a Shell on the hub's own machine is re-paired automatically).
Pairing, click by click. It takes two consoles and about a minute. On the hub's machine: MBXHub settings → Network Status tab → the Pairing box. The status line there says who is paired now — the address comes from the hub's own record of the pairing, so an install that predates that record says paired and names nobody rather than guessing. Click Pair a Shell; a ten-character code appears in two groups of five with the five minutes counting down beside it, and Cancel shuts the window early. On the other machine: the MBXHub Shell's tray icon → Settings → Network setup → Pair with hub…. That dialog carries the hub's address as well as the code box, because pointing the Shell at the jukebox is usually the same errand; an edited address is saved to mbxhub-shell.json before the code is sent. Type the code — the hyphen, the case and Crockford's O/0 and I/l/1 are all forgiven — and the dialog answers in words: paired, wrong code, no window open at the hub, or could not reach it. Unpair exists on both sides and they are not the same act: on the hub it drops the hub's key and its record, and a Shell paired from another machine stops working immediately (the one on the hub's own machine re-pairs itself at next start); in the Shell's tray it clears only that Shell's half, and the hub keeps its key until somebody unpairs it there. A Shell cannot unpair a hub over the network, deliberately. And nothing tells a remote Shell it has been unpaired — its status line goes on saying paired until somebody clears it there or pairs it again. That is the known cost of a deliberate omission: a dead secret already fails closed on every exchange, so a notification would buy nothing but a tidier line, and it would cost an authenticated remote-state-change route — the exact shape this design exists to avoid.
Which Shell gets pushed to. By default, the one on the hub's own machine — 127.0.0.1 at the Shell’s own port (smtc.port in mbxhub-shell.json, default 8081, read from that file; the REST port plus one only when the Shell has never written its config). A hub whose Shell runs on another machine names it with the shellAddress setting, as host:port. An address that is not this machine is used ONLY when it is the Shell this hub is paired with — the hub's own pairing record has to name that host, and the key behind it has to be readable. Not merely “a Shell is paired somewhere”: a hub whose Shell runs on its own machine is paired from startup with nobody typing anything, so that weaker test is true on almost every install and would wave through an address naming any host on the network. And the key as well as the record, because a record without a readable key would send the push off-machine unsigned. Otherwise the plugin does not push off-machine at all: nothing is sent, a runtime grant prompt answers not asked — never a grant, never a decline held for the session — and one line goes to the log saying which of the three it was: no pairing at all, paired with a different host (it names both), or the key could not be read. The reason is the grant prompt itself: it asks a person whether one charm may act, and it is answered over the response to our own request, so pointing it at a host this hub is not paired with would put the charm, the capability and the target on the wire in the clear and let whoever answers there decide what software on this machine may do. Loopback is honored regardless, in every spelling (127.0.0.1, localhost, [::1]) — a Shell on this machine is already inside the boundary. A DNS name is treated as off-machine without being resolved: nothing on this path performs a lookup, because a security decision that consults DNS lets whatever answers for a name decide what counts as local. So localhost is honored and myhost needs the pairing, even if it points at this very machine. An address this hub cannot read falls back to the local Shell and is logged once, so a typo costs the address that was typed and not the notifications. It is a Security setting, so it is absent from GET /system/settings/schema, absent from the web settings page, and PUT /system/config refuses it with 403 SETTING_CONSOLE_ONLY; it is answered at the console, in MBXHub settings → Network Status tab, beside the pairing box. A setting that redirects where a consent question is asked must not be reachable by anything that would like a different answer.
Prompts can be turned off, at the console: MBXHub settings → Charm Manager tab → “Show runtime prompts”, on by default; the setting key is runtimePromptsEnabled, which is how the log names it. It is a Security setting, so it is absent from GET /system/settings/schema, absent from the web settings page, and PUT /system/config refuses it with 403 SETTING_CONSOLE_ONLY — a switch that decides whether a person is consulted must not be reachable by the software that wants their answer. Off means every would-be prompt answers not now, silently: the call gets the same 403 CAPABILITY_NOT_GRANTED it would have got from a person clicking Not now, nothing is granted, and nothing is declined for the session. It is held as an absence, with the reason prompts-off, on the same sixty-second cooldown as “nobody could be asked” — so turning prompts back on lets the question reach a person again within the minute, with nothing restarted. The log carries an Info line per (charm, capability) per cooldown — not one per request — naming the setting and the Charm Manager. What a charm may do is the same either way: the switch sets how loud MBXHub is, never what is granted.
Two demo buttons sit beside that switch — Demo: quiet and Demo: prompt. Both run the real policy and the real push, for a demo charm demo.mbxhub.prompt asking for proxy:lan; the only difference between them is the tier: quiet runs as verified, prompt runs as unsigned. The status line names the decision and the reason — verified: no prompt — decided from grants: not granted, unsigned: prompted — Trust or … — Not now, unsigned: would prompt — prompts are off, answered not now, unsigned: would prompt — no Shell to show it, answered not now. The answer is discarded: no runtime allowance is recorded, no registration is created, and nothing is written to any record. Clicking Trust in a demo grants nothing to anything.
Which routes need which capability. It lives in code, not in configuration — a security control that can be changed over the network is not a security control — and it is consulted once per request, in front of dispatch, so no handler knows it is gated and none can be edited into being ungated. Fifteen rows as shipped.
The rule: exact rows are answered before any prefix row, and among matching prefix rows the longest prefix decides. Both are properties of the lookup rather than of how the rows happen to be listed — rewriting the table into a different order must not change a single answer. Paths are matched case-insensitively, and a row that lists no verbs covers every verb.
| Path | Match | Verbs | Capability |
|---|---|---|---|
/api/proxy | prefix | any | proxy:lan |
/aria/ | prefix | any | aria:input |
/aria/status | prefix | any | automation:status |
/aria/wake | prefix | any | automation:wake |
/aria/scan | prefix | any | automation:scan |
/aria/presets | prefix | any | automation:presets |
/aria/programs | prefix | any | automation:presets |
/aria/preset/ | prefix | any | automation:presets |
/library/ | prefix | PUT, POST, DELETE, PATCH | library:write |
/library/artwork/batch | exact | any | open — not gated |
/library/files/batch | exact | any | open — not gated |
/library/find-device-ids | exact | any | open — not gated |
/library/sync-delta | exact | any | open — not gated |
/audio/tap | prefix | any | audio:tap |
/charms/{id}/store | prefix /charms/ + one segment + /store | GET, PUT | store |
ARiA is not one thing. Asking whether automation is available, waking the machine, kicking off a scan and running a routine the operator wrote are each their own answer; typing and clicking (aria:input) is the one that is everything. Running a saved routine is automation:presets — the same answer a person gave for the two lists — because making it need aria:input would charge a charm the whole keyboard to press one button. The /aria/ catch-all is written above the six longer rows deliberately: source order and prefix length disagree there, so the longest-prefix rule is exercised rather than assumed.
Reads under /library/ stay open; changing what is on disk does not. Four POSTs are stated as exact open rows rather than left to omission, so the exception is visible to whoever next reads the table: /library/artwork/batch, /library/files/batch, /library/find-device-ids and /library/sync-delta POST because their input is too big for a query string, not because they write anything.
/audio/tap is gated although no route serves it. Reserved means nobody is issued the capability; it does not mean the path is open. The gate exists before the surface does, deliberately.
The store row matches one charm id and nothing else: /charms/<one segment>/store for GET and PUT. /charms/{id}/launch, /charms/{id}, /charms/register and a store path with a POST or DELETE are not gated by it. The gate checks the ticket holds store; the store route then checks the ticket is the path’s own charm.
A publisher can carry signed grants in the manifest's grants[], so a capability is open to their charm without the operator granting it by hand. No production signing key is pinned in this build. The consequence, stated plainly so nobody has to discover it: every grant presented to this build is refused as an unknown keyId, and the licensed set is always empty, until the operator pins one. A build that accepted grants it could not check would be worse than one that accepts none. MBXHub verifies; it never issues — there is no signing code in the product and there is not meant to be.
base64url(payload) "." base64url(signature) — exactly two parts, at most 8192 characters. Plain base64 (a +, / or =) is refused as not base64url, so there is exactly one encoding a grant can be in and no chance of two producers disagreeing about which bytes were signed.publisher, class (one capability scope, from the same vocabulary GET /charms/capabilities states), expiry (a date — a bare instant is read as UTC rather than as the box's local time, and an offset is normalized), and keyId.r followed by s, 64 bytes for P-256. A DER-encoded signature — what most signing tools emit by default — is refused as invalid, so an issuer must convert.keyId selects the pinned public half; an unknown one refuses. The grant's publisher must equal the manifest's, or it is not this charm's to present — otherwise one leaked blob licenses every charm on every hub that ever sees it. A class this build does not know licenses nothing. Every refusal is logged with a reason and none of them fails the registration: a charm that registered correctly is registered, just not licensed. Two grants for one class is a renewal, and the later expiry wins.GET /charms/{id} and not on any other route. A revoke does not clear it — it is not the operator's to take back, and it is recomputed from what the charm next presents. Nothing leaks by leaving it, because a revoked record fails the gate at the status check, before any capability is considered.The dashboard, the HUD and play.html will frame a charm's page and perform actions on its behalf, and what they lend is deliberately narrow. No identity and no ticket ever reaches a browser: tickets are issued and consumed server-side, and no host page holds one, forwards one, or is told one exists.
Only open verbs, and only on the routes paired with them: playNow (POST /queue/playnow), queueNext (POST /queue/add), queueLast (POST /queue/add) and playAll (POST /queue/clear, POST /queue/add, POST /queue/play). An action the list does not name, or a route the list does not pair with that action, is refused. A target that is not a plain rooted path — an absolute URL, a protocol-relative one, anything carrying .. — cannot be compared against the list honestly, so it is simply not on it.
A charm's post <path> is wider, but not unlimited. The rail, charm-group buttons and charm-menu items name a path in the manifest and the page performs it with the page's standing. The page may be a local caller; the charm's author is not. So the routes the hub answers differently — or only — for a caller on its own machine are refused outright, and so is anything beneath them: /app/exit, /app/restart, /system/open-settings, /system/shell-bridge, /system/config, /system/settings, /system/default-page, /system/theme, /charms/register, /queue/projection, /partymode/start, /system/events, /rpc, and the launch route /charms/{id}/launch. The hub refuses the same list for a registered charm’s verb, which it performs itself (see Verbs, the store and settings). A target that cannot be shown to be a plain rooted path on this origin is refused; a URL somewhere else entirely is not the hub's authority to lend, so it is left alone. Scope-table routes stay reachable this way — a charm legitimately posts /aria/*, and a host page is a no-ticket caller, so the gate never gates it.
A registered charm’s post entry is not performed by the page. The dashboard, the HUD and the Charm Bar press it through POST /charms/{id}/launch?verb=<n>, so it runs as the charm and needs the verbs grant; without the grant the press shows the hub’s refusal rather than running with the page’s standing. A hand-placed manifest with no registration is still performed by the page, as above.
MBXHub settings → the Charm Manager tab. That is the only place approve, revoke, ban, unban, un-revoke, decline and dismiss happen. There is no REST route, no web page and no remote path for any of them: a registration cannot approve itself, and it cannot un-ban itself either. The tab lists every application that has registered with this hub, including a record whose manifest file is gone, and what each one may do, and for every capability it shows the word for where that capability stands — granted, reserved, not issued, banned or declined.
revoked, the ticket digest gone, every grant released, and any approval whose plaintext was never collected discarded with it. The record stays — a revoke is not a delete, and the id stays claimed by its publisher. It also writes a line to the revoked list (publisher, id, and the hash of the manifest it revoked): a revoked charm is not loaded anywhere, it stays revoked through a hub id reset, and a new manifest for its id (new bytes, a new version included) waits for Approve rather than loading. A charm MBXHub ships stays revoked at any bytes, so an upgrade does not undo the operator’s Revoke.POST /charms/register answers 403 CHARM_BANNED for it, and a manifest file for it placed by hand is not loaded. It is refused, with the reason on the row, for a charm that has no publisher to ban.verbs: POST /autoq/dice, or verbs: none) and, once the charm holds settings, its settings with a Save settings button that writes only what changed. Before that the row says how many settings are declared and that they are shown once the charm holds the grant.Three capabilities a charm asks for in its own name: verbs (a press posts to a hub route the manifest names), store (a small JSON document the hub keeps for it) and settings (knobs the hub draws on the web settings page and in the Charm Manager). They are one word each, not area:verb, and they are granted like any other scope: asked for in scopes with a sentence in scopeReasons, approved in the Charm Manager. All three are lab. No charm MBXHub ships uses them: an in-box charm’s post entry (the Dice’s Roll button, ARiA’s entries) is posted by the page itself, with no grant.
A verb is an expand[] entry whose action is post <path> (post in lower case, one space). It may carry body (a JSON object the press sends; {} when absent), say (one line, at most 200 characters, shown after a press that succeeded; {key} is filled from the route’s data, one level deep) and confirm (one line, at most 200 characters; the surface asks it, and the second tap within 3 seconds sends). Registration refuses a malformed field, naming the verb: 400 VERB_SAY_INVALID, 400 VERB_BODY_INVALID, 400 VERB_CONFIRM_INVALID.
A verb runs as the charm. Pressed through POST /charms/{id}/launch, a verb is performed by the hub: it POSTs the path to its own REST port over loopback, with the verb’s body, and the scope gate judges that request against the charm’s grants — never the standing of the page or the person who pressed it. The press needs a registration that is active, the verbs grant and a path the hub allows, or it is 403 LAUNCH_REFUSED with the reason.
X-MBXHub-Verb is internal. The hub mints a single-use value for each press (good for 15 seconds) and puts it on its own loopback POST; the gate spends it before the route runs and resolves it to the charm. A client never sends it, and a charm never sees it: the charm learns what its verb did from the launch answer. A value the hub did not mint opens nothing — on a gated route it is 401 VERB_UNKNOWN, from another machine 401 VERB_NOT_LOCAL, and an ungated route answers as it answers anybody. The store routes refuse a request carrying it (403 STORE_NOT_GRANTED): a verb is never the console.
Which paths a verb may name. A plain rooted path on this hub, shorter than 512 characters, with no ?, #, \, control character, . or .. segment, //, or encoded backslash; /api/v1/ is stripped and case is ignored for the comparisons. Refused, with anything beneath them: /app/exit, /app/restart, /system/open-settings, /system/shell-bridge, /system/config, /system/settings, /system/default-page, /system/theme, /charms/register, /queue/projection, /partymode/start, /system/events, /rpc, and /charms/{id}/launch. These are routes the hub answers differently for a caller on its own machine, and a verb arrives from that machine. Judged at the press, not at registration: a refused path is a 403 LAUNCH_REFUSED naming the rule.
The launch answer for a verb is 200 whatever the route said, because the press happened. status is the route’s own HTTP status; data is the route’s data (the hub’s envelope unwrapped; any other JSON relayed whole) and error its error; an answer that is not JSON is error.code VERB_ANSWER_UNREADABLE. say is filled only when status is 2xx. The hub waits up to 10 seconds for its own route.
// POST /charms/{id}/launch, a registered charm whose verb posts /autoq/dice
{
"success": true,
"data": {
"id": "<id>",
"launchKind": "post",
"status": 200,
"data": { "album": "Blue", "artist": "Joni Mitchell", "tracks": 10 },
"say": "Blue - Joni Mitchell"
}
}
| Status | Code | When (beside the launch route’s own refusals) |
|---|---|---|
400 | VERB_INDEX_INVALID | ?verb= out of range or not a whole number |
409 | VERB_IN_FLIGHT | This charm’s last press is still running |
502 | VERB_UNREACHABLE | The hub could not reach its own route in time |
503 | VERB_UNAVAILABLE | This hub cannot perform verbs |
400 | VERB_BODY_INVALID | The body could not be built; a second check behind the registration one |
?verb=<n> picks the verb, by position: 0-based, counting only the post entries, in manifest order. Without it a press starts the charm’s launch target when it declares one, else performs its first verb. It cannot name a path. A changed verb takes the approval back, like any changed byte of the manifest; see Any changed byte of the manifest under POST /charms/register above.
The charm’s store: one JSON object per charm, at most 65536 bytes of UTF-8, written atomically. GET answers { "success": true, "data": <the document> }, with {} when there is none. PUT writes the request body and answers { "id", "bytes" }: a charm holding settings replaces the whole document, and for a charm without it the hub-written settings member is kept and the body’s own settings member dropped. This machine only.
With a ticket: the scope gate requires store (403 CAPABILITY_NOT_GRANTED, capability: "store", otherwise) and the ticket opens its own charm’s store only. With no ticket (the console — a local process, or a page on this machine): the charm must be registered, active on this hub, not banned, reserved or declined for store, and hold store (granted, or licensed).
The store’s settings member is where the hub keeps the charm’s setting values. A PUT keeps the settings member the hub wrote (the operator’s choices) unless your charm holds the settings scope; a charm that holds it writes the whole document. On the keep path the hub stores every member value exactly as you sent it, byte for byte, with nothing re-escaped (an emoji stays its four bytes), drops your body’s own settings member and adds back the one on file. So the stored document, which is what the 65536-byte limit and the answer’s bytes measure, is the members you sent (the whitespace between them aside) plus the kept settings member. Member names are written by the hub’s JSON writer, which may escape a few characters in a name, a character outside the Basic Multilingual Plane among them.
| Status | Code | When |
|---|---|---|
403 | API_READ_ONLY | PUT only: the hub’s read-only mode or a Read API key, refused before the body is read, as /charms/register refuses it |
403 | STORE_NOT_GRANTED | Another machine; another charm’s ticket; the console for a charm without the grant; a verb |
404 | CHARM_NOT_FOUND | No registration |
500 | REGISTRY_RECORD_REFUSED | The registration record is damaged or a newer build’s; nothing was changed, and a person has to repair or remove it on this hub |
503 | REGISTRY_BUSY | The registration could not be read just now; try again |
413 | STORE_TOO_LARGE | Over 65536 bytes of UTF-8 |
400 | STORE_INVALID_JSON | Not one JSON object |
500 | STORE_WRITE_FAILED | The write failed; the previous document stands |
500 | STORE_READ_FAILED | The document could not be read; nothing was changed |
Settings are a top-level settings[], each entry { key, type, default, options, min, max, step, description, tier }. key: one lower-case letter, then up to 39 letters or digits; unique; never settings. type: bool, int, double, string or options. options (the choices) is read for options only; min, max and step for int and double only. default is required and must be a value of the type. description is shown beside the control. tier: basic, advanced or dangerous; absent is basic. A manifest that declares settings[] must ask for the settings scope. Registration refuses the first fault, naming the key: 400 SETTING_KEY_INVALID, 400 SETTING_TYPE_UNKNOWN, 400 SETTING_TIER_UNKNOWN, 400 SETTING_RANGE_INVALID, 400 SETTING_DEFAULT_INVALID, then 400 SETTINGS_SCOPE_MISSING. SETTING_RANGE_INVALID is an int or double setting whose min, max or step is NaN, infinite, or of magnitude 7.9e28 or more; the Charm Manager clamps every other number into what its editor can hold.
Where settings appear. Only for a charm whose registration is active on this hub, not banned, reserved or declined for settings, and holding settings (granted, or licensed). Then GET /system/settings/schema lists one row per setting, keyed charm.<id>.<key>, the web settings page draws them, and the Charm Manager row draws them with a Save button. The category they are drawn under is the charm’s own label when that label is plain ASCII (letters, digits, space, ., _, -) and, compared ignoring case, matches no hub or console-only settings category, no Object.prototype member name and no other settings-holding charm’s label; otherwise it is the charm’s id.
Writing them: PUT /system/config with charm.<id>.<key> keys. They go to that charm’s store, under settings, and never into mbxhub.json; a request holding only charm keys does not rewrite the settings file. Values are judged strictly by JSON kind: true/false for bool; a whole number within min/max for int; a finite number within min/max for double; a string for string; exactly one of the options for options. 400 CHARM_SETTING_UNDECLARED (no charm holding the settings grant declares that key — a charm without the grant declares nothing here) and 400 CHARM_SETTING_INVALID (not a value the setting accepts) name the keys and apply nothing. Store failures answer as the store does: 500 STORE_READ_FAILED, 413 STORE_TOO_LARGE, 404 CHARM_NOT_FOUND, 500 STORE_WRITE_FAILED. In a request that mixes charm keys and hub keys the charm keys are written first; if the hub half then fails, the answer names the charm keys that were saved.
Settings in a verb’s body. {settings.<key>} inside a string value of a verb’s body is replaced at the press with the stored value when the setting accepts it, else the setting’s default (a bool as true/false, a number as written). The replacement stays a string, so it cannot change the body’s shape. A key the charm does not declare becomes empty. A charm without the settings grant gets its manifest defaults, and the verb still fires.
A charm MBXHub ships. A manifest inside the signed plugin that declares scopes is approved at first start with every scope it declares except one the operator banned or declined for it: nobody is asked to approve a charm MBXHub ships. It can be revoked or banned in the Charm Manager like any charm and stays that way across upgrades; after Un-revoke or Unban the next MusicBee start approves it again. An upgrade that changes it is approved on the start that brings it, a file somebody edited by hand is not approved, a hub id reset is followed by the same approval under the new id, and POST /charms/register for its id answers 409 ID_CLAIMED. No charm this build ships declares scopes.
Each charm is a .json file in the charms/ folder inside the MBXHub data directory.
MBXHub seeds built-in charms (mixer.json, browse.json) on first run, among them the Dice (dice.json): a button that opens /pages/dice.html and a Roll button that posts /autoq/dice, the way ARiA’s entries post theirs.
Simple charm (single button):
{
"id": "my-charm",
"icon": "\uD83D\uDD0A",
"label": "My Charm",
"action": "webapp /pages/my-charm.html",
"display": "both",
"msg": "My Charm"
}
Expand charm (grouped buttons, e.g. Mixer):
{
"id": "mixer",
"label": "Mixer",
"expand": [
{ "icon": "\uD83C\uDFA8", "label": "Mixer", "action": "webapp /pages/mixer.html", "display": "both" },
{ "icon": "+", "label": "Volume Up", "action": "iframe-cmd volumeUp", "msg": "Vol +" },
{ "icon": "\u2013", "label": "Volume Down", "action": "iframe-cmd volumeDown", "msg": "Vol \u2013" },
{ "icon": "\uD83D\uDD07", "label": "Mute", "action": "iframe-cmd toggleMute", "msg": "Mute" }
]
}
id — Unique identifier (matches filename without extension).icon — Emoji or character displayed on the button.label — Tooltip and display name.action — What happens on click:
webapp /path — Opens an HTML page (standalone tab or inline iframe).iframe-cmd <command> — Sends a postMessage command to the charm iframe. The iframe webapp listens for { charmCmd: "command" } messages. If the iframe isn’t loaded yet, the dashboard auto-loads it from the sibling webapp action.http://... — Fires an HTTP request. Same-origin requests go direct; cross-origin LAN requests are routed through /api/proxy automatically.post <path> — POSTs to a route on this hub. An in-box charm’s entry (the Dice’s Roll button, ARiA’s entries) is posted by the page itself, with no body and no grant. For a registered charm this is a verb: the hub performs it as the charm, under the verbs grant — see Verbs, the store and settings.body, say, confirm — On a post <path> entry: the JSON object the press sends, the line shown after it succeeds, and the question asked before it. See Verbs, the store and settings.display — How webapp charms open:
standalone — Always opens in a new tab.inline — Always opens in an iframe below the charm bar.both — Click opens inline; Shift+click opens standalone.action-menu — Single trigger + popover of expand[] items (e.g. ARiA presets). Popover auto-orients above/below trigger; keyboard nav and Esc-to-close. Its items take one verb, post <path> — the path is POSTed to this hub with an empty body. A bare path with no verb still works and warns that it is deprecated, so a hand-edited charm does not stop working the day the grammar arrives. Anything else — webapp, iframe-cmd, an http:// URL — is ignored with a warning by an action-menu item; those verbs belong on the charm button and on expand[] items rendered as a button row. The shipped aria charm uses this mode, and both its seeded entries and the entries merged from your ARiA presets are written in the post <path> form.msg — Status message shown after execution.context — Optional. library-only or stream-only to restrict when the charm appears.expand — Optional array of sub-actions. Each sub-action has icon, label, action, display, and msg. Renders as a grouped button row with a connection status indicator. An entry may also carry placement (where it appears) and source (what fills a pixel surface) — see Placements, and how an activation reaches a charm. An entry with no placement takes the charm's own, which is what every shipping manifest does.A manifest may also carry the fields below. They describe the charm to the hub; the shipping charms declare none of them and are unaffected.
schemaVersion — Manifest schema version, an integer. Absent means 1, which is what every shipping charm is; a registration is stored with the value filled in.kind — The charm's substrate: where its code lives, and therefore what evidence about it can exist.
page — HTML/JS that MBXHub hosts and renders. Everything shipped is this.proc — A local executable. The only substrate with an image to sign.endpoint — Answers HTTP somewhere else; no local image exists.page. A value that is none of the three is reported back verbatim rather than coerced, so nothing is ever described as something it is not. kind is not the same axis as the verbs in action: those say where a charm appears, this says where its code lives.publisher — Who published the charm. Free text, echoed back by GET /charms/{id}.version — The charm's own version. Free text; the hub does not compare or order it.scopes — The capabilities the charm asks for, as an array of strings, named from GET /charms/capabilities. This is a request and nothing more — it grants nothing, and it is read back as requestedScopes beside the separate grantedScopes list. An entry this build does not know is 400 UNKNOWN_SCOPE and nothing is written. What is still asked for here is also what a grant survives on: see the pruning rule above.scopeReasons — One sentence per scope, in your words, saying why you want it: an object keyed by scope name. Shown verbatim beside the capability’s name on the Charm Manager row and on the runtime prompt, labeled They say:. One line, at most 200 characters, trimmed. Declare schemaVersion: 2 and give every scope its sentence — that is how a charm written to this document registers; version 1 is tolerated for manifests that predate the field, and its rows read no reason given. Refusals: SCOPE_RATIONALE_MISSING (version 2 and up), SCOPE_RATIONALE_INVALID and SCOPE_RATIONALE_UNKNOWN (every version). Rewording one, like any change to the manifest, sends an approved charm back to waiting for Approve.grants — Signed license grants the charm carries, as an array of base64url(payload) "." base64url(signature) blobs. The caller's word, like everything else in the manifest proper: what a grant actually earns is decided by verifying it, and the verified answer is written into the hub-owned half. Presented on every registration — there is no stored copy to fall back on. See License grants above, including the fact that no signing key is pinned in this build.settings — The knobs the charm offers, an array of { key, type, default, options, min, max, step, description, tier }. Needs the settings scope; shown and written only while the charm holds it. See Verbs, the store and settings.endpoint — Where an endpoint charm answers, and where surface events are POSTed to it.launch — What to launch for a proc charm: an executable or a URL scheme. Absent is normal — an application whose own launcher starts it is still a proc. This is the primary target for the launch action; the rules are under Placements, and how an activation reaches a charm.launchFallback — Optional second executable or URL scheme, in the same string format as launch. Tried only when the primary is unavailable or dispatch fails, subject to the same policy. Both targets are shown before approval; changing either target on an active registration requires approval again.registration — Hub-owned, not yours, and not in the manifest. What the registry keeps about a charm lives in its record, charms/registrations/<id>.json, beside the manifest file and never inside it: its own schemaVersion (the record's, not the manifest's), the charmId, the publisher, the status, when it registered, which hub install it registered with, the observed image and its signer digest, the trust tier, the ticket digest and whether it was collected, the manifest hash and the approval stamp over it, the granted scopes, the declined and banned lists, and the licensed scopes with their expiries. A registration member in a manifest is ignored, whether a request body sent it or somebody typed it into the file — anything a caller may say about itself belongs in the manifest proper.Charm bar order, visibility, sizing, and per-charm overrides are stored in charmBar in mbxhub.json:
{
"charmBar": {
"order": ["mixer"],
"hidden": [],
"buttonSize": "M",
"sizeOverrides": { "mixer": "XL" },
"breakBefore": ["mixer"],
"displayOverrides": { "mixer": "inline" }
}
}
order — Charm IDs in display order. New charms not in the list are appended.hidden — Charm IDs to hide from the dashboard.buttonSize — Bar-level button size preset: S (36px), M (44px, default), L (52px), XL (64px), XXL (76px). S and M were bumped up in v0.5.2.1 (was 32/40); XL and XXL were added in v0.5.2.3 for comfortable touch on high-density displays (11.6″ FHD @ 150% DPI needs ~76px to hit a 0.6″ physical target).sizeOverrides — Per-charm size overrides (charm ID → S/M/L/XL/XXL). Individual charms can break from the bar-level size; the override wins via CSS specificity.breakBefore — Array of charm IDs that force a new row in the charm bar. Combined with sizeOverrides, one charm (e.g. the mixer) can sit on its own XL row while the rest stay compact.rail — Legacy desktop charm selection/order; empty means all eligible charms. Modern editors use charmBar.presentation, with independent dashboardOrder/desktopOrder/hudViewsOrder/hudOrder lists and matching Overrides maps. Override values are show/hide; removing an override follows the charm default. All desktop items, including MusicBee, HUD, saved sets and launchers, can be hidden and reordered in Settings → Charms. Existing settings migrate once. Legacy writes remain supported; mixing legacy visibility/order keys with new presentation keys in a request is rejected.GET /system/settings/schema supplies charmPresentation.hudViews rows. Save their item keys, such as charm:dice.overlay.2, in charmBar.presentation.hudViewsOrder and hudViewsOverrides through PUT /system/config. GET /charms/pages?placement=overlay applies these choices, and the HUD refreshes automatically. Hidden rows remain editable; Library stays built in. Service tiles retain hudOrder/hudOverrides.displayOverrides — Per-charm display mode (charm ID → inline or standalone). Overrides the charm manifest's default.The charm bar also appears as “Charm Bar” in the Dashboard Layout panel ordering, so it can be repositioned or collapsed like any other section.
Optional. The plugin (mb_MBXHub.dll) is fully functional on its own — REST, WebSocket, dashboard, AutoQ, charms, and discovery all work without the Shell. The Shell adds Windows-side conveniences: SMTC integration, a system-tray UI for switching SMTC targets and opening dashboards across the fleet, Windows app identity (AUMID, Start Menu shortcut), and a firewall helper that adds / removes the Windows Firewall rule and URL ACL on --install / --uninstall.
The Shell binds the SMTC bridge on its own port, smtc.port in mbxhub-shell.json (default 8081); the hub reads that port from the Shell’s config rather than deriving it from its own. Local use (Shell and plugin on the same box) needs no firewall config — loopback traffic passes through. Remote use (Shell on one machine bridging to MBXHub on another) needs the SMTC port open on the machine hosting the Shell, so the remote dashboard can reach /meta/smtc/*. MBXHub.exe --install opens it for you.
Key commands for the MBXHub Shell (MBXHub.exe):
| Command | Description |
|---|---|
MBXHub.exe status | Show system health and tool discovery status |
MBXHub.exe --no-smtc | Run without SMTC bridge (headless/NAS mode) |
MBXHub.exe --install | Register AUMID, create Start Menu shortcut, report tool discovery, print Send To setup tip |
MBXHub.exe --uninstall | Remove AUMID registration and shortcut |
Direct access to the MusicBee API methods registered by this bridge; see the supported signatures below. Unregistered method names return 404. Use this for operations not exposed via REST endpoints or for scripting/automation. Disabled by default — the bridge is an advanced case and must be enabled at the console before any call succeeds: MBXHub settings → API Access tab → "RPC bridge" (rpcEnabled, a Network setting, absent from the web settings page). While disabled every call answers 403 FORBIDDEN "RPC endpoint is disabled". Under Shared LAN the bridge is never available remotely, allowed clients and DJs included (403 ACCESS_PROFILE_DENIED); only the local host keeps it.
Invoke a registered MusicBee API method by name
POST /rpc/Library_GetFileTag
Content-Type: application/json
{
"fileUrl": "C:\\Music\\song.mp3",
"field": "TrackTitle"
}
// Response:
{
"success": true,
"data": {
"method": "Library_GetFileTag",
"result": "Song Title"
}
}
| Method | Parameters | Returns |
|---|---|---|
| Player_PlayPause | - | boolean |
| Player_Stop | - | boolean |
| Player_StopAfterCurrent | - | boolean |
| Player_PlayNextTrack | - | boolean |
| Player_PlayPreviousTrack | - | boolean |
| Player_PlayNextAlbum | - | boolean |
| Player_PlayPreviousAlbum | - | boolean |
| Player_StartAutoDj | - | boolean |
| Player_EndAutoDj | - | boolean |
| Player_GetPosition | - | int (ms) |
| Player_SetPosition | position: int | boolean |
| Player_GetVolume | - | float (0-1) |
| Player_SetVolume | volume: float | boolean |
| Player_GetMute | - | boolean |
| Player_SetMute | mute: boolean | boolean |
| Player_GetShuffle | - | boolean |
| Player_SetShuffle | shuffle: boolean | boolean |
| Player_GetRepeat | - | RepeatMode |
| Player_SetRepeat | mode: RepeatMode | boolean |
| Player_GetPlayState | - | PlayState |
| Player_GetEqualiserEnabled | - | boolean |
| Player_SetEqualiserEnabled | enabled: boolean | boolean |
| Player_GetDspEnabled | - | boolean |
| Player_SetDspEnabled | enabled: boolean | boolean |
| Player_GetCrossfade | - | boolean |
| Player_SetCrossfade | enabled: boolean | boolean |
| Player_GetReplayGainMode | - | ReplayGainMode |
| Player_SetReplayGainMode | mode: ReplayGainMode | boolean |
| Player_GetScrobbleEnabled | - | boolean |
| Player_SetScrobbleEnabled | enabled: boolean | boolean |
| Player_QueueRandomTracks | count: int | int |
| Player_GetOutputDevices | - | {devices, activeDevice} |
| Player_SetOutputDevice | deviceName: string | boolean |
| Method | Parameters | Returns |
|---|---|---|
| NowPlaying_GetFileUrl | - | string |
| NowPlaying_GetDuration | - | int (ms) |
| NowPlaying_GetFileTag | field: MetaDataType | string |
| NowPlaying_GetFileTags | fields: MetaDataType[] | string[] |
| NowPlaying_GetFileProperty | type: FilePropertyType | string |
| NowPlaying_GetArtwork | - | string (base64/path) |
| NowPlaying_GetArtworkUrl | - | string |
| NowPlaying_GetLyrics | - | string |
| NowPlaying_GetDownloadedLyrics | - | string |
| NowPlaying_GetArtistPicture | fadingPercent: int | string |
| NowPlaying_GetArtistPictureThumb | - | string |
| NowPlaying_GetArtistPictureUrls | localOnly: boolean | string[] |
| NowPlaying_IsSoundtrack | - | boolean |
| NowPlaying_GetSpectrumData | size: int (optional; default 512, maximum 4096) | Object in data.result: {count: int, data: float[]} |
| NowPlaying_GetSoundGraph | size: int (optional; default 512, maximum 4096) | Object in data.result: {success: boolean, data: float[]} |
| Method | Parameters | Returns |
|---|---|---|
| NowPlayingList_GetCurrentIndex | - | int |
| NowPlayingList_GetNextIndex | offset: int | int |
| NowPlayingList_IsAnyPriorTracks | - | boolean |
| NowPlayingList_IsAnyFollowingTracks | - | boolean |
| NowPlayingList_GetListFileUrl | index: int | string |
| NowPlayingList_GetFileTag | index: int, field: MetaDataType | string |
| NowPlayingList_GetFileTags | index: int, fields: MetaDataType[] | string[] |
| NowPlayingList_GetFileProperty | index: int, type: FilePropertyType | string |
| NowPlayingList_Clear | - | boolean |
| NowPlayingList_PlayNow | fileUrl: string | boolean |
| NowPlayingList_QueueNext | fileUrl: string | boolean |
| NowPlayingList_QueueLast | fileUrl: string | boolean |
| NowPlayingList_QueueFilesNext | fileUrls: string[] | boolean |
| NowPlayingList_QueueFilesLast | fileUrls: string[] | boolean |
| NowPlayingList_RemoveAt | index: int | boolean |
| NowPlayingList_MoveFiles | fromIndices: int[], toIndex: int | boolean |
| NowPlayingList_PlayLibraryShuffled | - | boolean |
| NowPlayingList_QueryFilesEx | query: string | string[] |
| Method | Parameters | Returns |
|---|---|---|
| Library_GetFileTag | fileUrl: string, field: MetaDataType | string |
| Library_GetFileTags | fileUrl: string, fields: MetaDataType[] | string[] |
| Library_GetFileProperty | fileUrl: string, type: FilePropertyType | string |
| Library_SetFileTag | fileUrl: string, field: MetaDataType, value: string | boolean |
| Library_CommitTagsToFile | fileUrl: string | boolean |
| Library_GetLyrics | fileUrl: string, type: LyricsType | string |
| Library_GetArtwork | fileUrl: string, index: int | string |
| Library_GetArtworkUrl | fileUrl: string, index: int | string |
| Library_GetArtistPicture | artistName: string, fadingPercent: int | string |
| Library_GetArtistPictureThumb | artistName: string | string |
| Library_GetArtistPictureUrls | artistName: string, localOnly: boolean | string[] |
| Library_QueryFilesEx | query: string | string[] |
| Library_QuerySimilarArtists | artistName: string, minimumSimilarity: double | string |
| Library_AddFileToLibrary | fileUrl: string, category: LibraryCategory | string |
| Method | Parameters | Returns |
|---|---|---|
| Playlist_QueryPlaylists | - | boolean |
| Playlist_QueryGetNextPlaylist | - | string |
| Playlist_GetName | playlistUrl: string | string |
| Playlist_GetType | playlistUrl: string | PlaylistFormat |
| Playlist_IsInList | playlistUrl: string, filename: string | boolean |
| Playlist_QueryFilesEx | playlistUrl: string | string[] |
| Playlist_CreatePlaylist | folderName: string, playlistName: string, filenames: string[] | string |
| Playlist_DeletePlaylist | playlistUrl: string | boolean |
| Playlist_SetFiles | playlistUrl: string, filenames: string[] | boolean |
| Playlist_AppendFiles | playlistUrl: string, filenames: string[] | boolean |
| Playlist_RemoveAt | playlistUrl: string, index: int | boolean |
| Playlist_MoveFiles | playlistUrl: string, fromIndices: int[], toIndex: int | boolean |
| Playlist_PlayNow | playlistUrl: string | boolean |
| Method | Parameters | Returns |
|---|---|---|
| Podcasts_QuerySubscriptions | query: string | string[] |
| Podcasts_GetSubscription | id: string | string[] |
| Podcasts_GetSubscriptionArtwork | id: string, index: int | string (base64) |
| Podcasts_GetSubscriptionEpisodes | id: string | string[] |
| Podcasts_GetSubscriptionEpisode | id: string, index: int | string[] |
| Method | Parameters | Returns |
|---|---|---|
| Setting_GetPersistentStoragePath | - | string |
| Setting_GetSkin | - | string |
| Setting_GetSkinElementColour | element: SkinElement, state: ElementState, component: ElementComponent | int |
| Setting_IsWindowBordersSkinned | - | boolean |
| Setting_GetFieldName | field: MetaDataType | string |
| Setting_GetDataType | field: MetaDataType | string |
| Setting_GetLastFmUserId | - | string |
| Setting_GetWebProxy | - | string |
| Setting_GetValue | settingId: SettingId | object |
| Method | Parameters | Returns |
|---|---|---|
| MB_GetWindowHandle | - | long |
| MB_RefreshPanels | - | true |
| MB_GetLocalisation | id: string, defaultText: string | string |
| MB_ShowNowPlayingAssistant | - | boolean |
| MB_InvokeCommand | command: Command, parameter: object | boolean |
| MB_SetWindowSize | width: int, height: int | boolean |
| MB_GetVisualiserInformation | - | {visualiserNames, defaultState, currentState} |
| MB_ShowVisualiser | visualiserName: string, state: WindowState | boolean |
rpcEnabled). Console-only; PUT /system/config cannot change it. Under Shared LAN it is refused for every remote caller regardless.Player_PlayPause, Library_SetFileTag) require the same permissions as their REST equivalents.
Real-time event streaming. Connect once, receive updates automatically - no polling.
ws://127.0.0.1:8080/wsThe feed is open to every client the hub admits unless the hub resolves it restricted — read the answer from GET /system/features → webSocketAuth ("clear" or "restricted"), never guess it from posture. Only a Shared LAN profile ever restricts it (by default; the plugin can turn that off: MBXHub settings → Remote Access tab → Live event feed). Private LAN and This computer only behave exactly as before, whatever the setting holds. Local connections are never gated.
When restricted, a remote handshake needs one of two credentials, because a browser cannot set a header on a WebSocket handshake:
| Client | How |
|---|---|
| A program (Home Assistant, a script) | Send X-Api-Key: mbx_... or Authorization: Bearer mbx_... on the upgrade request — the same headers as any route. A Basic install's Authorization: Basic works too. |
| A browser page | POST /ws/ticket (gated like any route, so a page that holds a session, a key, or is local can mint) answers { "ticket": "...", "expiresInSeconds": 30, "webSocketAuth": "restricted" }; connect to ws://127.0.0.1:8080/ws?ticket=.... A ticket is single-use and lives thirty seconds, so mint one before every connect, including reconnects — MBXShared.connectWebSocket does. A key in the query string is never a credential, here or anywhere. |
A refused handshake answers 401 before the socket is accepted, in the same two shapes as the REST gate: AUTH_REQUIRED with a Basic challenge on a hub where the password is on, UNAUTHORIZED with no challenge otherwise. Restricted with neither the password nor keys configured refuses every remote handshake — and POST /ws/ticket refuses too, 401 UNAUTHORIZED — rather than quietly opening; the settings dialog says so beside the control. POST /ws/ticket is on the Read-key allow-list and classifies as a read: it changes nothing on the hub.
| Step | Description |
|---|---|
| 1. Connect | Open WebSocket to ws://127.0.0.1:8080/ws |
| 2. Receive | Immediately starts receiving ALL events (default behavior) |
| 3. Subscribe (optional) | Send subscribe message to filter to specific events only |
| 4. Disconnect | Close the WebSocket connection when done |
Note: New clients receive ALL events by default. Once you send a subscribe message, you only receive those specific events. Use unsubscribe to stop receiving events without disconnecting.
| Event | Description | Frequency |
|---|---|---|
| TrackChanged | New track started playing | On track change |
| PlayStateChanged | Play/pause/stop state changed | On state change |
| VolumeChanged | Volume level or mute state changed | On volume change |
| PositionChanged | Playback position update (milliseconds) | ~1 per second while playing |
| QueueChanged | Now playing list modified (add/remove/clear) | On queue change |
| ShuffleChanged | Shuffle mode toggled on/off | On shuffle change |
| RepeatChanged | Repeat mode changed (none/all/one) | On repeat change |
| TempoChanged | Playback tempo changed (MB 3.5+; relays the file, no tempo value in the MB API) | On tempo change |
| MetadataChanged | Rating or love tag changed on current track | On tag/rating change |
| Reaction | User reacted to now playing track (emoji, nickname, track info) | On reaction submit |
| TasteChanged | AutoQ taste vector updated | On taste update |
| ThemeChanged | Theme configuration updated (active mode HSL values). A device with a theme of its own ignores it — see /device/theme | On theme change via PUT /system/theme or dashboard toggle |
| InfluenceChanged | An AutoQ influence was added or removed. Payload: {action, target, value}, plus type (the direction) on every path but the DELETE; the dashboard's own toggle adds source and message, and a party vote adds nickname | On POST /influences, DELETE /influences/{target}/{value}, POST /dashboard/influence/… and POST /partymode/vote |
| SystemEvent | A named signal pushed in from outside the plugin process. Payload: {name, payload} — the pushed name and the request body, which is null when the body was empty or was not JSON. Clients subscribe to SystemEvent, not to the pushed name. Names in use: CastStatus — the Cast session’s state, pushed by the paired Shell; its payload is GET /cast/status’s data | On POST /system/events/{name} |
| Shutdown | MusicBee is closing or restarting. Sent before the close is issued, so clients can reconnect gracefully rather than discover the socket gone. Payload: {restart, delay} — delay is the restart countdown in seconds, and 0 on a plain exit | On POST /app/exit and POST /app/restart |
| SearchMatched | v0.5.3.0. Saved-search match set changed. Payload: {id, name, added:[urls], removed:[urls], total, evaluatedAt} | Periodic SavedSearchScheduler tick when the diff is non-empty |
| MoodCacheReady | v0.5.3.2. Server-side mood cache finished cold-start load — dashboard mood pill is now fillable. Payload: {entries: N} (count of moods now in cache). Dashboard's WS handler calls softUpdate on receipt; replaces the previous 1500ms server-side Thread.Sleep that bridged the cold-cache window. | One-shot, fired once per plugin start after MoodCache phase 3 completes |
| PartyStateChanged | Party mode started or ended. Payload: {isActive}. Lets already-open pages redirect to the party surface the instant a party starts, instead of polling /partymode/status. Fresh page loads are handled server-side by a 302 redirect. | On StartParty / StopParty (REST endpoint or WinForms dialog) |
| CharmActivated | A person activated one of a charm's entries. Addressed for a proc charm: it goes to the socket bound to that charm and to nobody else — see Binding a charm's socket below. A page charm's activation is broadcast, because the hub has no handle on the iframe: it goes to every connected client that has not filtered it out, and the dashboard's socket handler forwards it into the right frame. Payload either way: {charmId, event, placement, entryIndex, entryLabel, atUtc} — which charm, which entry and where it sits, and nothing else | On a click, on any surface the charm placed an entry on |
| ListenHereClaimed | A page took the Listen Here audio. Every other page holding it lets go — stops its stream and returns to speaker output, without touching MusicBee. Payload: {holder, page}. A page ignores its own id. Delivered on the priority path: there is no later event that repeats it, and a dropped one leaves two pages playing at once until the holder check catches up | On POST /listen-here/claim, only when the holder changes |
// Subscribe to specific events (filters to only these events)
{"subscribe": ["TrackChanged", "PlayStateChanged"]}
// Unsubscribe from events (stop receiving them)
{"unsubscribe": ["PositionChanged"]}
// Subscribe to all events (equivalent to no subscriptions).
// This is EventTypes.All in full - all 19 types this build broadcasts.
{"subscribe": ["TrackChanged", "PlayStateChanged", "VolumeChanged",
"PositionChanged", "QueueChanged", "ShuffleChanged",
"RepeatChanged", "TempoChanged", "MetadataChanged",
"Reaction", "TasteChanged", "ThemeChanged",
"InfluenceChanged", "SystemEvent", "Shutdown",
"SearchMatched", "MoodCacheReady", "PartyStateChanged",
"CharmActivated", "ListenHereClaimed"]}
A registered charm names itself on a socket by presenting its ticket in a bind message. After that the connection receives CharmActivated for that charm — a person clicking one of its menu or bar entries arrives here.
// Sent by the charm, once, after connecting:
{"bind": "<the plaintext ticket from POST /charms/register>"}
// Then, on a click:
{
"event": "CharmActivated",
"timestamp": "2026-09-04T12:00:00.000Z",
"data": {
"charmId": "com.example.thing",
"event": "activated",
"placement": "menu",
"entryIndex": 0,
"entryLabel": "Open Thing",
"atUtc": "2026-09-04T12:00:00.0000000Z"
}
}
bind from anywhere but the machine MBXHub runs on is ignored, and it is ignored above resolution — the same order the REST gate uses — so a socket from across the network cannot learn whether the ticket it presented is a real one.proc charm. A page charm runs in a frame the dashboard owns, so its activation is broadcast rather than addressed and the dashboard forwards it into that frame — there is no socket for a page to bind, and CharmActivated is a subscribable type like any other.bind is handled before subscribe in the same frame, so one message may do both.data.event is activated, and that is the whole verb set this release. placement says which surface was touched, so a charm with entries in two places can tell them apart; entryIndex is the index into the charm's own expand[], not a position in a menu.// TrackChanged
// cueTrack + cueStartMs are present only when the current track is a
// CUE-backed virtual track (one physical file, multiple logical tracks).
// Clients that stream audio locally use cueStartMs to seek <audio>.currentTime.
{
"event": "TrackChanged",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"fileUrl": "C:\\Music\\song.mp3",
"title": "Track Title",
"artist": "Artist Name",
"album": "Album Name",
"duration": 245000,
"artworkUrl": "/nowplaying/artwork",
"cueTrack": 3, // optional
"cueStartMs": 184000 // optional
}
}
// PlayStateChanged
{
"event": "PlayStateChanged",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"state": "playing" // "playing", "paused", "stopped"
}
}
// VolumeChanged
{
"event": "VolumeChanged",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"volume": 75, // 0 to 100
"muted": false
}
}
// PositionChanged
{
"event": "PositionChanged",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"position": 45000, // Current position in milliseconds
"duration": 245000 // Total duration in milliseconds
}
}
// QueueChanged
{
"event": "QueueChanged",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"action": "add", // "add", "remove", "clear", "move"
"index": 5,
"totalTracks": 42
}
}
// ShuffleChanged
{
"event": "ShuffleChanged",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"enabled": true
}
}
// RepeatChanged
{
"event": "RepeatChanged",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"mode": "all" // "none", "all", "one"
}
}
// MetadataChanged
{
"event": "MetadataChanged",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"fileUrl": "C:\\Music\\song.mp3",
"rating": 3, // -1 (unrated) to 5
"love": "L" // "L" (loved), "B" (banned), or "" (neither)
}
}
// Reaction
{
"event": "Reaction",
"timestamp": "2024-01-03T12:00:00.000Z",
"data": {
"emoji": "fire",
"type": "fire", // fire, heart, like, dislike, ban
"nickname": "Guest",
"trackTitle": "Song Name",
"trackArtist": "Artist"
}
}
// TasteChanged (debounced, fires after reactions/influences/mood changes)
{
"event": "TasteChanged",
"timestamp": "2024-01-03T12:00:05.000Z",
"data": {
"topGenres": [{ "name": "Rock", "weight": 1.0 }],
"topArtists": [{ "name": "Foo Fighters", "weight": 0.85 }],
"bpmRange": [90, 160],
"mood": "Energetic",
"moodConfidence": 0.88,
"influenceCount": 3,
"reactionCount": 12
}
}
// ThemeChanged (fires on PUT /system/theme or dashboard mode toggle)
{
"event": "ThemeChanged",
"timestamp": "2024-01-03T12:00:06.000Z",
"data": {
"activeMode": 1,
"accentHue": 197, "accentSaturation": 80, "accentLightness": 55,
"bgHue": 203, "bgSaturation": 30, "bgLightness": 94,
"surfaceHue": 203, "surfaceSaturation": 30, "surfaceLightness": 96,
"textHue": 203, "textSaturation": 50, "textLightness": 13,
"intensity": 100
}
}
// Connect to WebSocket
const ws = new WebSocket('ws://127.0.0.1:8080/ws');
ws.onopen = function() {
console.log('Connected to MBXHub');
// Optional: Subscribe to specific events only
// Without this, you receive ALL events
ws.send(JSON.stringify({
subscribe: ['TrackChanged', 'PlayStateChanged', 'PositionChanged']
}));
};
ws.onmessage = function(event) {
const msg = JSON.parse(event.data);
switch (msg.event) {
case 'TrackChanged':
console.log('Now playing:', msg.data.title, '-', msg.data.artist);
break;
case 'PlayStateChanged':
console.log('State:', msg.data.state);
break;
case 'PositionChanged':
const pct = (msg.data.position / msg.data.duration * 100).toFixed(1);
console.log('Position:', pct + '%');
break;
}
};
ws.onclose = function() {
console.log('Disconnected from MBXHub');
};
ws.onerror = function(err) {
console.error('WebSocket error:', err);
};
// Later: Unsubscribe from position updates (too frequent)
ws.send(JSON.stringify({ unsubscribe: ['PositionChanged'] }));
// Clean disconnect
ws.close();
Cross-checks MBXHub's REST responses against mbClouseau, a separate MusicBee plugin that reads MusicBee's state directly. Used by MBXHVAL to prove the API reports what MusicBee actually holds, rather than what the hub believes. Requires mbClouseau.dll loaded in MusicBee; every endpoint except /status returns 503 CLOUSEAU_NOT_AVAILABLE without it.
Whether Clouseau is reachable. Always 200 — this is the probe you call first. Response: {available, message}.
Clouseau's view of MusicBee state as validation JSON, for comparison against REST responses. 503 CLOUSEAU_NOT_AVAILABLE when the plugin isn't loaded.
Same state as the GET, and also writes it to disk as a timestamped file so a run can be compared later. 503 when unavailable.
Lists the validation state files already written by the POST above. 503 when unavailable.
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description"
}
}
| Code | HTTP | Description |
|---|---|---|
| NOT_FOUND | 404 | Endpoint or resource not found |
| INVALID_REQUEST | 400 | Invalid parameters or malformed request |
| ARIA_DISABLED | 403 | ARiA input simulation is disabled |
| FORBIDDEN | 403 | Operation not allowed (e.g., RPC disabled) |
| AUTH_REQUIRED | 401 | The HTTP Basic password gate is enabled and the request lacked valid credentials. Response carries a WWW-Authenticate: Basic header. See Security → HTTP Basic Password Gate. |
| UNAUTHORIZED | 401 | API keys are required, the password gate is off, and the request carried no valid key. No WWW-Authenticate header — a browser must not be prompted for a password that does not exist. See Security → API Keys. |
| READ_ONLY_KEY | 403 | The API key has no Write grant for this action. See Security → API Keys. |
| EXECUTE_REQUIRED | 403 | An ARiA action requires an explicit Execute grant. Read-write alone does not allow host automation, including its GET aliases. |
| METHOD_NOT_ALLOWED | 405 | Wrong HTTP method |
| SERVICE_UNAVAILABLE | 503 | Required service not available (e.g., TrueShuffle/AutoQ) |
| INTERNAL_ERROR | 500 | Server error. Response body contains the full exception dump (type, message, stack trace, inner-exception chain) when debugMode is on and logLevel is Debug or lower; otherwise body is the generic fallback string. The dump always lands in mbxhub.log via _log.Warn(ex, ...) regardless of the gate. See Logging section below for how to enable. |
Common enum values used in API parameters and responses.
| Value | Code | Description |
|---|---|---|
| undefined | 0 | Unknown state |
| loading | 1 | Track is loading |
| playing | 3 | Playing |
| paused | 6 | Paused |
| stopped | 7 | Stopped |
| Value | Code | Description |
|---|---|---|
| none | 0 | No repeat |
| all | 1 | Repeat all tracks |
| one | 2 | Repeat current track |
| Value | Code | Description |
|---|---|---|
| off | 0 | Disabled |
| track | 1 | Track-based gain |
| album | 2 | Album-based gain |
| smart | 3 | Automatic selection |
| Field | Code | Description |
|---|---|---|
| TrackTitle | 65 | Track title |
| Album | 30 | Album name |
| AlbumArtist | 31 | Album artist |
| Artist | 32 | Track artist |
| Composer | 43 | Composer |
| Genre | 59 | Genre |
| Rating | 75 | Star rating (0-5) |
| RatingLove | 76 | Love rating |
| TrackNo | 86 | Track number |
| DiscNo | 52 | Disc number |
| Year | 88 | Year |
| Lyrics | 114 | Lyrics text |
| Comment | 44 | Comment |
| Publisher | 73 | Publisher/Label |
| Conductor | 45 | Conductor |
| Property | Code | Description |
|---|---|---|
| Url | 2 | File path/URL |
| Kind | 4 | File type (Music, Video, etc.) |
| Format | 5 | Audio format (MP3, FLAC, etc.) |
| Size | 7 | File size in bytes |
| Channels | 8 | Audio channels |
| SampleRate | 9 | Sample rate (Hz) |
| Bitrate | 10 | Bitrate (kbps) |
| Duration | 16 | Duration (ms) |
| PlayCount | 14 | Play count |
| SkipCount | 15 | Skip count |
| LastPlayed | 13 | Last played date |
| DateAdded | 12 | Date added to library |
| DateModified | 11 | File modification date |
| Format | Code | Description |
|---|---|---|
| Unknown | 0 | Unknown format |
| M3u | 1 | M3U playlist |
| Xspf | 2 | XSPF (XML Shareable Playlist) |
| Asx | 3 | ASX (Windows Media) |
| Wpl | 4 | WPL (Windows Media) |
| Pls | 5 | PLS playlist |
| Auto | 7 | Auto-detect format |
| Type | Code | Description |
|---|---|---|
| NotSpecified | 0 | Any lyrics type |
| Synchronised | 1 | Time-synced lyrics (LRC) |
| UnSynchronised | 2 | Plain text lyrics |
| Category | Code | Description |
|---|---|---|
| Music | 0 | Music files |
| Audiobook | 1 | Audiobooks |
| Video | 2 | Video files |
| Inbox | 4 | Inbox (new files) |
MBXHub includes comprehensive logging for debugging and monitoring. Logs are written using NLog to a log file in the MBXHub folder.
Logging is controlled by two settings in mbxhub.json:
| Setting | Type | Description |
|---|---|---|
debugMode | boolean | Master switch - enables/disables all logging. Also gates exception dumps in 500 response bodies (see below). |
logLevel | string | Minimum log level when debug mode is on |
500 response body gate: when debugMode is true AND logLevel is Debug or Trace, INTERNAL_ERROR responses include the full exception dump in the body (type, message, stack trace, inner-exception chain) — useful when debugging a self-hosted LAN deployment where the operator is also the consumer. Otherwise the body is the generic fallback string and the dump only lands in mbxhub.log. Default is fail-secure (debugMode: false) so production / shared exposure does not leak internals.
| Level | What Gets Logged |
|---|---|
| Trace | Everything including request/response bodies, WebSocket message content. Very verbose. |
| Debug | Route matching, handler selection, subscription changes, internal decisions. |
| Info | Startup/shutdown, HTTP requests (method/path/status/timing), connections, track changes. |
| Warning | Recoverable errors, timeouts, retries, unexpected but handled situations. |
| Error | Failures, exceptions, service unavailable. Always logged even with debug mode off. |
Log files are stored in the MBXHub subfolder of MusicBee's persistent storage:
%AppData%\MusicBee\MBXHub\mbxhub.log
mbxhub.1.log, mbxhub.2.log, etc.To enable debug logging:
// In mbxhub.json:
{
"debugMode": true,
"logLevel": "Trace" // or "Debug", "Info", "Warning", "Error"
}
HTTP requests are logged at Info level with timing:
2025-01-25 14:32:15 [INFO ] [REST] HTTP GET /player/status -> 200 (12ms)
2025-01-25 14:32:16 [INFO ] [REST] HTTP POST /player/playpause -> 200 (8ms)
2025-01-25 14:32:17 [INFO ] [Plugin] Track changed: Artist Name - Track Title
2025-01-25 14:32:17 [INFO ] [WebSocket] WS client abc12345 connected from 192.168.1.50:54321
MBXHub operates on a trusted local network model with multiple security layers.
PartyMode uses PIN-based authentication with three roles:
| Role | Access | Authentication |
|---|---|---|
DJ | Full control: player, queue, start/stop party | DJ PIN via X-Party-PIN header |
Guest | Browse library, request songs, vote | Guest PIN via X-Party-PIN header |
Anonymous | Read-only: now playing, artwork, status | No PIN required |
PartyMode endpoints authenticate via the X-Party-PIN header:
# Guest request example
curl -X POST http://127.0.0.1:8080/partymode/request \
-H "X-Party-PIN: 1234" \
-H "Content-Type: application/json" \
-d '{"url":"C:\\Music\\Track.mp3","nickname":"Haro"}'
# Validate PIN and get role
GET /partymode/validate?pin=1234&nickname=Haro
Invalid or missing PIN returns 401 Unauthorized.
An optional always-on password gate over the whole API surface — a safety net for trusted LANs. Turn it on in the plugin under Settings → Remote Connection Settings (Configure Access…) → Remote Access by ticking Require password (HTTP Basic) and setting a password. It is off by default and has no web/API configuration.
When enabled, remote requests must carry an HTTP Basic credential. The username is ignored; only the password is checked:
# Any username, the configured password
curl http://127.0.0.1:8080/library/files \
-H "Authorization: Basic $(printf 'x:yourpassword' | base64)"
Missing or wrong credentials return 401 Unauthorized with a WWW-Authenticate: Basic realm="MBXHub" header, so browsers show a native login prompt. Exempt from the gate: localhost (the host machine), and the live-PartyMode surface (party guests authenticate with their PIN, not this password). The /ws event stream is exempt only when resolved webSocketAuth is clear. Shared LAN defaults to restricted: remote sockets need an accepted credential header or a single-use ticket. Local sockets remain exempt. See the WebSocket section and GET /system/features for the resolved mode. A speaker the hub is casting to is admitted for its audio and artwork (/stream/, /library/file/.../artwork) while the cast lasts; every other route stays gated, and a banned address is still refused.
Limits — this is a speed bump, not transport security. Over plain HTTP the password is sent base64-encoded (effectively cleartext) on every request; only HTTPS fixes that. The stored password is an unsalted SHA-256 hash. Fine for a single shared secret on a trusted network, not a hostile one.
The password is stored only as a one-way hash — never in plain text, and it cannot be recovered. If you forget it, set a new one in the same Remote Access settings where you enabled the gate.
Named, revocable credentials for other programs — a Home Assistant integration, a script, a wall display — so one can be cut off without changing a password everyone else shares. Turn them on in the plugin under Settings → Remote Connection Settings (Configure Access…) → Remote Access by ticking Require API key and pressing New key…. Off by default, no web/API configuration, and independent of the password gate: either, both or neither. Effective only when enabled and at least one key exists.
A key is mbx_ followed by 43 characters. It is shown once when made and is not stored — the hub keeps a hash, the label and the last four characters. If a key is lost, revoke it and make a new one. A key's access is fixed when it is made; to widen it, revoke and mint.
# Canonical: the X-Api-Key header
curl http://127.0.0.1:8080/library/files -H "X-Api-Key: mbx_..."
# Also accepted
curl http://127.0.0.1:8080/library/files -H "Authorization: Bearer mbx_..."
Header only. A key in the query string is never a credential, on any route — this hub's log prints request URLs. If both headers are present, X-Api-Key wins.
Two refusal shapes, so a browser is never handed a password prompt it cannot satisfy. On a hub where the Basic password is also on, a missing or wrong key gets the Basic challenge — 401 AUTH_REQUIRED with WWW-Authenticate: Basic, unchanged, and no “bad key” hint. On a keys-only hub it is 401 UNAUTHORIZED, “Valid API key required”, with no WWW-Authenticate header.
Every exemption the password has, a key has, and a key is not a way around any of them: localhost, the /ws event stream while it resolves open (see WebSocket Events → Credentials — when restricted, the key on the handshake is what opens it), the live-PartyMode surface, a SAC header on a SAC-gated route, and the speaker the hub is casting to, admitted for its audio and artwork (/stream/, /library/file/.../artwork) while the cast lasts — every other route stays gated and a banned address is still refused. An authorized key logs one Info line naming its label only — never the key, the hash or the suffix — so mbxhub.log can say which integration called.
Read, Write and Execute. Choose Read or Read-write, then optionally grant Execute - allow ARiA host automation in the console's New API key dialog. Execute is unchecked by default; existing keys gain no execution permission. Widening remains revoke-and-mint. Read covers status/inventory GETs, HEAD, OPTIONS and three read-shaped POSTs: /api/proxy when its inner method is GET, /partymode/validate, and /ws/ticket. Mutating GET aliases are classified by their effect: /dashboard/theme requires Write; ARiA actions require Execute, including wake, scan, key input, focus, mouse and preset execution. ARiA status, preset-list and program-list reads remain Read. Missing Write answers 403 READ_ONLY_KEY; missing Execute answers 403 EXECUTE_REQUIRED. Write never grants Execute, and Read + Execute does not grant ordinary writes. Feature, profile and party restrictions still apply. POST /search/saved/{id}/run remains Write because it records the match set; its read equivalent is GET /search/saved/{id}/results.
Proxy boundary. When Basic authentication or API-key authentication is effective, /api/proxy refuses loopback and self targets even if allowInternalProxy is true. Proxying back to this hub cannot turn a remote action into an exempt local request.
Limits — the same as the password's: without HTTPS a key travels in the clear on every request.
MBXHub supports three protection levels for different deployment scenarios:
| Level | Description | Use Case |
|---|---|---|
| Default | Full API access, no restrictions | Personal use, trusted networks |
| Kiosk | All requests redirect to defaultPage | Party displays, public screens |
| Restricted | Read-only mode with granular controls | Shared access, limited control |
Configure via kioskMode and apiReadOnlyMode in settings.
PartyMode includes built-in limits and optional per-IP rate limiting:
Built-in limits (always active):
Per-IP rate limiting (configurable via Settings → Party Mode...):
Returns 429 Too Many Requests when limits exceeded.
| Origin | Access |
|---|---|
localhost, 127.0.0.1, [::1] | Always allowed |
192.168.x.x | Allowed when allowRemoteConnections is enabled |
10.x.x.x | Allowed when allowRemoteConnections is enabled |
172.16.x.x - 172.31.x.x | Allowed when allowRemoteConnections is enabled |
fe80::/10, fc00::/7, this machine’s own hostname | Allowed when allowRemoteConnections is enabled |
| External origins | Refused — see below |
Refused, not merely un-granted. A disallowed origin on POST, PUT, PATCH or DELETE answers 403 ORIGIN_NOT_ALLOWED before the request is routed, and a disallowed preflight answers 403 rather than 204. Withholding Access-Control-Allow-Origin only stops a page READING the response — a simple POST needs no preflight, so a page could otherwise fire one at this API and get the side effect it wanted while ignoring an answer it never needed.
A request with no Origin header is unaffected. Native callers — the Shell, mbxutil, MCP, scripts, and integrations written against this API — send none, and are served exactly as before. GET and HEAD are not refused either: without the grant header they stay unreadable to a disallowed page, which is what CORS is for.
“Allow remote connections” off is enforced per request, not by the listener. The hub binds the same http://+:<port>/ prefix either way and answers every caller that is not on this machine with 403. It used to bind http://localhost:<port>/ instead, and under the first-run wizard’s URL reservation that listener was unreachable — http.sys fails any request a + reservation matches and no + registration covers, so every route answered 503 before the hub saw it. localhost, 127.0.0.1 and [::1] answer on every binding; the machine name answers while the hub holds the wildcard prefix, which is the normal case on a box that has run network setup.
Setup modes (first-run setup, re-runnable from the tray’s Network setup → Open setup…): This computer only — allowRemoteConnections=false, no reservations, no admin prompt; My private network — true + Private LAN, reservations + firewall rule (one UAC); A shared network — true + Shared LAN. The default with no setup run is This computer only. Moving down to This computer only writes settings only; the reservations stay and are inert (the code gate refuses remote callers). “Remove network setup” (wizard, tray) is the optional elevated cleanup.
Two settings, chosen at the console: MBXHub settings → API Access tab → Access profile. Both are Security settings, so they are absent from GET /system/settings/schema and PUT /system/config refuses them with 403 SETTING_CONSOLE_ONLY. When neither has been chosen, the hub behaves as Private LAN + Free Join.
| Setting | Choice | Behavior |
|---|---|---|
accessProfile | Private LAN | Keeps the existing network filters, administration restrictions and write permissions. |
accessProfile | Shared LAN | Normal remote access requires an allowed client (Client Management → Allow). Administration and configuration are localhost-only. Remote library, playlist, tag and play-statistics writes are refused, including for allowed clients and DJs. Unclassified endpoints are refused too, and the RPC bridge is refused for every remote caller (no per-method allow-list); only the local host keeps it, and only when rpcEnabled is on. A speaker the hub is casting to is admitted for its audio and artwork (/stream/, /library/file/.../artwork) while the cast lasts, listed or not; every other route stays gated and a banned address is still refused. A request carrying the paired Shell’s signature (X-MBXHub-SAC) on the SAC-gated routes — the cast status push, /system/open-settings, /app/exit and /app/restart — reaches its handler instead of being refused as administration; the handler verifies the signature and keeps its own gate (exit and restart still need allowRemoteExit). |
webSocketAuth | Follow profile | Whether the /ws event feed needs a credential from a remote client on a Shared LAN — on any other profile the feed behaves as it always has, whatever this holds. Follow profile: a Shared LAN restricts. Open: turns that off. Require a ticket or key: a header key on the handshake or a single-use ticket from POST /ws/ticket; with neither the password nor keys configured this refuses every remote socket, by design. Console-only: MBXHub settings → Remote Access tab → Live event feed; absent from GET /system/settings/schema. Read the resolved value from GET /system/features → webSocketAuth. |
partyAdmission | Free Join | During a party, guests join with the guest PIN without being allowed individually; an unlisted client gets guest participation only. |
partyAdmission | Managed Join | During a party, guests must also be allowed in Client Management. PIN and role rules still apply, on either profile. |
A refusal answers 403 ACCESS_PROFILE_DENIED, with the reason in the message. Local callers are never restricted by a profile, and access is decided by IP address, never by a discovered or saved client name. Guest participation covers the guest pages, PIN entry, song browsing, queue and now-playing reads, requests, votes, reactions and leaderboard/QR data; a guest-only socket receives TrackChanged, QueueChanged and Reaction only. Changing either setting re-checks connected WebSockets, and a connection whose access changed must reconnect. A malformed explicit value blocks remote access until it is corrected at the console.
MBXHub can restrict write operations via settings with granular per-operation controls:
Restriction Hierarchy:
Settings cascade: master OR category OR operation = blocked
Granular Operations:
| Category | Operation | Endpoints Affected |
|---|---|---|
| Library | Tag edits | PUT /library/file/*, POST /library/commit |
| Queue | Add tracks | POST /queue/add, /queue/playnow, /queue/play |
| Add AND remove | POST /queue/play-list — replacing or bulk-enqueueing around a queue destroys entries, so it needs both; during a party a guest holds neither | |
| Remove tracks | DELETE /queue/* | |
| Reorder tracks | POST /queue/move | |
| Player | Playback | POST /player/play, /pause, /stop, /next, /previous |
| Volume | POST /player/volume, /mute | |
| Seek | POST /player/position | |
| Playlists | Create | POST /playlists |
| Delete | DELETE /playlists/* | |
| Modify | PUT /playlists/*, POST /playlists/*/files |
Default Settings:
The philosophy: player and queue are open, destructive operations are locked. Playlists, tag edits, and file deletion default to read-only.
| Setting | Default | Effect |
|---|---|---|
apiReadOnlyMode | false | Master switch — off |
apiReadOnlyPlayer | false | Player controls allowed |
apiReadOnlyQueue | false | Queue modifications allowed |
apiReadOnlyLibrary | false | Library allowed (but see granular) |
apiReadOnlyPlaylists | true | Playlists blocked by default |
apiReadOnlyLibraryTags | true | Tag edits blocked by default |
apiReadOnlyLibraryDelete | true | File deletion blocked by default |
apiReadOnlyPlayerPlayback | false | Playback allowed |
apiReadOnlyPlayerVolume | false | Volume allowed |
apiReadOnlyPlayerSeek | false | Seek allowed |
apiReadOnlyQueueAdd | false | Queue add allowed |
apiReadOnlyQueueRemove | false | Queue remove allowed |
apiReadOnlyQueueReorder | false | Queue reorder allowed |
apiReadOnlyPlaylistsCreate | false | (moot — parent is true) |
apiReadOnlyPlaylistsDelete | false | (moot — parent is true) |
apiReadOnlyPlaylistsModify | false | (moot — parent is true) |
Settings cascade: master OR category OR operation = blocked. The granular playlist settings default to false but are moot because their parent apiReadOnlyPlaylists is true.
Action Categories (v0.5.2.6+):
| Category | Endpoints | Notes |
|---|---|---|
| MediaHandler party-mode | /media/* | When a party is active, all /media/* endpoints are DJ-only. Guest/Anonymous ⇒ 403 PARTY_LOCKED. The host’s Projector charm runs as DJ on localhost. Outside party mode, every caller is allowed. |
Always Permitted (exempt from restrictions):
Blocked requests return 403 Forbidden. Playback restrictions use ACCESS_DENIED; tag restrictions use LIBRARY_TAGS_READ_ONLY, and API-key Write refusals use READ_ONLY_KEY. Read the returned message for the specific gate:
{"success":false,"error":{"code":"ACCESS_DENIED","message":"The API is in read-only mode. Uncheck 'Read-Only Mode' in MusicBee > MBXHub settings > API Access."}}
{"success":false,"error":{"code":"PARTY_LOCKED","message":"Media browsing is locked during PartyMode (DJ-only)."}}
allowRemoteConnections disabled unless neededrpcEnabled: false). Leave it off unless a client specifically needs direct MusicBee API access; when on, it respects read-only mode and granular permissions, and Shared LAN keeps it local-onlyariaEnabled: false) unless specifically needed for PC wake scenariosrun() command only launches programs defined in ariaAllowedPrograms. Do not add shell interpreters (cmd.exe, powershell.exe) to the allowlist
All file URLs are Windows paths. URL-encode when passing in path parameters:
// Original: C:\Music\Artist\Track.mp3
// Encoded: C%3A%5CMusic%5CArtist%5CTrack.mp3
// Example: GET /library/file/C%3A%5CMusic%5CArtist%5CTrack.mp3
Most list endpoints support offset and limit query parameters:
limit=0 means every row from the offset, up to the maximum — ask for the whole list instead of naming a big number and hopingGET /library/files?offset=100&limit=50GET /queue?offset=0&limit=0Omitting limit is not the same as no limit. A request without one is answered with the default 50, and nothing in the response announces that it was cut. The truncation is always derivable — total is the length of the whole list, not of the page — so compare the returned row count against total - offset before treating a page as the whole thing, or pass limit=0 and skip the question.
This bites hardest where a count in the response spans the whole list while the rows do not. GET /queue is the example: upcomingTotal counts what MusicBee will still play across the entire list, so on a long queue a default page can report 16 tracks up next and contain none of them (cursor at 187 of 204). Both numbers are right; only reading them together is wrong. Ask from the cursor — GET /queue/current, then ?offset=<currentIndex>&limit=0 — or ask for all of it.
To read what MusicBee will play in the order it will play it, including shuffle, ask GET /queue?view=play: the same offset/limit rules and the same 10000-row cap, over positions in play order instead of list rows, each row keeping its original index, and 409 QUEUE_CHANGED when the list moved under the read. See GET /queue.
Library endpoints support ?sort= parameter for server-side sorting:
alpha (default) - Alphabetical by title+artistartist - By artist name, then titlealbum - By album name, then track numbertitle - By title onlydate - By date added (newest first)track - By disc number, then track number (natural album order)name - By display nameyear-asc - By year ascending (chronological, oldest first)Example: GET /library/files?artist=Pink+Floyd&sort=album
v0.5.3.4 behavior notes. String comparisons use InvariantCultureIgnoreCase over diacritic-folded keys, so non-Latin album titles (Cyrillic / Greek / CJK / Hebrew / Arabic) sort under their own scripts instead of dropping to the tail of an A→Z list (ASCII libraries unchanged). Custom-* sorts configured as Numeric send rows with unparseable values (Year=1999/2000 on compilations, whitespace-padded fields) to the END of the list alongside other non-Numerics, matching the Auto kind's segregation — previously they landed at the TOP via a long.MinValue sentinel.
Shuffle, banlist, and influence endpoints require TrueShuffle or AutoQ to be enabled. Check availability:
GET /shuffle/status
// Returns 503 SERVICE_UNAVAILABLE if TrueShuffle/AutoQ not enabled
For real-time updates (track changes, state changes), use WebSocket instead of polling:
By default, MBXHub only accepts localhost connections. For network access:
allowRemoteConnections in settingshttp://192.168.1.100:8080)MBXHub automatically detects CUE-backed audio files and resolves per-track metadata across all surfaces:
/player/status) - Overlays title, artist, trackNo, album from CUE sheet/nowplaying/position) - Includes cueTrack and cueTitle/nowplaying) - Includes cueTrack and cueStartMs (browser-mode clients seek the container and report play stats against the right sub-track)/nowplaying/tag) - Returns CUE track data for TrackTitle, Artist, Album, TrackNotrack events include cueTrack field when CUE activeEncoding detection: BOM check → UTF-8 validation → Windows-1252 fallback. Built-in regex parser; no external CUE library required.
| Use REST when... | Use RPC when... |
|---|---|
| Building a client app | Writing automation scripts |
| Need clean, discoverable URLs | Need direct MusicBee API access |
| Want resource-oriented design | Familiar with MusicBee plugin API |
| Working with standard HTTP clients | Need parameter flexibility |