// main.go — Kodi JSON-RPC -> BlueZ RegisterPlayer via MPRIS (LibreELEC-safe) // // Key point: Do NOT RequestName "org.mpris.*" on LibreELEC (policy blocks it). // We run on SYSTEM bus (to talk to BlueZ) and export MPRIS interfaces on the // connection unique name. BlueZ RegisterPlayer expects org.mpris.MediaPlayer2.Player. :contentReference[oaicite:2]{index=2} // // Env: // KODI_URL default http://127.0.0.1:8080/jsonrpc // KODI_USER default kodi // KODI_PASS default kodi // BLUEZ_ADAPTER default hci0 // POLL_MS default 900 // HTTP_TIMEOUT_MS default 1200 // // Build (ARMv7): // CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -trimpath -ldflags="-s -w" -o kodi-avrcp package main import ( "bytes" "context" "encoding/json" "fmt" "log" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/godbus/dbus/v5" ) // ---------- env helpers ---------- func envStr(key, def string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } return v } func envInt(key string, def int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } n, err := strconv.Atoi(v) if err != nil || n <= 0 { return def } return n } // ---------- config ---------- type config struct { kodiURL string kodiUser string kodiPass string adapter string pollEvery time.Duration httpTimeout time.Duration } func loadConfig() config { return config{ kodiURL: envStr("KODI_URL", "http://127.0.0.1:8080/jsonrpc"), kodiUser: envStr("KODI_USER", "kodi"), kodiPass: envStr("KODI_PASS", "kodi"), adapter: envStr("BLUEZ_ADAPTER", "hci0"), pollEvery: time.Duration(envInt("POLL_MS", 900)) * time.Millisecond, httpTimeout: time.Duration(envInt("HTTP_TIMEOUT_MS", 1200)) * time.Millisecond, } } // ---------- Kodi JSON-RPC ---------- type kodiReq struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params interface{} `json:"params,omitempty"` ID int `json:"id"` } type kodiResp struct { ID int `json:"id"` Result json.RawMessage `json:"result"` Error *struct { Code int `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` } type activePlayer struct { PlayerID int `json:"playerid"` Type string `json:"type"` // audio/video } type itemResult struct { Item struct { Title string `json:"title"` Album string `json:"album"` Artist []string `json:"artist"` Track int `json:"track"` Label string `json:"label"` // returned by Kodi even if not requested } `json:"item"` } type propsResult struct { Speed int `json:"speed"` // 0 paused, 1 playing Time struct { Hours, Minutes, Seconds, Milliseconds int } `json:"time"` TotalTime struct { Hours, Minutes, Seconds, Milliseconds int } `json:"totaltime"` } func kodiCall(ctx context.Context, cfg config, method string, params interface{}, out interface{}) error { payload, _ := json.Marshal(kodiReq{JSONRPC: "2.0", Method: method, Params: params, ID: 1}) req, err := http.NewRequestWithContext(ctx, "POST", cfg.kodiURL, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.SetBasicAuth(cfg.kodiUser, cfg.kodiPass) client := &http.Client{Timeout: cfg.httpTimeout} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() var kr kodiResp if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil { return err } if kr.Error != nil { return fmt.Errorf("kodi jsonrpc error: %d %s", kr.Error.Code, kr.Error.Message) } if out == nil { return nil } return json.Unmarshal(kr.Result, out) } func msTotal(h, m, s, ms int) int64 { return int64((h*3600+m*60+s)*1000 + ms) } type nowPlaying struct { Status string // MPRIS: Playing/Paused/Stopped Title string Artist string Album string TrackNo int LengthUS int64 // microseconds PositionUS int64 // microseconds } func getNowPlaying(ctx context.Context, cfg config) (nowPlaying, error) { var aps []activePlayer if err := kodiCall(ctx, cfg, "Player.GetActivePlayers", nil, &aps); err != nil { return nowPlaying{Status: "Stopped"}, err } if len(aps) == 0 { return nowPlaying{Status: "Stopped"}, nil } pid := aps[0].PlayerID for _, ap := range aps { if ap.Type == "audio" { pid = ap.PlayerID break } } // GetItem (properties compatible with your Kodi) var it itemResult itParams := map[string]any{ "playerid": pid, "properties": []string{"title", "album", "artist", "track"}, } if err := kodiCall(ctx, cfg, "Player.GetItem", itParams, &it); err != nil { return nowPlaying{Status: "Stopped"}, err } title := strings.TrimSpace(it.Item.Title) if title == "" { title = strings.TrimSpace(it.Item.Label) } album := strings.TrimSpace(it.Item.Album) artist := "" if len(it.Item.Artist) > 0 { artist = strings.TrimSpace(it.Item.Artist[0]) } // GetProperties for status/position/length var pr propsResult prParams := map[string]any{ "playerid": pid, "properties": []string{"speed", "time", "totaltime"}, } status := "Playing" posUS := int64(0) lenUS := int64(0) if err := kodiCall(ctx, cfg, "Player.GetProperties", prParams, &pr); err == nil { if pr.Speed == 0 { status = "Paused" } else { status = "Playing" } posUS = msTotal(pr.Time.Hours, pr.Time.Minutes, pr.Time.Seconds, pr.Time.Milliseconds) * 1000 lenUS = msTotal(pr.TotalTime.Hours, pr.TotalTime.Minutes, pr.TotalTime.Seconds, pr.TotalTime.Milliseconds) * 1000 } else { if title == "" && artist == "" && album == "" { status = "Stopped" } } if title == "" && artist == "" && album == "" { status = "Stopped" } return nowPlaying{ Status: status, Title: title, Artist: artist, Album: album, TrackNo: it.Item.Track, LengthUS: lenUS, PositionUS: posUS, }, nil } // ---------- MPRIS on SYSTEM bus (unique name) ---------- const ( objPath = dbus.ObjectPath("/org/mpris/MediaPlayer2") ifaceRoot = "org.mpris.MediaPlayer2" ifacePlay = "org.mpris.MediaPlayer2.Player" ifaceProps = "org.freedesktop.DBus.Properties" ) type mpris struct { conn *dbus.Conn mu sync.RWMutex identity string status string metadata map[string]dbus.Variant position int64 } func (p *mpris) emit(changed map[string]dbus.Variant) { _ = p.conn.Emit(objPath, ifaceProps+".PropertiesChanged", ifacePlay, changed, []string{}) } // Properties func (p *mpris) Get(iface, prop string) (dbus.Variant, *dbus.Error) { p.mu.RLock() defer p.mu.RUnlock() switch iface { case ifaceRoot: switch prop { case "Identity": return dbus.MakeVariant(p.identity), nil case "CanQuit": return dbus.MakeVariant(false), nil case "CanRaise": return dbus.MakeVariant(false), nil } case ifacePlay: switch prop { case "PlaybackStatus": return dbus.MakeVariant(p.status), nil case "Metadata": return dbus.MakeVariant(p.metadata), nil case "Position": return dbus.MakeVariant(p.position), nil case "CanControl": return dbus.MakeVariant(false), nil case "CanPlay": return dbus.MakeVariant(false), nil case "CanPause": return dbus.MakeVariant(false), nil case "CanGoNext": return dbus.MakeVariant(false), nil case "CanGoPrevious": return dbus.MakeVariant(false), nil case "CanSeek": return dbus.MakeVariant(false), nil } } return dbus.Variant{}, dbus.MakeFailedError(fmt.Errorf("unknown %s.%s", iface, prop)) } func (p *mpris) GetAll(iface string) (map[string]dbus.Variant, *dbus.Error) { p.mu.RLock() defer p.mu.RUnlock() out := map[string]dbus.Variant{} switch iface { case ifaceRoot: out["Identity"] = dbus.MakeVariant(p.identity) out["CanQuit"] = dbus.MakeVariant(false) out["CanRaise"] = dbus.MakeVariant(false) case ifacePlay: out["PlaybackStatus"] = dbus.MakeVariant(p.status) out["Metadata"] = dbus.MakeVariant(p.metadata) out["Position"] = dbus.MakeVariant(p.position) out["CanControl"] = dbus.MakeVariant(false) out["CanPlay"] = dbus.MakeVariant(false) out["CanPause"] = dbus.MakeVariant(false) out["CanGoNext"] = dbus.MakeVariant(false) out["CanGoPrevious"] = dbus.MakeVariant(false) out["CanSeek"] = dbus.MakeVariant(false) } return out, nil } func (p *mpris) Set(iface, prop string, value dbus.Variant) *dbus.Error { return dbus.MakeFailedError(fmt.Errorf("read-only")) } // Player methods (no-op) func (p *mpris) Play() *dbus.Error { return nil } func (p *mpris) Pause() *dbus.Error { return nil } func (p *mpris) PlayPause() *dbus.Error { return nil } func (p *mpris) Stop() *dbus.Error { return nil } func (p *mpris) Next() *dbus.Error { return nil } func (p *mpris) Previous() *dbus.Error { return nil } func (p *mpris) Seek(offset int64) *dbus.Error { return nil } func (p *mpris) SetPosition(trackId dbus.ObjectPath, position int64) *dbus.Error { return nil } func (p *mpris) OpenUri(uri string) *dbus.Error { return nil } func makeMetadata(np nowPlaying) map[string]dbus.Variant { meta := map[string]dbus.Variant{ "mpris:trackid": dbus.MakeVariant(dbus.ObjectPath("/org/mpris/MediaPlayer2/track/0")), } if np.Title != "" { meta["xesam:title"] = dbus.MakeVariant(np.Title) } if np.Artist != "" { meta["xesam:artist"] = dbus.MakeVariant([]string{np.Artist}) } if np.Album != "" { meta["xesam:album"] = dbus.MakeVariant(np.Album) } if np.TrackNo > 0 { meta["xesam:trackNumber"] = dbus.MakeVariant(int32(np.TrackNo)) } if np.LengthUS > 0 { meta["mpris:length"] = dbus.MakeVariant(np.LengthUS) } return meta } func (p *mpris) update(np nowPlaying) { p.mu.Lock() defer p.mu.Unlock() changed := map[string]dbus.Variant{} if np.Status == "" { np.Status = "Stopped" } if p.status != np.Status { p.status = np.Status changed["PlaybackStatus"] = dbus.MakeVariant(p.status) } if p.position != np.PositionUS { p.position = np.PositionUS changed["Position"] = dbus.MakeVariant(p.position) } newMeta := map[string]dbus.Variant{} if np.Status != "Stopped" { newMeta = makeMetadata(np) } oldB, _ := json.Marshal(p.metadata) newB, _ := json.Marshal(newMeta) if !bytes.Equal(oldB, newB) { p.metadata = newMeta changed["Metadata"] = dbus.MakeVariant(p.metadata) } if len(changed) > 0 { p.emit(changed) } } // ---------- BlueZ RegisterPlayer ---------- func registerWithBlueZ(conn *dbus.Conn, adapter string) error { adapterPath := dbus.ObjectPath("/org/bluez/" + adapter) obj := conn.Object("org.bluez", adapterPath) props := map[string]dbus.Variant{ "Name": dbus.MakeVariant("Kodi"), "Type": dbus.MakeVariant("Audio"), } return obj.Call("org.bluez.Media1.RegisterPlayer", 0, objPath, props).Err } func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) cfg := loadConfig() conn, err := dbus.SystemBus() if err != nil { log.Fatalf("SystemBus: %v", err) } p := &mpris{ conn: conn, identity: "Kodi (AVRCP)", status: "Stopped", metadata: map[string]dbus.Variant{}, position: 0, } // Export MPRIS interfaces on SYSTEM bus (unique name; no RequestName) conn.Export(p, objPath, ifaceRoot) conn.Export(p, objPath, ifacePlay) conn.Export(p, objPath, ifaceProps) if err := registerWithBlueZ(conn, cfg.adapter); err != nil { log.Fatalf("RegisterPlayer failed on %s: %v", cfg.adapter, err) } log.Printf("Registered MPRIS player on adapter=%s path=%s; polling Kodi=%s", cfg.adapter, objPath, cfg.kodiURL) t := time.NewTicker(cfg.pollEvery) defer t.Stop() var lastErrLog time.Time for range t.C { ctx, cancel := context.WithTimeout(context.Background(), cfg.httpTimeout) np, err := getNowPlaying(ctx, cfg) cancel() if err != nil { if time.Since(lastErrLog) > 5*time.Second { lastErrLog = time.Now() log.Printf("Kodi poll error: %v", err) } np = nowPlaying{Status: "Stopped"} } p.update(np) } }