Covers the design rationale, the refill-cycle mechanism, three real issues found and fixed via live testing against a real Kodi session (a silent result-key mismatch that meant the daemon never once resolved a track, a starved similarity candidate pool, and a 4-minute poll-interval blind spot), and live proof of a working auto-queued run of similar tracks. Co-Authored-By: Claude Sonnet 5 <[email protected]>
9.3 KiB
🎧 Kodi <-> Music Assistant Smart Queue Bridge
Note
Kodi stays the only music player, UI, and ratings authority on this fleet — ratings and library metadata live in a shared MariaDB, untouched. Music Assistant runs invisibly behind it as a pure similarity engine, never opened day-to-day, never a player, never writing anything back to the library. A small daemon watches Kodi's live playback queue and auto-refills it with musically similar tracks when it runs low, using MA purely as a recommendation backend.
Why This Shape, Not The Obvious One
Two more obvious designs were deliberately rejected. The first: let MA write ratings and tags back into the audio files directly, so it could double as a second library manager. MA doesn't support this upstream, and even if it did, it would create two competing sources of truth for the same library — exactly the failure mode this design avoids. The second: make Kodi a playback target for Music Assistant, so MA could drive playback directly. The one community project for this integration (MAserver-Kodi-Provider) is archived and abandoned — betting a working setup on dead third-party code wasn't worth it.
What's left is the actual design: Kodi remains the single source of truth for everything a listener touches (library, ratings, playback), and MA is reduced to a disposable, stateless recommendation service — it could be deleted and rebuilt from an empty config tomorrow without losing a single rating or play count.
How A Refill Cycle Actually Works
kodi-ma-bridge is a small asyncio daemon, deployed as part of the same kodi stack as Kodi's MariaDB (not a separate stack — the two were merged once the bridge was built, since the bridge only exists to serve Kodi):
$ docker ps --filter name=kodi --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"
NAMES STATUS IMAGE
kodi-ma-bridge Up 18 minutes python:3.13-slim
kodi-mariadb Up 38 hours (healthy) <fleet registry>/mariadb:latest
Every 60 seconds (POLL_INTERVAL_SECONDS=60), it checks each configured Kodi instance:
$ docker exec kodi-ma-bridge env | grep -E "POLL_INTERVAL|REFILL|MA_BASE_URL|KODI_INSTANCES"
KODI_INSTANCES=[personal-device-1]:<host>:8115,[personal-device-2]:<host>:8115
MA_BASE_URL=http://music-assistant:8095
POLL_INTERVAL_SECONDS=60
REFILL_COUNT=10
REFILL_THRESHOLD=5
- Ask Kodi's JSON-RPC API (
Player.GetActivePlayers) whether anything's playing. If the instance is unreachable or idle, skip silently — these are personal desktops, not always-on servers, so "nothing there right now" is normal, not an error condition. - If something's playing, check how many tracks remain after the current position (
Playlist.GetItems+Player.GetProperties). AboveREFILL_THRESHOLD(5 remaining), stop here — nothing to do yet. - Below the threshold: read the current track's artist/title and resolve it to an MA library item via
music/search. - Ask MA's
music/tracks/similar_tracksfor a large candidate pool, filter out anything already queued or recently added, translate MA's library-relative path to Kodi's SMB path, and push up toREFILL_COUNT(10) new tracks straight into the live playlist viaPlaylist.Add.
That queue-length gate is the one real branch in the whole design — everything else is a straight pipeline — so it's worth seeing as a diagram rather than another paragraph of prose:
Three Real Issues, Found By Actually Testing It
The bridge sat deployed and silently non-functional for weeks before this. All three of the following were found by doing the one thing that actually proves a system works: playing real music on a real machine and watching what happened, not just exercising the API calls in isolation.
1 — the daemon never once successfully resolved a track. Kodi's own JSON-RPC API wraps every response in a result key, so the bridge's generic API-calling helper was written to unwrap it the same way for every call, including the ones to MA. MA's REST API doesn't use that envelope at all — it returns the payload flat, at the top level:
$ curl -s -X POST http://music-assistant:8095/api -H "Content-Type: application/json" \
-d '{"message_id":"t","command":"music/search","args":{"search_query":"Oasis - Champagne Supernova","media_types":["track"],"limit":1,"library_only":true}}' \
| python3 -c "import json,sys; d=json.load(sys.stdin); print('top-level keys:', list(d.keys())); print('has result key:', 'result' in d)"
top-level keys: ['artists', 'albums', 'genres', 'tracks', 'playlists', 'radio', 'audiobooks', 'podcasts', 'sound_effects']
has result key: False
data.get("result") against that shape silently returns None every time — no exception, no log line, just a daemon that looked alive and never did anything. Confirmed live against a real Kodi playback session before touching a line of code; fixed by returning MA's response body directly instead of unwrapping a key that was never there.
2 — even after the fix, MA only ever offered one real match for a mainstream track. The bridge asked MA for a small candidate pool (15 tracks) and assumed that was the full ranked result. It isn't — MA runs its own internal similarity-distance filter over whatever raw pool size you request, so most of a small batch gets discarded before it ever reaches the caller:
limit=10 -> 1 match
limit=15 -> 1 match (the bridge's original request size)
limit=20 -> 1 match
limit=30 -> 2 matches
limit=50 -> 5 matches
limit=100 -> 6 matches
limit=150 -> 8 matches
limit=200 -> 10 matches
The real candidate pool for that seed track was there the whole time — a solid run of mid-90s Britpop and alt-rock (The Verve, The Cranberries, Ocean Colour Scene, Beck, Manic Street Preachers) — it just never got a large enough raw pool to survive MA's own filtering. Fixed by requesting 200 candidates up front; the bridge's own REFILL_COUNT cap still bounds how many actually get queued.
3 — a real blind-spot window between the queue running dry and the bridge noticing. With both of the above fixed and the daemon genuinely working, the original 4-minute poll interval turned out to be too coarse on its own: during one active listening session the live queue hit 0 remaining tracks twice before the next scheduled poll caught it and refilled —
[personal-device] queue low (0 left), added 10 tracks seeded from 'Primal Scream - Shine Like Stars'
[personal-device] queue low (0 left), added 10 tracks seeded from 'Kula Shaker - Govinda'
REFILL_THRESHOLD=5 only guarantees a refill attempt starts with 5 tracks of runway left — it says nothing about how long that runway lasts in wall-clock time, and a 4-minute gap between checks was long enough, on short tracks, to burn through that runway before the next poll ever ran. Playback itself never actually stopped in practice, but the margin was thinner than the design assumed. Fixed by shortening the poll interval to 60 seconds rather than widening the threshold — each idle poll is a handful of cheap JSON-RPC calls, so checking 4x more often costs effectively nothing.
Proof It Actually Works
Confirmed end-to-end against a live Kodi session, mid-album, queue genuinely running low:
[personal-device] queue low (2 left), added 8 tracks seeded from 'Oasis - Champagne Supernova'
And the live playlist, read directly from Kodi right after:
$ curl -s -u <user>:*** -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"Playlist.GetItems","params":{"playlistid":0,"properties":["title","artist"]},"id":1}' \
http://<host>:8115/jsonrpc
...
02. The Verve - Sonnet
The Day We Caught the Train
Dreams
Loser
You Stole the Sun From My Heart
Movin' On Up
Blue Orchid
Buck Rogers
Reptilia
Use Somebody
A real, coherent 90s/00s indie-and-alt-rock run, auto-queued with zero manual input, directly behind the Oasis album that was already playing:
Similarity Engine
Matching is powered by MA's sonic_analysis provider (on-device CLAP audio-embedding analysis — no cloud service, no internet dependency for recommendations) feeding the sonic_similarity plugin, confirmed enabled against the running library:
$ docker exec music-assistant python3 -c "..."
sonic_analysis -> enabled: True
sonic_similarity -> enabled: True
filesystem_local -> enabled: True
The library syncs every 12 hours and diffs the file list, so a replaced file (an upgraded rip, say) is picked up as a new file and the stale entry pruned automatically — no manual re-indexing required.
Secrets
Both the Kodi JSON-RPC password and the MA API token are Vault-sourced via the fleet's standard 3-file docker-secrets pattern (see docs/secret-distribution.md) — nothing hardcoded in the compose file or the bridge script itself.
