# MBXHub API > MusicBee REST API - v0.5.5.0 > > llms.txt specification: https://llmstxt.org/ > Requires MusicBee 3.5+ (API rev 57). Supports up to API rev 58 (MusicBee 3.6+ APIs available when present). ## Endpoints - Base URL: `http://localhost:8080` - WebSocket: `ws://localhost:8080/ws` - API Docs: `http://localhost:8080/docs` ## Authentication - Optional HTTP Basic password gate (off by default). When enabled, remote requests need `Authorization: Basic ` (username ignored). Missing or invalid returns `401` with `WWW-Authenticate: Basic realm="MBXHub"`. - Exempt from the gate: localhost, the `/ws` event stream, and the live-PartyMode surface (guests use the `X-Party-PIN` header instead). - Configured only in the plugin (Remote Connection Settings -> Configure Access -> Remote Access); not web/API-configurable. A speed bump for trusted LANs, not transport security: without HTTPS the password is sent base64 (effectively cleartext) on every request. ## Player Control - `POST /player/play` - Start playback - `POST /player/pause` - Pause playback - `POST /player/playpause` - Toggle play/pause (alias: `/player/play-pause`) - `POST /player/stop` - Stop playback - `POST /player/next` - Next track. When the playing file's Comment carries a time-coded setlist (SL-TPS v1.0), skips to the next setlist entry's `startMs` instead of the next queue file; from the last entry falls through to `Player_PlayNextTrack`. Response carries `scope: "setlist"` + `entryIndex` on setlist hits. - `POST /player/previous` - Previous track. Setlist-aware. Within 2s of an entry's start it seeks to the previous entry's `startMs`; after 2s it restarts the current entry. From the first entry within the 2s grace it falls through to `Player_PlayPreviousTrack`. Setlist lookup is cached for 2s so consecutive presses don't re-parse the Comment. - `GET /player/volume` - Get volume (0-100) - `PUT /player/volume` - Set volume (body: {"volume":50} absolute, or {"delta":-5} relative — resolved server-side, clamped 0-100) - `GET /player/shuffle` - Get shuffle state - `PUT /player/shuffle` - Set shuffle (body: {"shuffle":true/false}) - `GET /player/autodj` - AutoDJ state - `POST /player/autodj/start` - Start AutoDJ - `POST /player/autodj/stop` - Stop AutoDJ - `GET /player/repeat` - Get repeat mode - `PUT /player/repeat` - Set repeat (off/all/one) - `GET /player/status` - Full player state - `GET /player/mute` - Mute state - `PUT /player/mute` - Set mute (body: {"mute":true}) - `GET /player/position` - Playback position in ms - `PUT /player/position` - Seek (body: {"position":ms}) - `POST /player/next-album` - Skip to next album - `POST /player/previous-album` - Skip to previous album - `POST /player/queue-random` - Queue a random track (alias: `/player/queuerandom`) ### Audio Processing - `GET /player/equaliser` - Get equalizer state (also `/player/equalizer`) - `PUT /player/equaliser` - Set equalizer - `GET /player/dsp` - Get DSP enabled state - `PUT /player/dsp` - Set DSP enabled - `GET /player/crossfade` - Get crossfade state - `PUT /player/crossfade` - Set crossfade - `GET /player/replaygain` - Get replay gain mode - `PUT /player/replaygain` - Set replay gain mode - `GET /player/scrobble` - Get scrobble (Last.fm) state - `PUT /player/scrobble` - Set scrobble ### Display Settings - `GET /player/stopaftercurrent` - Get stop-after-current state - `POST /player/stopaftercurrent` - Toggle stop-after-current - `GET /player/show-time-remaining` - Show time remaining preference - `GET /player/show-rating-track` - Show rating on track preference - `GET /player/show-rating-love` - Show love rating preference - `POST /player/show-equaliser` - Open the equaliser window in MusicBee (also `/player/show-equalizer`) - `GET /player/button-enabled?button=N` - Check if a UI button is enabled - `GET /player/output-devices` - List audio output devices - `PUT|POST /player/output-device` - Switch output device (body: {"device":"name"}) ## Device & Mixer Control ### Windows Audio Device - `GET /devices/audio/outputs` - List all active Windows audio render devices (name, id, isDefault) - `GET /devices/audio/volume` - Windows audio device volume and mute state - `PUT /devices/audio/volume` - Set Windows audio device volume (body: {"volume":50}) - `PUT /devices/audio/mute` - Set Windows audio device mute (body: {"mute":true}) ### Network Endpoints - `GET /devices/endpoints` - List configured network endpoints. `type` is one of `devialet` (Phantom), `streamsdk` / `fosi` (StreamUnlimited StreamSDK, e.g. Fosi Audio S3) or `linkplay` / `wiim` - `POST /devices/endpoints` - Add a network endpoint (body: {"ip":"192.168.1.50","type":"devialet","name":"Living Room"}). `type` chooses the wire dialect — devialet | streamsdk | fosi | linkplay | wiim. Reachability is probed with that dialect before the endpoint is stored, and an unknown type is refused 400 UNKNOWN_TYPE rather than saved as an endpoint nothing can reach - `POST /devices/endpoints/scan` - LAN discovery scan; bodyless (Content-Length: 0); ~4s blocking. `?protocol=mdns|ssdp|all` (default mdns): mdns sweeps speaker service types, ssdp sweeps UPnP MediaRenderer:1 (WiiM/LinkPlay, Sonos, TVs). Returns [{name, ip, port, hostName, serviceType, alreadyConfigured, existingId, protocol}]; ssdp rows add {model, manufacturer, location}. A row whose `manufacturer` is recognised also carries `kind` (devialet | streamsdk | linkplay - the wire dialect that drives it) and `kindSource` (which rule fired, for diagnosis). Derived from the UPnP description the scan already fetched, so identification adds no request and no probe. An unrecognised device OMITS both fields rather than guessing - absent means unknown, and a human still picks the type. Exact manufacturer match only, never fuzzy: a near-miss abstains rather than producing a confident wrong answer - `DELETE /devices/endpoint/{id}` - Remove a saved endpoint - `GET /devices/endpoint/{id}/volume` - Get endpoint volume and mute state - `PUT /devices/endpoint/{id}/volume` - Set endpoint volume (body: {"volume":50}). 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 `/mixer/volume` does - `PUT /devices/endpoint/{id}/mute` - Set endpoint mute (body: {"mute":true}) - `GET /devices/endpoint/{id}/sources` - List endpoint input sources - `PUT /devices/endpoint/{id}/source` - Select endpoint input source (body: {"sourceId":"upnp"}) ### Mixer Settings - `GET /mixer/settings` - Get mixer settings. `defaultFader`, `pollIntervalMs`, `defaultEndpointId` (what is stored) and `defaultEndpointResolved` ({id,name,type,ip} or null — the endpoint the endpoint fader would actually act on right now, fallback already applied) - `PUT /mixer/settings` - Set mixer settings (body: {"defaultFader":"device"}). `defaultEndpointId` names which configured endpoint the `endpoint` fader acts on; empty string = first in the list. An id matching no endpoint is refused 400 UNKNOWN_ENDPOINT - `GET /mixer/volume` - Get volume from default fader (player, device, or endpoint). Endpoint mode resolves `mixer.defaultEndpointId`, falling back to the first configured entry in `endpoints[]`; soft-falls-back to player if none configured or the speaker is unreachable - `PUT /mixer/volume` - Set volume on default fader. Absolute (body: {"volume":50}) or relative (body: {"delta":-5}), the delta resolved server-side against the active fader's current level — same contract as `/player/volume`, so a first press works before any read has landed and two quick presses cannot race a stale read. Response surfaces the actual `fader` that was hit (matters when endpoint mode falls back to player) - `GET /mixer/mute` - Mute state of the default fader, resolved exactly as `/mixer/volume` is, so volume and mute always land on the same device. `fader` is the one actually hit - `PUT /mixer/mute` - Set mute on the default fader (body: {"mute":true}), or {"toggle":true} to invert whatever the current fader reports without a read-then-write round trip ### Streaming - `POST /player/update-play-statistics` - Apply a caller-chosen count type to a track (body: {"url","countType"?,"disableScrobble"?}; countType: IncreasePlayCount / IncreaseSkipCount / NoChange). Prefer `/player/report-play`, which judges played vs skipped server-side. 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` - `POST /player/report-play` - Report a Listen Here (browser streaming) playback exit (body: {"url","positionMs","durationMs","cueStartMs"?,"cue"?,"abandoned"?,"disableScrobble"?}). `cue: true` marks a CUE-backed source - required for a sheet's FIRST sub-track, whose cueStartMs is 0. Server applies MusicBee's own play-count trigger settings (percent/seconds, read live) and increments play or skip count via the MusicBee API; position 0 records nothing. `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. Counted plays also feed the AutoQ session signal and (when TrueShuffle is enabled) the shuffle played-set + 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 of one file never collide) inside an adaptive window are ignored (`deduped: true`). 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 duration for whole files. Play-stat endpoints share a per-IP rate limit (`rateLimitPlayStatsPerMinute`, 429 when exceeded; loopback exempt). 404 if file not in library or `apiDisablePlayReporting`/`apiDisablePlayCountUpdates` is on ## Now Playing - `GET /nowplaying` - Current track metadata - `GET /nowplaying/artwork` - Album art (binary image) - `GET /nowplaying/lyrics` - Track lyrics. Response: `{ hasLyrics, lyrics, source?, label?, comment? }`. `source` is `"lyrics"` (MusicBee field) or `"comment"` (fallback to the track Comment tag when no real lyrics exist). When `source: "lyrics"` and the track also has a non-empty Comment tag, an additional `comment` field is included so clients can offer a Lyrics/Comment toggle. When `source: "comment"`, `label` is the chip text shown above the body (default "from comment"). Both the sibling `comment` field and the fallback path are truncated to `LyricsFallback.MaxDisplayChars` (default 4000). Configurable via `LyricsFallback.{Enabled, MaxDisplayChars, Label}`; hard-killable via `ApiDisableLyricsFallback` (also suppresses the `comment` field). - `GET /nowplaying/position` - Playback position in ms - `GET /nowplaying/property?type=X` - File property (Size, Bitrate, DateAdded, etc.) - `GET /nowplaying/tag?field=X` - File tag (Artist, Album, Genre, Rating, etc.) - `GET /nowplaying/artwork-url` - Artwork as data URL (base64) - `GET /nowplaying/downloaded-artwork` - Downloaded artwork (binary) - `GET /nowplaying/downloaded-artwork-url` - Downloaded artwork as data URL - `GET /nowplaying/downloaded-lyrics` - Downloaded lyrics from provider - `GET /nowplaying/artist-picture` - Artist picture (binary, ?fadingPercent=0) - `GET /nowplaying/artist-thumbnail` - Artist thumbnail (smaller) - `GET /nowplaying/artist-picture-urls` - Artist picture URLs (?localOnly=false). Response includes a `pictures` array with `src` URLs (serveable via `/nowplaying/artist-pictures/{index}`) in addition to raw file paths. - `GET /nowplaying/artist-pictures/{index}` - Serve current artist's Nth picture as binary image (0-based). Query: ?localOnly=true - `GET /nowplaying/is-soundtrack` - Whether current track is a soundtrack - `GET /nowplaying/soundtrack-pictures` - Soundtrack picture URLs - `GET /nowplaying/spectrum` - Real-time spectrum data (frequency analysis) - `GET /nowplaying/sound-graph` - Waveform graph data - `GET /nowplaying/sound-graph-ex` - Extended waveform graph data - `GET /nowplaying/peak` - Current stereo peak and RMS levels (0.0-1.0). Returns {peak: [L, R], rms: [L, R]}. Requires MusicBee 3.6+ (API rev 58+) ## Queue - `GET /queue` - List queue (?offset=0&limit=50). Each track has an optional `provenance:{driver,reason,whenUtc}` (driver: AutoQ|TrueShuffle|Journey|Station|Manual) stamping who filled it and why; omitted when unknown (older fills / manual MB adds) - `GET /queue/current` - Current track index - `POST /queue/add` - Add tracks (body: {"url":"path","position":"next|last"} or batch: {"urls":["path1","path2"],"position":"last"}) - `POST /queue/playnow` - Play track immediately (body: {"url":"path","cueTrack":3}) - `POST /queue/clear` - Clear queue - `POST /queue/move` - Reorder (body: {"from":0,"to":5}) - `DELETE /queue/{index}` - Remove track by index - `POST /queue/play` - Play track at index (body: {"index":3,"cueTrack":1}) - `GET /queue/next-index?offset=1` - Get index N tracks ahead/behind - `GET /queue/has-prior` - Whether queue has prior tracks - `GET /queue/has-following` - Whether queue has following tracks - `POST /queue/play-library-shuffled` - Clear queue and play library shuffled - `GET /queue/{index}/url` - Get file URL for track at index - `GET /queue/{index}/tag?field=X` - Get tag for track at index - `GET /queue/{index}/property?type=X` - Get property for track at index ### Virtual Tracks (CUE) Virtual Tracks share the same audio file URL. The queue response includes Virtual Track metadata: - `cueTrack` - Track number when detected - `cueStartMs` - Start offset in milliseconds within the physical file - Track titles from CUE sheet (not embedded audio metadata) - Include `cueTrack` in add/playnow to preserve track identity - `GET /nowplaying` also returns `cueTrack` + `cueStartMs` for the current track; browser-mode clients seek the container to `cueStartMs` and pass it through to `/player/report-play` ### CUE Track Resolution MBXHub detects CUE-backed audio files and resolves per-track metadata automatically across all surfaces: - `GET /player/status` - Returns CUE track title, artist, trackNo, cueTrack field - `GET /nowplaying/position` - Includes `cueTrack` and `cueTitle` when active - `GET /nowplaying/tag?field=TrackTitle` - Returns CUE track title (not base file) - WebSocket `TrackChanged` events include `cueTrack` and `cueStartMs` fields when CUE active - Dashboard shows resolved CUE track metadata - Listen Here streaming: seeks to `cueStartMs`, shows track-relative progress, auto-advances at track end CUE parsing uses a built-in regex parser with encoding detection (BOM, UTF-8 validation, Windows-1252 fallback). No external CUE library is loaded or required. ## Library - `GET /library/files` - Query files (?query=&artist=&albumArtist=&album=&genre=&people=&offset=&limit=&sort=). `?people=X` on an album-scoped query (`&album=Y`) now post-filters across MB's full role-union — `Artists` (144 — Artist + Performer + Guest + Remixer), `Composer` (43), and `Conductor` (45). This catches feat./guest credits that MB's `ArtistPeople` XmlFilter omits from the Guest role bucket (e.g. David Guetta - "The Whisperer (feat. Sia)" where Sia is tagged GuestArtist). Whole-library `?people=` without `?album=` still uses `ArtistPeople` for speed and continues to miss pure-guest credits. Additional params: `?include=` attaches per-track value of a tag for display (e.g. the active sort field when expanding an album under a custom-* sort) — accepts any tag from the 48-tag customSorts allowlist (`Custom1`..`Custom16`, `Virtual1`..`Virtual25`, `Year`, `OriginalYear`, `Sort*`); unknown values silently ignored. Each returned row gets `extraField` + `extraValue` when set. `?roles=true` adds every per-track role-credit field MB tracks separately — `artistsRole`, `performers`, `guests`, `remixers`, `composers`, `conductors`, `artistsUnion` — populated raw from `Library_GetFileTag`. Default off; only paid for when requested. `?setlist=hint` emits a `hasSetlist:bool` per row — true when that track's Comment tag matches the time-coded setlist heuristic (≥3 monotonically increasing entries; see `/library/file/{urlHash}/setlist` for the full accepted-formats grammar, SL-TPS v1.0). Drives the SET LIST badge on the dashboard and the Tracklist tab on `nowplaying.html`. Opt-in to pay the per-row Comment + regex cost; default off. `?sort=year` (and the year/date track sorts on album expansion) extract the 4-digit year from malformed Year tags before sorting — full dates like `20180727`, `2024-01-01`, `5/27/2014`, `1999/2000` sort by their year component instead of as integers ten million high. Sort key only; stored tags and displayed values untouched. Toggle off with the **Disable malformed-Year sort normalization** Advanced setting. - `GET /library/artists` - List artists (?offset=&limit=&sort=) - `GET /library/albums` - List albums (?artist=&offset=&limit=&sort=) - `GET /library/albums/detailed` - Albums with firstTrackUrl, year, dateAdded for artwork lookups and sorting (?offset=&limit=) — eliminates per-album /library/files round-trips. Each album row includes `virtualKind` (`"cue"` | `"setlist"` | `null`) indicating whether the album is a CUE-split or setlist virtual tracklist. - `GET /library/album-artists` - List album artists (?offset=&limit=) - `GET /library/albums/by-artist` - Albums for an artist (?albumArtist= or ?artist=, &sort=alpha|year|year-asc). ?artist= queries ArtistPeople (broader), ?albumArtist= queries AlbumArtist (exact credit). Each album row includes `virtualKind` (`"cue"` | `"setlist"` | `null`). - `GET /library/albums/unheard` - Albums where all tracks have playCount=0 (?offset=&limit=) - `GET /library/albums/with-pdf` - Albums that contain PDF booklets (?offset=&limit=) - `GET /library/albums/with-video` - Albums whose folder contains a video file (derived from the Video library, Source Type 64, matched by folder) (?offset=&limit=) - `GET /library/genres` - List genres (?sort=) - `GET /library/inbox` - Files in MusicBee Inbox (Source Type 4). Progressive: browse tab hidden if empty - `GET /library/audiobooks` - Audiobook files (Source Type 32). Progressive: browse tab hidden if empty - `GET /library/videos` - Video files (Source Type 64). Progressive: browse tab hidden if empty - `GET /library/search?q=term` - Full-text search over title/artist/album/genre. Two modes: **strict** (default) requires the typed phrase to appear as a contiguous run of whole words in a single field — "st anger" matches the album "St. Anger" (including mid-typing partials like "st an") but NOT "Stranger" or "Strange Anger"; **substring** is loose — any query word appearing as a substring passes ("anger" matches "Stranger"). Default mode from `Library.DisableStrictSearch` setting (false=strict, shipped default). Override per-call with `?substring=true|false`. Diacritic and punctuation normalized. Response includes `mode: "strict"|"substring"`. Also supports `?sort=`. Add `?dsl=true` to route the query through `SearchDslParser` (qualifier syntax — `artist:`, `album:`, `genre:`, `year:`, `rating:`, `fmt:`, range / boolean / grouping); cheat sheet at `GET /library/search/syntax`. Backstop: queries shorter than `search.live.minQueryLength` (default 2) return `400 QUERY_TOO_SHORT` with `{error: "Query must be at least N characters (got M)."}` — single-char walks stall every other MB-API consumer for the duration of the cursor lock, so this protects fleet-wide responsiveness from runaway non-conforming clients (curl, scripts). - `GET /library/search/syntax` - Returns the DSL grammar as JSON (qualifiers, operators, worked examples). 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. Authoritative source — the reference below is derived from the same data. Used by Cmd+K's cheatsheet and any other client surfacing DSL syntax to users. #### Query DSL Reference The DSL layers on top of free-text: 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`. **Qualifiers** (22 total): | Qualifier | Type | Range/Ops | Maps to | Notes | |---|---|---|---|---| | `artist:` | string | — | ArtistPeople | Includes featured / album artists | | `album:` | string | — | Album | | | `albumartist:` | string | — | AlbumArtist | Compilation-aware | | `genre:` | string | — | Genre | Multi-value tags split client-side | | `year:` | int | range + ops | Year | `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) | `rating:>=4`, `rating:3..5` | | `loved:` | bool | — | Loved tag | `loved:true` / `loved:false` | | `bpm:` | int | range + ops | Tempo (Truedat/Essentia) | Requires fingerprint or mood-cache data | | `mood:` | string | — | AutoQ mood channel | Channel name (e.g. `mood:chill`). **Post-filter** | | `vibe:` | float | range + ops | AutoQ vibe score | 0–1. `vibe:>=0.7` | | `arousal:` | float | range + ops | AutoQ arousal (energy axis) | 0–1. `arousal:>0.7`, `arousal:0.7..1.0`. SMFM contributes the same axis as a 2nd source. **Post-filter** | | `valence:` | float | range + ops | AutoQ valence (bright/dark axis) | 0–1. `valence:<0.4`. **Post-filter** | | `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 | ISO (`2025-01-01`), relative (`now-7d`), or named alias | | `played:` | date | range + ops | DateLastPlayed | Same date format as `added:` | | `playcount:` | int | range + ops | PlayCount | `playcount:>10`, `playcount:0` (never played) | | `duration:` | int (seconds) | range + ops | Duration | `duration:>3600` (over an hour) | | `path:` | string | — | FilePath substring | Case-insensitive substring match on full path | | `type:` | string | — | file extension | **Post-process** — exact, case-insensitive match on the file extension (`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 (`key:9A`, `key:11B`). Scanned tracks only — no key data never matches. Combine wheel-compatible codes with `OR` | | `lyric:` | string | — | lyrics body | **Post-process**, expensive. Gated on `library.search.dsl.allowLyricSearch = true` (default off) | **Any-field / custom slots**: beyond the 21 named qualifiers above, the field registry 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`). These dynamic slots are NOT listed by `GET /library/search/syntax` (which returns only the 21 static qualifiers). Mood-position search uses the first-class `arousal:`/`valence:` range qualifiers (above), which filter on the AutoQ V/A; SMFM contributes the same axes as a second source. **Operators**: | 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 (low..high) | Qualifiers with `allowsRange` | | `-` (prefix) | exclude | 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 | **Examples**: | 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 — two range/op qualifiers (implicit AND) | | `mood:chill rating:>=4` | Chill-mood tracks rated 4 or higher — mood is post-filter | | `rock -genre:metal` | Rock tracks excluding metal — free-text plus exclusion | | `(rock or metal) -live` | Rock or metal, but not live recordings | | `source:audiobooks duration:>3600` | Audiobooks longer than one hour | | `played:5` | Favorites you haven't played in the last 30 days | | `decade:80s -genre:disco` | 80s, no disco — decade enum plus genre exclusion | **Composition rules**: - Default between terms is AND. Use `OR` (or lowercase `or`) for disjunction; parentheses for grouping. - **Where a grouped query runs.** A query that is ONLY a group (`genre:rock OR genre:metal`) is handed to MusicBee's engine whole and runs there. A group placed BESIDE another condition (`artist:davis (genre:rock OR genre:jazz)`) is evaluated in the post-filter instead: MusicBee silently drops the sibling condition when a group sits next to it — measured 2026-08-18 on a ~7.5k library, where `artist:davis` alone matched 0 tracks yet `(genre:rock OR genre:jazz) artist:davis` returned 3385. Same results either way; the difference is that the post-filter path is capped, so a query producing more candidates than `library.search.dsl.maxPostFilterCandidates` answers `413 POST_FILTER_CAP_EXCEEDED` instead of quietly returning the wrong set. Add a selective condition, or write the group on its own. - Free-text terms work alongside qualifiers — `radiohead year:>=2000` filters Radiohead from 2000 onward. - Post-filter qualifiers (`mood`, `playlist`, `lyric`) run after candidate selection, capped by `library.search.dsl.maxPostFilterCandidates` (default 5000). - Unknown qualifiers return `422 INVALID_DSL` with parse position and Levenshtein-based "did you mean" suggestions. - Date format: ISO (`2025-01-01`), relative (`now-7d`, `now-30d`), or named alias from `library.search.dsl.dateAliases` (defaults: `lastweek`, `lastmonth`, `thisyear`). - `GET /search?q=&buckets=&limit=&cursor=` - 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. Cursors are HMAC-stamped + TTL-checked (stale → 410, malformed → 400, query-mismatch → 400). Backs the Cmd+K palette, the dashboard search bar, player.html, browse.html, and explore.html. **DSL routing:** auto-detects DSL queries the same way `/library/search` does (qualifier colons, `..` ranges, `>`/`<`, ` OR `, leading `-`). When detected and `Search.Dsl.Enabled=true` (default), routes through DSL pipeline and response carries `mode: "dsl"`. Per-call `?dsl=true` explicit override still works. Plain free-text queries continue to use strict/substring mode. **Tracks bucket relevance-sorted** by match strength (title > album > artist, with prefix-match bonus) regardless of mode. Backstop: same `search.live.minQueryLength` (default 2) check as `/library/search` — short queries return `400 QUERY_TOO_SHORT`. Per-bucket `?limit=` caps at 500 so browse.html's client-side aggregation gets the full intended slice. - `GET /system/search-index` - Live engine + index state snapshot. Currently reports `engine:"mb"` (the seam is preserved for future search engines). - `GET /library/file/{url}` - Get file metadata - `PUT /library/file/{url}` - Update tags (body: {"Artist":"name","Title":"name"}) - `GET /library/files/raw` - Raw file path list (no metadata, faster) - `GET /library/cuetest?query=` - Test CUE track resolution for a query - `POST /library/add` - Add file to library (body: {"url":"path"}) - `POST /library/artwork/batch` - Batch artwork fetch (body: {"urls":["path1","path2",...]}, max 50). Returns base64 data URIs keyed by URL, null for missing artwork - `POST /library/find-device-ids` - Find device persistent IDs (body: {"urls":[...]}) - `POST /library/sync-delta` - Get sync delta (body: {"deviceId":"...","since":"ISO date"}) - `POST /library/commit` - Commit pending tag changes to file (body: {"file":"path"}). Use after batching Library_SetFileTag RPC calls - `GET /library/evaluate?expression=&fileUrl=` - Evaluate MusicBee expression (template syntax: , $If(), virtual tags). fileUrl optional, defaults to now-playing. - `GET /library/no-artwork` - MusicBee's placeholder image for tracks with no artwork (binary). Cache-Control: 24h. - `GET /library/file/{url}/lyrics` - Lyrics for specific file - `GET /library/file/{url}/artwork` - Artwork for specific file (binary) - `GET /library/file/{url}/artwork-url` - Artwork as data URL for specific file - `GET /library/file/{url}/artwork-count` - Returns `0` (no artwork) or `1` (artwork present). Probes up to 20 embedded-artwork LOCATIONS and picks the largest byte-size as the canonical image; `/artwork?index=0` then serves that variant. Cached per fileUrl. - `GET /library/file/{url}/pdf` - PDF booklet from track's album folder (binary, application/pdf) - `GET /library/file/{url}/fan-art` - List all images in the track's album folder and one level of subfolders. Returns {folder, images:[], count}. Excludes: canonical primary-cover filenames (folder/cover/front/album × .jpg/.jpeg/.png) — duplicates of the primary artwork served by `/artwork`; ALL Windows Media Player cache files (AlbumArtSmall*, AlbumArt_*); thumbnails (<5KB); Thumbs.db; desktop.ini. Security: track must be in MusicBee library. - `GET /library/fan-art/{path}` - Serve a single fan art image by absolute path (binary). Security: image must be in a directory containing a MusicBee library file. Cache-Control: 1 hour. - `GET /library/file/{url}/videos` - 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 on `nowplaying.html` Extras and the Videos section on `explore.html` expanded album view. Security: track must be in MusicBee library. - `GET /library/video/{path}` - 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). Same library-scope check as `/library/fan-art/{path}` — the path's directory (or its parent) must contain at least one MusicBee library file. Cache-Control: 1 hour. - `GET /library/file/{url}/has-pdf` - Check if PDF booklet exists (returns {hasPdf: bool, url: string}) - `GET /library/file/{url}/device-id` - Device persistent ID - `PUT /library/file/{url}/device-id` - Set device persistent ID - `GET /library/file/{urlHash}/setlist` - Returns the parsed time-coded set list embedded in a track's Comment tag. Response: `{ fileUrl, fileDurationMs, entries:[{index, startMs, title, rawTime, cueTime}], trailingNotes, parsedAt, extractedCueExists, extractedCuePath }`. Each entry carries `rawTime` (the matched timecode verbatim, e.g. `"0:40"`) and `cueTime` (CUE MM:SS:FF form, 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 is unreliable). Returns `404 NO_SETLIST` if the Comment doesn't match the heuristic. **Accepted line formats** (SL-TPS v1.0): each candidate line is tested independently (multiline `^…$`), with optional leading bullet (`-` / `•` / `*`) and optional decorative index prefix (digits, optional `.` or `)`, optional `-` / `–` / `—`). Then one of two core shapes: **time-first** `[HH:]MM:SS [optional separator] TITLE` or **time-last** `TITLE [optional separator] [HH:]MM:SS`. The optional separator accepts `-`, `–`, `—`, or `:` followed by whitespace (the colon variant is common on YouTube-comment paste-throughs, e.g. `02:46 : Last Train`). The timestamp may also be wrapped in parens, e.g. `(0:11) Live In The Moment` (common on YouTube/Reddit comments); title-internal parens are preserved verbatim. Hours 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. Lines that don't match are silently skipped. Detection rules: ≥3 matched lines (configurable `MinEntries`), timestamps strictly monotonic increasing, optional duration sanity (last timestamp must be before file duration when known). `{urlHash}` is computed from the track's `url` (the `url` field from `/library/files` or `/nowplaying`): `base64url(sha1(utf8(url))[0:16])` with `=` stripped → 22 chars. Browser clients use `MBXShared.urlHashOf(url)` (shared.js), byte-equivalent on secure and non-secure LAN-HTTP contexts. - `POST /library/file/{urlHash}/extract-setlist-cue` - Body: `{ overwrite: bool }` (default false). Parses the Comment via the same heuristic, builds a real CUE sidecar at `.cue` (UTF-8 with BOM so `CueParser.DetectEncoding` round-trips), atomic write via tmp (per-call guid suffix) → `File.Replace` → `.bak`. Invalidates `AlbumIndex` so the next browse / search / NP read returns the virtual tracks. Returns `200 OK { cuePath, entriesWritten }`. Errors: `409 CUE_EXISTS` (pre-existing CUE, `overwrite=false`), `400 NO_SETLIST`, `500 WRITE_FAILED`. Gated by `AccessControl.LibraryTags` so PartyMode guests cannot write. Duration sanity: detector rejects setlists whose last timestamp lands past the file's duration. Deep-link query params on the standalone pages (no new endpoints): - `/pages/nowplaying.html?tab=tracklist` - Opens the page with the segmented `[Lyrics | Comment | Tracklist]` control switched to the Tracklist tab on the first successful setlist load. Used by the dashboard SET LIST badge. - `/pages/play.html?np-tab=tracklist` - Same intent for the full-player chrome variant: appends `&tab=tracklist` to the middle-pane nowplaying iframe src so the embedded NP page lands on the Tracklist tab. Used when `DashboardLayout.liveSetBadgeTarget` is set to `"play"`. - `/pages/browse.html?album=&artist=&trackUrl=` - After the album drilldown renders, the watcher polls for the row whose `data-url` hashes to `` (via `MBXShared.urlHashOf`) and scrolls it into view with a brief accent pulse. Generated by the Now Playing right-click "Copy deep link" item. - `GET /library/artist/{name}/similar` - Similar artists (?limit=10) - `GET /library/artist/{name}/picture` - Artist picture (binary) - `GET /library/artist/{name}/thumbnail` - Artist thumbnail (smaller) - `GET /library/artist/{name}/pictures` - Artist picture URLs (?localOnly=false). Response includes a `pictures` array with `src` URLs (serveable via `/library/artist/{name}/pictures/{index}`) in addition to raw file paths. - `GET /library/artist/{name}/pictures/{index}` - Serve artist picture by index as binary image (0-based, from /pictures list). Query: ?localOnly=true - `GET /library/recent` - Recently played tracks sorted by last played descending (?limit=50&offset=0&days=30) - `GET /library/videos` - Video files from MusicBee's Video library node. Returns `{total, videos: [{url, title, artist, album, kind, duration}]}`. Title falls back to filename when tag is empty. Uses Source Type 64 query ## Saved Search & History ### Saved searches Persisted query under a name, evaluated on a schedule. Backed by `mbxhub-search.json` next to `mbxhub.json`. Enabled by default; toggle via `library.search.savedSearch.enabled`. Disabled endpoints return 404 NOT_FOUND. **All write/mutation routes return 403 FORBIDDEN when `ApiReadOnlyMode` is set.** - `GET /search/saved` - List all saved searches `{searches:[...], total}` - `POST /search/saved` - Create. Body `{name, query, delivery?, schedule?, mbPlaylist?}`. Server generates id. 422 INVALID_DSL on parse error (with parse position); 409 on duplicate name (case-insensitive, per host); 422 INVALID_NAME if name missing or > 80 chars - `GET /search/saved/{id}` - Fetch one - `PUT /search/saved/{id}` - Update name / query / delivery / schedule / mbPlaylist - `DELETE /search/saved/{id}` - 204 on success, 404 if unknown - `GET /search/saved/{id}/results` - Evaluate now; returns Track B response shape (matched URLs + count) - `POST /search/saved/{id}/run` - Force re-evaluation; returns diff vs `LastMatchUrls`. Drives the `SearchMatched` WS broadcast (D2) ### Search history Server-side recent-search log shared by Cmd+K and any other search bar. Falls through to client localStorage if 404. Mutations gated on `ApiReadOnlyMode`. - `GET /search/history?limit=N` - Newest-first. `limit` defaults 20, caps at 200 - `POST /search/history` - Body `{query, source}`. Empty body clears (returns `{cleared:true}`) - `DELETE /search/history` - Wipe all entries ## Radio - `GET /radio/stations` - List radio stations (returns `{total, stations: [{url, name}]}`) ## Playlists - `GET /playlists` - List all playlists - `POST /playlists` - Create playlist (body: {"name":"My Playlist","files":["path1","path2"]}) - `GET /playlists/{url}/files` - Get playlist tracks - `POST /playlists/{url}/files` - Add tracks (body: {"urls":["path1","path2"]}) - `POST /playlists/{url}/play` - Play playlist - `GET /playlists/{url}` - Get playlist details - `PUT /playlists/{url}` - Update playlist name - `DELETE /playlists/{url}` - Delete playlist ## WebSocket Connect to `ws://localhost:8080/ws` for real-time events: ```javascript const ws = new WebSocket('ws://localhost:8080/ws'); ws.onmessage = (e) => { const msg = JSON.parse(e.data); // msg.event: TrackChanged, PlayStateChanged, VolumeChanged, etc. // msg.data: event-specific payload // msg.timestamp: ISO 8601 timestamp }; // Subscribe to specific events: ws.send(JSON.stringify({ subscribe: ['TrackChanged', 'PlayStateChanged'] })); ``` Event types: - `TrackChanged` - {"fileUrl":"...","title":"...","artist":"...","album":"...","duration":ms,"artworkUrl":"/nowplaying/artwork","cueTrack":N,"cueStartMs":ms} (cueTrack + cueStartMs present only when CUE active) - `PlayStateChanged` - {"state":"playing|paused|stopped"} - `PositionChanged` - {"position":ms,"duration":ms} - `VolumeChanged` - {"volume":0-100,"muted":bool} - `ShuffleChanged` - {"enabled":bool} - `RepeatChanged` - {"mode":"none|all|one"} - `TempoChanged` - {"file":"..."} - MB 3.5+ playback-tempo notification relay (no tempo value in the MB API; observation event) - `QueueChanged` - {"action":"changed","index":-1,"totalTracks":-1} - `MetadataChanged` - {"fileUrl":"...","rating":-1 to 5,"love":"L|B|"} - `Reaction` - {"emoji":"fire","type":"fire","nickname":"Guest","trackTitle":"...","trackArtist":"..."} - `TasteChanged` - {"topGenres":[...],"topArtists":[...],"bpmRange":[lo,hi],"mood":"...","moodConfidence":0.0-1.0,"influenceCount":N,"reactionCount":N} - `ThemeChanged` - {"activeMode":1|2,"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} - `SearchMatched` - Periodic-evaluator broadcast when a saved search's match set changes. `{"id":"...","name":"...","added":[urls],"removed":[urls],"total":N,"evaluatedAt":"ISO8601"}`. Driven by `SavedSearchScheduler` ticking each saved-search interval; only fires when the diff is non-empty - `MoodCacheReady` - One-shot broadcast when the cold mood cache finishes loading. `{"entries":N}` (count of moods now in cache). Dashboard's WS handler calls `softUpdate` on receipt to fill the mood pill without a polling fetch. Fires once per plugin start, immediately after MoodCache phase 3 (valence/arousal) completes - `PartyStateChanged` - Party mode started or ended. `{"isActive":bool}`. Fired from StartParty/StopParty (REST endpoint or WinForms dialog). Already-open pages (player.html) redirect to `/pages/partymode/` the instant a party starts, instead of polling `/partymode/status`; fresh loads are handled server-side by a 302 redirect ## Static Pages (/pages/) MBXHub serves HTML, CSS, and JS from the `/pages/` directory. ### How It Works - Files location: `%APPDATA%\MusicBee\MBXHub\pages\` - Access via: `http://{host}/pages/yourfile.html` - Supports: .html, .css, .js, .json, images - No build step - just save and refresh ### Included Pages Read these for working reference code: - `GET /pages/index.html` - Landing page listing available views - `GET /pages/player.html` - LEGACY (kept serving for bookmarks/default-page users; receives no new features - superseded by the device-adaptive play.html). Full 3-column player (browse iframe, now playing, queue) with drag-drop queue reorder and touch support - `GET /pages/nowplaying.html` - Focused standalone now-playing view. Drag-drop queue reorder on upcoming tracks. Auto-stacks below 800px into three collapsible blades (Now Playing, Queue, Lyrics). - **Now Playing has two states**, never fully collapses to header-only: **Expanded** (~50% viewport, full artwork + reactions) and **Compact** (~52px header bar with mini artwork thumb + title + artist + transport buttons: prev / play-pause / next, all hitting `/player/previous` `/player/playpause` `/player/next`). The play/pause icon mirrors `isPlaying` driven by the `PlayStateChanged` WebSocket event. - **Queue and Lyrics use last-touch-wins**: at most one is Expanded at a time. Tapping a collapsed Queue or Lyrics header expands it and auto-collapses the peer to its header bar. - Floating 6px progress bar fixed at viewport bottom drives seek via click -> `PUT /player/position`. Position fill driven by the `PositionChanged` WebSocket event with a 30s `/nowplaying/position` poll as a tab-visibility-gated safety net. - All blade-collapse / transport markup is gated by the stacked layout (typical at tablet width or fullscreen browser). Charm-tab / iframe-mode rendering is unchanged - gated by `body.iframe-mode` and `html.embedded` rules. - `GET /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 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. `hud.dashboardPage` (default `/dashboard`) sets where the HUD's `Open full dashboard` menu entry goes - root-relative only, anything else falls back to the default. In a narrow overlay the Ctrl+K palette temporarily widens the window while open. `/pages/overlay.html` is an alias. The HUD can also be a DRAWER (tray: `HUD as a drawer`, off by default and remembered PER VIEW; turning it OFF clears every view so off means off, and the tray tick reads the current view each time the menu opens). ON: the HUD rests off the screen edge with a small THUMB showing at the middle of that edge — a pill with a gripper, a chevron and a pin, in the page's theme colours — comes out when the pointer rests on the thumb, and slides back a couple of seconds after the pointer leaves; the pin keeps it out, and a pinned drawer also ignores Esc, because Esc is the fastest way to lose a window you are working in. OFF: an ordinary window, and the thumb (pin included) is not shown at all. Only the thumb wakes it, not the whole edge, so a tall HUD does not open every time the pointer goes past. The edge is chosen for the monitor: one with empty desktop beyond it, so on a screen with another monitor to its right the drawer docks LEFT. What gets remembered is where it sits when it is OUT, never where it rests, or it would reopen off-screen. Focus buys time before it retreats, not immunity — a drawer you can only shut by closing it is not a drawer. See the Overlay section below for what a page must do to behave well when hosted. When hosted by the Shell the HUD has three SIZES, cycled by a chevron in its chrome: `full` (the whole HUD), `player` (a wide short bar - artwork, track, transport, volume, progress hairline) and `transport` (transport and mute only). The size is CHOSEN, not inferred from width - the page's separate narrow-width pass is a different thing. `overlay.mode` remembers the last size and `overlay.bounds` / `overlay.bounds.player` / `overlay.bounds.transport` each remember that size's own window rectangle, so summoning returns the HUD exactly as it was left. There is no second hotkey: every standard Windows shortcut nearby means minimize, and this is minimal rather than minimized. Ctrl+K from a compact size returns to `full` first, because the palette needs vertical room for results. In an ordinary browser the chevron does not appear - a page in a tab cannot resize its window. The HUD can also be dragged by its own content (the `MBXHub` name, the artwork, the compact bar) rather than only by the host's invisible edge band. LISTEN HERE in the HUD: one switch at the right end of the Services row in the browse view (full mode only; nothing is added to the compact bar), speakers (room) <-> browser (this device), shown when `/system/features` says streaming is on. It is 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, the 🎧 in the HUD's browse pane and its queue verbs drive a local queue and MusicBee stays silent. The choice is remembered per HUD (`localStorage["mbxhub-hud-output"]`), separately from play.html's. - **Fixed Browse / Playing / Queue selector** (pill-behind-glyph) - the three views are always one tap apart, and the target bar above them names the target with volume and an inline pause off the Playing view. - **Track-details (i) view** off the target bar - shows only what the track actually has: lyrics, comment, set list, fan art, video, similar. Registry-driven, one chip per source that has content, and the button itself is absent when there is nothing to show. - **Playing view** - prev / play-pause / next transport and the reactions (fire / heart / downvote inline, upvote / ban in overflow) sit beside the art, with Mode (mood / flow / ON-AIR) one tap away via the shared radio strip. - **Browse view** - a play-first Services grid above the embedded browse page: start a station or a program, reach devices. - **Mini-strip** appears whenever you leave the Playing view, so transport stays reachable from Browse and Queue. Queue shows count + total time, tap-to-play-from-here and a two-tap clear. - `GET /pages/browse.html` - Library browser: 8 tabs (Albums/Artists/Genres/Playlists/Tracks/Podcasts/Radio/Moods) with responsive tab sizing, drilldown and breadcrumbs, diacritic-normalized fuzzy search with 8-category grouped results, album art grid with lazy loading, batch queue via long-press, playlist picker, search-driven Tracks tab via /library/search, video items with direct play (no drilldown), auto-search fallback (when inline name filtering yields no results, auto-triggers full-text server search), light/dark theme - `GET /pages/config.html` - Settings and configuration interface for dashboard and AutoQ - `GET /pages/autoq.html` - AutoQ Tuning Console: mixer-style sliders for all scoring weights, reaction scores, and normalization ranges - `GET /pages/autoqworkbench/{faders|live|builder|stations|programs}.html` - AutoQ Workbench: five single-purpose pages that make up one workbench. Each is reachable on its own and is one entry on the `autoqworkbench` charm (not device-specific, so it ships visible - it is NOT in the `charmBar.hidden` defaults). No page adds a REST endpoint - all five ride existing surfaces. - `faders.html` - The tunings mixing console. Vertical faders for `scoringWeights` (7), `reactionScores` (5), `influenceScores` (4) and the `estimation` normalization ranges (14), grouped exactly as `GET /autoq/settings` groups them. 400ms-debounced `PUT /autoq/settings` of the whole mutated object (`moodChannels` / `estimation` / `genreProfiles` round-trip untouched); the save indicator is judged from the PUT response only (`saving…` → `saved ✓` / `save failed`); Reset-to-server cancels a pending save and refetches. The normalization rack dims and disables while `estimation.usePercentileNormalization` is on, the same inert treatment autoq.html gives those fields. - `live.html` - ON-AIR. Track header with artwork and a metadata sentence, a Valence/Arousal plot (12 mood channels, target ring, current-track dot), a Camelot ring lighting the harmonic neighbours (relative + ±1 hour), reactions (fire / heart / downvote inline, upvote / ban in overflow → `POST /autoq/react {emoji, nickname:"Host"}`; ban chains a bodyless `POST /player/next` with `Content-Length: 0`), and Mode via `MBXShared.attachRadioStrip`. 5s poll of `/nowplaying` + `/autoq/radio` + `/autoq/track-mood` with 1s interpolation; ms → seconds converted once at the fetch boundary. The queue-order why-trail is NOT available over REST (`AutoQDialog.HarmonicTrailFor` is plugin-internal), so the page renders only what `/autoq/radio` + `/autoq/track-mood` provide and says where the trail lives. - `builder.html` - Stage-then-Apply queue building. `GET /library/search?q=` (flat `data.tracks`; minimum query length 2, mirroring the server's own `QUERY_TOO_SHORT` gate) feeds a results list with a `+ Stage` verb per row; staging is local, and Apply posts the whole stage as ONE `POST /queue/add {position:"last", urls:[...]}`. Apply is the only queue-write verb. Clear empties the stage only (two-tap armed), Reload is user-driven, there is no auto-refresh anywhere, and the queue below is read-only with a current-row pill. - `stations.html` - Saved stations. Flow + seed count per row, Play (`POST /autoq/stations/{id}/play`), inline rename (Enter commits `PUT {name}`; Escape and blur revert), two-tap-armed `DELETE`, and "Save current run" (`POST /autoq/stations/from-run {name}`). There is no name-only create button: `POST /autoq/stations` requires `seedUrls` or a journey. - `programs.html` - Saved programs, i.e. sequences of stations. Entry count per row, Play (`POST /autoq/programs/{id}/play`), inline rename (`PUT {name}`), two-tap-armed `DELETE`. Management only - `POST /autoq/programs` requires at least one entry whose `stationId` resolves, so programs are created elsewhere and managed here. - `GET /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. Charm bar integration via postMessage - `GET /pages/matrix.html` - OREI BK-808 HDMI matrix charm (matrix.json). Audio-extraction-first: external-audio-out input select (Off/1-8) + SPDIF mode (bind-to-input follows video / bind-to-output / independent audio matrix), then per-output video routing (8 rows), route-to-all, scene recall/save (save is two-tap arm-confirm), power, status poll. Input/output names editable, stored browser-local. Device IP in localStorage; all control via POST /api/proxy {method:POST, url: http://ip/cgi-bin/instr, body:{comhead...}} — private-LAN only, tolerates the firmware's EF-prefix-before-JSON quirk. 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 its URL - `GET /pages/rail.html` - The Charm Bar (the desktop one — the dashboard's charm strip is a different thing that shares the name): charm icons, read from `/charms/pages`, in a window the MBXHub Shell docks flush to ONE edge of ONE monitor (an edge belongs to that display, never the virtual desktop). Which edge is `overlay.railSide` (tray: Settings > Charm Bar): `left` or `right` gives a vertical column, `top` or `bottom` a horizontal ROW — the same page, laid out along the other axis, with the arrows pointing sideways and Left/Right walking the icons; the work area excludes the taskbar, so a bottom bar sits above it. Clicking an icon opens that charm as its own desktop window growing out of the icon; clicking it again closes it. It is a wrapping grid: at its natural width that is one column (or one row), and dragging it wider (taller) reflows the icons into more — a rectangle or a square if you like. Its length is its ICONS, not the screen — the page measures the icons' extent along the live axis and asks the host via `overlay.contentSize` (`overlay.contentHeight` still goes out for a vertical bar, so an older Shell keeps working); once you have resized it yourself the ask is advisory and your shape stands. Which charms it shows is `charmBar.rail` (empty = all; the `R` box in the web settings charm editor). The FIRST icon is MusicBee — open or focus the player itself, right from the bar — wearing MusicBee's REAL icon when the Shell can extract it from the resolved MusicBee.exe (a drawn bee otherwise; the host posts it as `overlay.appIcon`) — a LOCAL DESKTOP verb, not a hub charm: the host runs the same open-or-focus as the tray's bold Open MusicBee, on the machine the Shell is on, whatever hub the bar targets (a hub call would raise MusicBee on the target's machine; deliberately one hardcoded verb, not an app-launcher registry — that generalization is a named slope and stays parked). SECOND is the HUD — pinned by the page when a Shell hosts it, id `hud`, summoning/dismissing the real HUD window (not a second copy of the page) and lit while the HUD is up; it sits outside the one-at-a-time rule. THIRD is the overlay-sets icon (`sets`, an overlay frame carrying list lines): it summons the real View Switcher — the list of saved sets — never a compositor copy of switcher.html. App icons carry no window, so the windowState echo never marks them. The bar and every charm window it opens FOLLOW HUD TARGETING (`overlay.target`, tray: Settings > HUD Target), LIVE: changing the target re-points the bar and every open charm window in place — geometry, marks and warm state kept — and the bar re-reads `/charms/pages` from the new hub, so the charm list, membership, order and icons are all the TARGET's (HUD=CHARM for targeting). The transient View Switcher is simply rebuilt on its next summon. The legacy `player` charm sits LAST by default (the dashboard's `charmBar.order` has it mid-row; the bar is its own surface) — a named `charmBar.rail` list is the only override. An outlined icon means that charm's window is ON SCREEN (the host tells the bar; closing a window by its own ✕ clears the mark too). One charm window at a time: a plain click closes the others and shows this one; Shift+click shows it alongside; clicking the lit icon closes it. Mouse clicks never activate the bar (the first click lands on the icon, and opening a charm over a game leaves the game's keyboard where it was) — but a deliberate SUMMON focuses it, first icon focused: `Ctrl+Alt+B`, arrows walk, Enter opens, Esc puts it away — a launcher you can drive without touching the mouse. If there are more charms than fit, arrows at each end scroll them (present only while there is somewhere to go). Shell only in practice: in an ordinary browser tab there is no window to dock or open, so the icons render and nothing happens. It skins from `theme.overlayThemeMode` — the SAME slot as the HUD, so the two windows sitting next to each other never disagree; the bar has no theme JS of its own, so it takes a theme change on its next load rather than live. When the hub is NOT running (MusicBee closed), every overlay window draws the host's own offline page instead of the browser's error — MusicBee's icon, the address it is waiting for, and (when the window points at the Shell's own hub) a Start MusicBee button; it reconnects by itself the moment the hub answers. Summon it from the Shell tray (Settings > Charm Bar > Show Charm Bar) or with `overlay.railHotkey` (default `Ctrl+Alt+B`, set in the Hotkeys dialog). - `GET /pages/explore.html` - Album art explorer: browse albums as artwork with source filters (All/1mo/3mo/12mo/Unheard), search, sort, image gallery with embedded artwork probing, PDF booklet viewing, play/queue controls, theme sync. Accepts `?album=&artist=` query params for seeding from search or dashboard. Artist discography grid in expanded view shows all albums by the current artist. Expanded-view hero has paired close (×) and dashboard home (⌂) buttons — close dismisses the overlay and stays on explore; home navigates to `/dashboard`. Home is hidden in iframe-mode - `GET /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 iframe registry), upcoming-queue + now-playing card (right), full-width transport footer. - Transport: play/pause, prev/next, shuffle, repeat, AutoDJ, volume + mute, scrubber, love, 5-star rating, the shared radio strip (`MBXShared.attachRadioStrip`, same component as the dashboard charm and the MusicBee panel: mood popup menu, ON-AIR toggle with fresh/continue, seeded start, saved-stations list-and-play, refresh, honest status line), reaction buttons (fire/heart/like/dislike/ban) gated on AutoQ availability. At narrow widths the station controls and reaction buttons page via `MBXShared.attachGroupSlide` (dot pager + touch swipe) instead of cramming onto one row. - "+ Add To Playlist" dropdown beside love/stars: quick-curate the playing track to any playlist; "+ New Playlist…" creates one with the current track as seed; last-used playlist bubbles to the top - ARiA presets dropdown in the overflow flyout (auto-hides if ARiA isn't enabled) - Influence thumbs (artist + genre) on the right-column now-playing card, visible only when mbxq reports `/influences/current` - Global media-key shortcuts: Space = play/pause, ← = previous, → = next (skipped while typing in inputs) - Cmd+K command palette (Ctrl+K) — same component as the dashboard via shared `/pages/components/cmdk-bootstrap.js`; action rows show the global hotkey alongside (Space / ← / → / ↑ / ↓) - **Listen Here** (browser-side audio): output toggle (🔊 speakers / 🎧 browser) in the transport flyout. Speakers is the default (MB plays through the host machine, controlled via `/player/*`). Browser plays the same server queue via a hidden `