bluxir is a C terminal controller for Blusound network music streamers. It communicates with BluOS players over HTTP (port 11000), renders a curses-based split-screen TUI, supports multiroom grouping with per-player volume control, and fetches metadata from external APIs in background threads.
+------------------+ +------------------+ +-------------------+
| main.c |---->| player.c |---->| BluOS HTTP API |
| (curses + input) | | (libcurl + expat)| | (port 11000) |
+------------------+ +------------------+ +-------------------+
| |
| +---> discover.c (Avahi mDNS)
|
+---> ui.c / ui_player.c / ui_browse.c / ui_search.c
| (group manager, volume overlay)
|
+---> metadata.c ------> MusicBrainz API
| (libcurl + cJSON) OpenAI API
| LRCLIB API
| Wikipedia API
|
+---> cover_art.c (stb_image + ncurses 256-color)
|
+---> config.c (cJSON) ---> config.json / ~/.bluxir.json
|
+---> cache.c (LRU hash table)
|
+---> logger.c (rotating file logger)
Holds all fields from the /Status XML response. Fixed-size char[] arrays eliminate per-field malloc:
name[256],artist[256],album[256]- current trackstate[64]- play, pause, stop, streamvolume(int),mute(bool),repeat(int),shuffle(bool)secs,totlen- playback positionstream_format[256]- codec and bitrate infoimage[1024]- cover art URL
Represents a browsable/playable item from the Browse API:
text[256],text2[256]- display name and secondary textbrowse_key[1024]- key for navigating deeperplay_url[1024]- URL for direct playbackcontext_menu_key[1024]- key for context menu (favourites, queue actions)search_key[1024]- search capability keychildren/children_count- nested items (dynamic array)
Holds the current multiroom group state, cached in AppState and refreshed every 15 seconds:
master_ip[256]— IP of the master player (empty if standalone or this player is master)slave_ips[16][256]— IPs of grouped slave playersslave_names[16][256]— display names of grouped slaves (from SyncStatus XML)slave_count— number of slaves in the group
Single struct replacing the Python BlusoundCLI class. Contains all UI state, player references, metadata, group info, locks, and highlight times. Stack-allocated in main().
Source selection state includes sort/filter support:
source_sort('o'/'t'/'a') — current sort modesource_filter[256]— active filter text (empty = no filter)unsorted_sources— backup pointer to original data before sort/filter was applied- When sort or filter is activated,
source_ensure_backup()saves the originalcurrent_sourcespointer and creates a working copy.source_apply_sort_filter()rebuilds the copy by filtering from backup then sorting withqsort. State is cleared on navigate deeper/back/exit.
Multiroom state:
group_info(GroupInfo) — cached group info, polled every 15 secondslast_group_update_time— timer for group info polling
Thread-safe hash table (256 buckets, djb2 hash) with doubly-linked list for LRU ordering. Internal pthread mutex. Entries own copies of keys and values, freed on eviction or destroy.
- Structs: fixed-size
char[]fields (no per-field malloc) - Dynamic arrays:
count/capacitypattern withrealloc - Heap blobs:
lyrics_text,wiki_text,cover_art_raw- malloc'd, freed on track change, protected bydata_lock - libcurl buffers: write-callback to dynamic buffer, freed by caller after parsing
- Cache: entries own copies of values, freed on eviction or
cache_destroy() - AppState: stack-allocated in
main(), cleaned up viaapp_state_destroy()
Matches the Python threading model:
- Main thread: curses event loop (100ms poll), status polling (3s), progress increment (1s)
- Detached pthreads: metadata fetch, lyrics fetch, cover art download, station info
- One global mutex (
data_lock): protects mb_info, wiki_text, lyrics_text, cover_art, loading flags - Discovery mutex: separate lock for player list updates (in DiscoveryState)
- Cache mutex: internal to cache module
- Rate limiter mutex: internal to metadata module
- libcurl: each thread creates its own
CURL*handle;curl_global_init()once inmain()
All communication uses HTTP GET on port 11000. Responses are XML parsed with expat (SAX-style).
| Endpoint | Purpose |
|---|---|
/Status |
Current playback state |
/SyncStatus |
Player identity, group info (master/slaves) |
/Browse |
Browse sources, search, context menus |
/Browse?key=... |
Navigate into a source/category |
/Browse?key=...&q=... |
Search within a source |
/Playlist |
Current play queue |
/Volume?level=N |
Set volume |
/Volume?mute=0|1 |
Toggle mute |
/Pause?toggle=1 |
Toggle play/pause |
/Skip |
Next track |
/Back |
Previous track |
/Play?id=N |
Jump to queue track (0-based) |
/Shuffle?state=0|1 |
Set shuffle |
/Repeat?state=0|1|2 |
Set repeat (0=queue, 1=track, 2=off) |
/Save?name=... |
Save playlist |
/Delete?name=... |
Delete playlist |
/AddFavourite?albumid=...&service=... |
Add album to favourites |
/AddSlave?slave=IP&port=11000 |
Add player to group (sent to master) |
/RemoveSlave?slave=IP&port=11000 |
Remove player from group (sent to master) |
BluOS browse keys contain pre-encoded URL segments with & characters:
Qobuz:Album/%2FAlbums%3Fcategory=FAVOURITES&service=Qobuz
When used as query parameter values, only & must be encoded as %26 to prevent splitting the query string. Do NOT full-encode these keys - the %2F, %3F, :, /, = are intentional and already correctly formatted.
Two encoding functions:
url_encode_param()- encodes only&. Used for browse keys, context menu keys, search keys.url_encode()- full RFC 3986 encoding. Used for user-supplied text (search queries, playlist names, artist names in context menu key construction).
Python's requests library handles this automatically via urllib.parse.urlencode.
<browse searchKey="Qobuz:Search" nextKey="..." type="albums">
<item text="Album Name" text2="Artist" browseKey="..." playURL="..."
contextMenuKey="..." isFavourite="true" type="album" />
</browse>The root element may have different names (<browse>, <items>, etc.). The parser captures searchKey and nextKey from any non-<item> element. Items can appear nested inside <category> elements - the SAX parser handles this automatically since it fires for all <item> elements regardless of depth.
Context menu items use actionURL (not playURL) for actions like add/remove favourite.
The SyncStatus response uses child elements (not attributes) for group info:
<!-- Master player (has slaves) -->
<SyncStatus name="Livingroom 40ST" group="Livingroom 40ST+office" ...>
<slave id="192.168.68.65" port="11000" name="office" model="P130" />
</SyncStatus>
<!-- Slave player (has master) -->
<SyncStatus name="office" ...>
<master port="11000">192.168.68.61</master>
</SyncStatus>
<!-- Standalone player (no group) -->
<SyncStatus name="Livingroom 40ST" ...>
</SyncStatus>The parser uses expat SAX callbacks:
sync_start— captures attributes from<SyncStatus>(name, brand, model, group) and<slave>(id, name)sync_chars— accumulates text content for<master>elementsync_end— extracts master IP from text content
Used by the health check feature:
| Page | Data |
|---|---|
/diagnostics |
Network info, signal strength, IP, MAC, firmware versions, uptime |
/upgrade |
Firmware update availability |
Parsed with hand-written HTML extractors (no regex library needed).
Searches for album releases matching artist + album name. Uses a scoring system:
| Factor | Score |
|---|---|
| Exact title match | +100 |
| Partial title match | +50-60 |
| Word overlap | +10 per word |
| Exact artist match | +50 |
| Partial artist match | +30 |
| MB confidence score | +score/10 |
Fetches genre tags from the release-group endpoint (top 3 by count). Rate-limited to 1 request/second.
Single combined request for year, label, genre, and track description. Response is JSON, possibly wrapped in markdown fences (stripped before parsing). Falls back to MusicBrainz when no API key or on failure.
Free lyrics API, no key needed. Returns plain lyrics text. Network errors are NOT cached (allow retry on next track change); only 404s are cached as negative results.
All metadata results are cached in a single LRU cache (default 256 entries). Cache keys use prefixes to namespace different data types: combined|, station|, lyrics|, wiki|, artist|album.
- Image decoded from raw bytes using stb_image (JPEG, PNG, BMP)
- Nearest-neighbor resize to target terminal dimensions (width x height*2 pixels)
- Each terminal row represents 2 pixel rows using the half-block character U+2580 (▀)
- Foreground color = top pixel, background color = bottom pixel
- RGB values mapped to xterm-256 color indices (grayscale ramp or 6x6x6 color cube)
- Color pairs allocated dynamically starting at pair index 10
- Graceful fallback to pair 0 when color pair limit is exhausted
Uses Avahi (the Linux mDNS/DNS-SD implementation) to discover Blusound players:
- Background thread creates an Avahi simple poll and client
- Service browser listens for
_musc._tcpservices - On discovery, resolves to IPv4 address
- Creates lightweight
BlusoundPlayerviaplayer_create()(sets host/name/base_url only, no HTTP) - Adds to shared player list (protected by mutex)
- Main thread polls the list for UI updates
- Sync name fetch and source initialization happen only when a player is activated (selected via ENTER)
Discovery is started on demand (when X or G is pressed) rather than at startup if a stored player connects successfully.
Pressing X starts mDNS discovery (if not already running) and shows the player selection screen. The active player is marked with *. Pressing b/ESC returns to the current player without switching.
When switching players, reset_for_player_switch() clears all cached metadata (wiki, lyrics, cover art, MusicBrainz info) so fresh data loads for the new player.
Pressing G opens a blocking modal overlay showing all discovered players (excluding the active one). Players in the current group show [G], others show [ ]. ENTER toggles group membership via /AddSlave or /RemoveSlave.
If the active player is currently a slave of another master, the overlay shows the master IP and ENTER leaves the group (sends /RemoveSlave to the master via player_leave_group()).
On close, app->group_info is updated so the header reflects changes immediately.
When the active player is in a group (has slaves), pressing UP/DOWN for volume opens a blocking overlay showing all group members (master + slaves) with volume bars. The first volume change is applied to the master immediately before the overlay opens.
- LEFT/RIGHT selects a player
- UP/DOWN adjusts volume for the selected player (5% increments)
- Selected player is highlighted in green (COLOR_PAIR(3))
- Master volume updates are synced back to
app->player_status.volume - Slave volumes are fetched via
player_get_status()when the overlay opens
When grouped, the header shows: BluOS Player Control - Livingroom 40ST (&office)
Slave names come from the name attribute on <slave> elements in the SyncStatus XML, stored in GroupInfo.slave_names[]. The group info is cached in app->group_info and polled every 15 seconds (GROUP_POLL_INTERVAL).
All log files are in the logs/ directory (relative to CWD), with rotating file handlers (1 MB max, 1 backup):
| Log File | Source |
|---|---|
logs/bluxir.log |
Main application |
logs/cli.log |
Player module |
logs/musicbrainz.log |
Metadata module |
- API functions return
bool(success/failure) with output parameters - HTTP errors: logged via
curl_easy_strerror(), caller receives false - XML errors: logged via expat error codes, empty results returned
- Curses errors: silent ignore (matches Python's
try/except curses.error: pass) - Fatal errors:
endwin(), log,exit(1)
- Main loop runs with 100ms curses timeout (
CURSES_POLL_MS) - Every second, local progress counter increments (client-side interpolation)
- Every 3 seconds, full status refresh from
/StatusAPI - Every 15 seconds, group info refresh from
/SyncStatusAPI - On track/album change, background metadata threads are launched
- On skip/back, next refresh is scheduled ~1 second early
- On volume/mute/repeat/shuffle, optimistic UI update (set value directly, no refresh)