This document outlines an architectural redesign to rewrite the MusicBee Remote plugin core in Rust while maintaining a minimal C# shim for MusicBee API access.
┌─────────────────────────────────────────────────────────────┐
│ MusicBee (C++) │
└─────────────────────────────────────────────────────────────┘
│
▼ MusicBee Plugin API
┌─────────────────────────────────────────────────────────────┐
│ mb_remote.dll (C# .NET Framework 4.8) │
│ ~500 lines, thin shim │
│ - Implements IMusicBeePlugin interface │
│ - Forwards notifications to Rust core │
│ - Exposes MusicBee API as callbacks │
└─────────────────────────────────────────────────────────────┘
│
▼ P/Invoke (C ABI)
┌─────────────────────────────────────────────────────────────┐
│ mbrc_core.dll (Rust, cdylib) │
│ ~400-500 KB │
│ - Axum + Tokio async runtime │
│ - Hybrid protocol server (HTTP/WS + Legacy) │
│ - Command dispatch & business logic │
│ - Settings & cache storage │
└─────────────────────────────────────────────────────────────┘
| Factor | Go | Rust |
|---|---|---|
| Binary size | ~11 MB | ~400-500 KB |
| Runtime | GC, scheduler overhead | Zero-cost abstractions |
| C FFI | CGO (complex, slow) | Native extern "C" |
| Async | Goroutines (good) | Tokio (excellent) |
| Safety | GC-managed | Compile-time guarantees |
The server listens on a single configurable port (default: 3000) and auto-detects the protocol:
Client connects to port 3000
│
▼
┌──────────────┐
│ Peek first │
│ bytes │
└──────────────┘
│
┌─────┴─────┬────────────┐
▼ ▼ ▼
"GET /" "{"context" WebSocket
"POST /" Upgrade
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ HTTP │ │ Legacy │ │WebSocket │
│ Axum │ │ Protocol │ │ Axum │
└────────┘ └──────────┘ └──────────┘
async fn detect_protocol(stream: &TcpStream) -> Protocol {
let mut peek_buf = [0u8; 16];
let n = stream.peek(&mut peek_buf).await?;
match &peek_buf[..n] {
// HTTP methods
b if b.starts_with(b"GET ") => Protocol::Http,
b if b.starts_with(b"POST ") => Protocol::Http,
b if b.starts_with(b"PUT ") => Protocol::Http,
b if b.starts_with(b"DELETE ") => Protocol::Http,
b if b.starts_with(b"OPTIONS ") => Protocol::Http,
// Legacy JSON protocol starts with '{'
b if b.starts_with(b"{") => Protocol::Legacy,
// Fallback to legacy for backwards compatibility
_ => Protocol::Legacy,
}
}REST API:
GET /api/v1/player/status → Player state
POST /api/v1/player/play → Start playback
POST /api/v1/player/pause → Pause
POST /api/v1/player/next → Next track
POST /api/v1/player/previous → Previous track
POST /api/v1/player/volume → Set volume { "value": 50 }
GET /api/v1/nowplaying → Current track info
GET /api/v1/nowplaying/cover → Album art (binary)
GET /api/v1/nowplaying/lyrics → Lyrics
GET /api/v1/library/search → Search library
GET /api/v1/playlists → List playlists
...
WebSocket:
WS /api/v1/ws → Real-time events
← {"event":"player_state","data":{"state":"playing"}}
← {"event":"track_changed","data":{...}}
← {"event":"volume_changed","data":{"value":75}}
→ {"action":"play"}
→ {"action":"volume","value":50}
Maintains full compatibility with existing Android app:
Connect → Send: {"context":"player","data":"MusicBee"}\r\n
← Recv: {"context":"player","data":{...}}\r\n
→ Send: {"context":"protocol","data":4}\r\n
← Recv: {"context":"protocol","data":{...}}\r\n
→ Send: {"context":"playerplay","data":""}\r\n
...
// Lifecycle
#[no_mangle]
pub extern "C" fn mbrc_initialize(
callbacks: *const MbrcCallbacks,
storage_path: *const c_char,
) -> i32;
#[no_mangle]
pub extern "C" fn mbrc_shutdown() -> i32;
// Networking
#[no_mangle]
pub extern "C" fn mbrc_start_networking() -> i32;
#[no_mangle]
pub extern "C" fn mbrc_stop_networking() -> i32;
// MusicBee notifications
#[no_mangle]
pub extern "C" fn mbrc_handle_notification(
notification_type: i32,
data: *const c_char, // JSON payload
) -> i32;
// Settings UI
#[no_mangle]
pub extern "C" fn mbrc_get_settings_json() -> *mut c_char;
#[no_mangle]
pub extern "C" fn mbrc_update_settings(json: *const c_char) -> i32;
#[no_mangle]
pub extern "C" fn mbrc_free_string(ptr: *mut c_char);#[repr(C)]
pub struct MbrcCallbacks {
// Player queries
pub get_player_status: extern "C" fn() -> *mut c_char, // Returns JSON
pub get_track_info: extern "C" fn() -> *mut c_char,
pub get_artwork: extern "C" fn() -> *mut c_char, // Base64
pub get_lyrics: extern "C" fn() -> *mut c_char,
pub get_now_playing_list: extern "C" fn(offset: i32, limit: i32) -> *mut c_char,
pub get_playlists: extern "C" fn() -> *mut c_char,
pub get_output_devices: extern "C" fn() -> *mut c_char,
// Library queries
pub library_search: extern "C" fn(query: *const c_char) -> *mut c_char,
pub library_browse: extern "C" fn(category: *const c_char,
offset: i32, limit: i32) -> *mut c_char,
// Player actions (return 0 = success, non-zero = error)
pub player_play: extern "C" fn() -> i32,
pub player_pause: extern "C" fn() -> i32,
pub player_stop: extern "C" fn() -> i32,
pub player_next: extern "C" fn() -> i32,
pub player_previous: extern "C" fn() -> i32,
pub player_set_volume: extern "C" fn(volume: i32) -> i32,
pub player_set_position: extern "C" fn(position_ms: i32) -> i32,
pub player_set_shuffle: extern "C" fn(mode: i32) -> i32,
pub player_set_repeat: extern "C" fn(mode: i32) -> i32,
pub player_set_mute: extern "C" fn(muted: i32) -> i32,
// Queue/playlist actions
pub queue_track: extern "C" fn(path: *const c_char, queue_type: i32) -> i32,
pub queue_album: extern "C" fn(album: *const c_char) -> i32,
pub queue_artist: extern "C" fn(artist: *const c_char) -> i32,
pub play_playlist: extern "C" fn(url: *const c_char) -> i32,
pub now_playing_move: extern "C" fn(from: i32, to: i32) -> i32,
pub now_playing_remove: extern "C" fn(index: i32) -> i32,
pub now_playing_play: extern "C" fn(index: i32) -> i32,
// Metadata
pub set_rating: extern "C" fn(rating: f32) -> i32,
pub set_lfm_rating: extern "C" fn(rating: i32) -> i32,
// Output
pub switch_output: extern "C" fn(device_id: *const c_char) -> i32,
// Memory management (C# must free returned strings)
pub free_string: extern "C" fn(ptr: *mut c_char),
}mbrc-core/
├── Cargo.toml
├── src/
│ ├── lib.rs # C ABI exports
│ ├── ffi/
│ │ ├── mod.rs
│ │ ├── callbacks.rs # Callback wrapper (safe Rust over raw ptrs)
│ │ └── types.rs # C-compatible types
│ ├── server/
│ │ ├── mod.rs
│ │ ├── hybrid.rs # Protocol detection & routing
│ │ ├── legacy/
│ │ │ ├── mod.rs
│ │ │ ├── connection.rs # Legacy client state machine
│ │ │ ├── codec.rs # CRLF-delimited JSON codec
│ │ │ └── handshake.rs # Player/Protocol handshake
│ │ ├── http/
│ │ │ ├── mod.rs
│ │ │ ├── routes.rs # Axum route definitions
│ │ │ └── handlers.rs # Request handlers
│ │ └── websocket/
│ │ ├── mod.rs
│ │ └── handler.rs # WS message handling
│ ├── protocol/
│ │ ├── mod.rs
│ │ ├── commands.rs # Command enum & dispatch
│ │ ├── events.rs # Event types for broadcast
│ │ └── messages.rs # Message serialization
│ ├── commands/
│ │ ├── mod.rs
│ │ ├── player.rs # Player command handlers
│ │ ├── nowplaying.rs # Now playing handlers
│ │ ├── library.rs # Library handlers
│ │ ├── playlist.rs # Playlist handlers
│ │ └── system.rs # System/protocol handlers
│ ├── discovery/
│ │ ├── mod.rs
│ │ └── udp.rs # UDP multicast discovery
│ ├── storage/
│ │ ├── mod.rs
│ │ ├── settings.rs # User settings
│ │ └── cache.rs # Cover art cache
│ └── state/
│ ├── mod.rs
│ └── player.rs # Cached player state
[package]
name = "mbrc_core"
version = "1.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
# Async runtime
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "io-util"] }
# HTTP/WebSocket
axum = { version = "0.7", features = ["ws"] }
tower = "0.4"
hyper = "1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Storage
sled = "0.34" # Embedded database (or redb for smaller size)
# Utilities
tracing = "0.1"
tracing-subscriber = "0.3"
parking_lot = "0.12" # Fast mutexes
once_cell = "1"
# FFI helpers
libc = "0.2"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true┌─────────────────────────────────────────────────────────────┐
│ Tokio Runtime │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ TCP Accept │ │ UDP Disco- │ │ Timer │ │
│ │ Task │ │ very │ │ (ping) │ │
│ └──────┬──────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Per-Connection Tasks │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Legacy │ │ HTTP │ │ WS │ │ │
│ │ │ Client │ │ Request │ │ Client │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ └───────┼───────────┼───────────┼─────────────────┘ │
│ │ │ │ │
│ └───────────┴───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Command Router │ │
│ │ (async dispatch) │ │
│ └──────────┬──────────┘ │
│ │ │
└─────────────────────┼──────────────────────────────────────┘
│
▼ spawn_blocking (for FFI calls)
┌─────────────────────┐
│ C# Callback │
│ (MusicBee API) │
│ [single-threaded] │
└─────────────────────┘
use parking_lot::Mutex;
use once_cell::sync::OnceCell;
static CALLBACKS: OnceCell<Mutex<MbrcCallbacks>> = OnceCell::new();
/// Call into C# safely from any async task
async fn call_csharp<F, R>(f: F) -> R
where
F: FnOnce(&MbrcCallbacks) -> R + Send + 'static,
R: Send + 'static,
{
tokio::task::spawn_blocking(move || {
let callbacks = CALLBACKS.get().expect("not initialized");
let guard = callbacks.lock();
f(&guard)
}).await.expect("callback panicked")
}
// Usage example
async fn handle_play_command() -> Result<(), Error> {
let result = call_csharp(|cb| (cb.player_play)()).await;
if result != 0 {
return Err(Error::PlayerError);
}
Ok(())
}- Rust project setup with Cargo
- C ABI interface definition
- Callback structure and safe wrappers
- C# shim modifications (P/Invoke declarations)
- Basic
mbrc_initialize/mbrc_shutdownlifecycle - Logging infrastructure (tracing → file)
- TCP listener with protocol detection
- Legacy protocol codec (CRLF-delimited JSON)
- Legacy handshake state machine
- HTTP router (Axum) skeleton
- WebSocket upgrade handling
- Client connection management
- Player status query
- Play/pause/stop/next/previous
- Volume control
- Shuffle/repeat modes
- Mute toggle
- Position seeking
- Current track info
- Album artwork (with caching)
- Lyrics
- Now playing list (paginated)
- Queue operations (move, remove, play)
- Rating (local + Last.fm)
- Search (artist, album, genre, title)
- Browse (genres, artists, albums, tracks)
- Queue operations
- Radio stations
- Playlist list & play
- Output device switching
- Protocol/ping/pong
- Connection verification
- Settings persistence (sled/redb)
- Cover art cache
- UDP multicast discovery
- Service announcement
- REST API endpoints
- WebSocket real-time events
- Event broadcasting to all clients
- API documentation
- Integration tests with mock MusicBee
- Android app compatibility testing
- Performance profiling
- Error handling hardening
- Documentation
| Feature | Guarantee |
|---|---|
| Legacy protocol | 100% compatible - existing Android app works unchanged |
| Port number | Same default (3000), same setting |
| UDP discovery | Same multicast address (239.1.5.10:45345) |
| Message format | Identical JSON structure |
| Handshake | Same player→protocol sequence |
| All 54 commands | Fully supported |
| Feature | Benefit |
|---|---|
| REST API | Easy integration with web apps, Home Assistant, etc. |
| WebSocket | Lower latency events, efficient for web clients |
| Binary covers | Direct image download, no Base64 overhead |
| OpenAPI spec | Auto-generated API documentation |
| CORS support | Browser-based clients |
| Future: HTTPS | Secure connections (with user-provided cert) |
| Component | Size |
|---|---|
| Tokio runtime | ~150 KB |
| Axum + Hyper | ~200 KB |
| serde_json | ~50 KB |
| sled storage | ~100 KB |
| Application code | ~50 KB |
| Total | ~500-600 KB |
Compare to:
- Current C# core: ~2 MB (with merged assemblies)
- Go equivalent: ~11 MB
The existing Plugin.cs would be simplified to:
public class Plugin : IMusicBeeApiPlugin
{
private MusicBeeApiInterface _api;
private bool _coreLoaded;
public PluginInfo Initialise(IntPtr apiInterfacePtr)
{
_api = new MusicBeeApiInterface();
_api.Initialise(apiInterfacePtr);
// Load Rust core
var callbacks = CreateCallbacks();
var storagePath = _api.Setting_GetPersistentStoragePath();
if (NativeMethods.mbrc_initialize(ref callbacks, storagePath) == 0)
{
_coreLoaded = true;
NativeMethods.mbrc_start_networking();
}
return _pluginInfo;
}
public void ReceiveNotification(string source, NotificationType type)
{
if (!_coreLoaded) return;
var json = SerializeNotification(source, type);
NativeMethods.mbrc_handle_notification((int)type, json);
}
public void Close(PluginCloseReason reason)
{
if (_coreLoaded)
{
NativeMethods.mbrc_stop_networking();
NativeMethods.mbrc_shutdown();
}
}
private MbrcCallbacks CreateCallbacks()
{
return new MbrcCallbacks
{
get_player_status = GetPlayerStatusCallback,
player_play = () => _api.Player_PlayPause() ? 0 : 1,
player_next = () => _api.Player_PlayNextTrack() ? 0 : 1,
// ... etc
};
}
}- Cover caching strategy: Keep in Rust (sled) or delegate to C#?
- Settings UI: Rust HTTP serves settings page, or keep C# WinForms?
- HTTPS support: Worth the complexity for local network use?
- mDNS/Bonjour: Add as alternative to UDP multicast discovery?