Skip to content

Instantly share code, notes, and snippets.

@kelsos
Last active January 31, 2026 19:55
Show Gist options
  • Select an option

  • Save kelsos/d1d668b803234bbcf86fa50a77b26895 to your computer and use it in GitHub Desktop.

Select an option

Save kelsos/d1d668b803234bbcf86fa50a77b26895 to your computer and use it in GitHub Desktop.
MusicBee Remote: Go Core Architecture Design

MusicBee Remote: Rust Core Architecture

Overview

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                                 │
└─────────────────────────────────────────────────────────────┘

Why Rust?

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

Hybrid Protocol Server

Single-Port Architecture

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    │
└────────┘ └──────────┘ └──────────┘

Protocol Detection Logic

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,
    }
}

HTTP/WebSocket API (New Protocol)

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}

Legacy Protocol (Backwards Compatible)

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
       ...

C ABI Interface

Rust Exports (called by C#)

// 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);

Callback Structure (Rust calls into C#)

#[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),
}

Rust Project Structure

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

Key Dependencies

[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

Threading Model

┌─────────────────────────────────────────────────────────────┐
│                    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] │
          └─────────────────────┘

Thread Safety for C# Callbacks

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(())
}

Migration Phases

Phase 1: Foundation (Week 1-2)

  • Rust project setup with Cargo
  • C ABI interface definition
  • Callback structure and safe wrappers
  • C# shim modifications (P/Invoke declarations)
  • Basic mbrc_initialize / mbrc_shutdown lifecycle
  • Logging infrastructure (tracing → file)

Phase 2: Hybrid Server (Week 3-4)

  • 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

Phase 3: Commands - Player (Week 5)

  • Player status query
  • Play/pause/stop/next/previous
  • Volume control
  • Shuffle/repeat modes
  • Mute toggle
  • Position seeking

Phase 4: Commands - Now Playing (Week 6)

  • Current track info
  • Album artwork (with caching)
  • Lyrics
  • Now playing list (paginated)
  • Queue operations (move, remove, play)
  • Rating (local + Last.fm)

Phase 5: Commands - Library (Week 7)

  • Search (artist, album, genre, title)
  • Browse (genres, artists, albums, tracks)
  • Queue operations
  • Radio stations

Phase 6: Commands - Playlist & System (Week 8)

  • Playlist list & play
  • Output device switching
  • Protocol/ping/pong
  • Connection verification

Phase 7: Storage & Discovery (Week 9)

  • Settings persistence (sled/redb)
  • Cover art cache
  • UDP multicast discovery
  • Service announcement

Phase 8: HTTP API & WebSocket (Week 10)

  • REST API endpoints
  • WebSocket real-time events
  • Event broadcasting to all clients
  • API documentation

Phase 9: Testing & Polish (Week 11-12)

  • Integration tests with mock MusicBee
  • Android app compatibility testing
  • Performance profiling
  • Error handling hardening
  • Documentation

Backwards Compatibility

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

New Capabilities (HTTP/WS)

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)

Binary Size Estimate

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

C# Shim Changes

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
        };
    }
}

Open Questions

  1. Cover caching strategy: Keep in Rust (sled) or delegate to C#?
  2. Settings UI: Rust HTTP serves settings page, or keep C# WinForms?
  3. HTTPS support: Worth the complexity for local network use?
  4. mDNS/Bonjour: Add as alternative to UDP multicast discovery?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment