Compare commits

..
6 Commits
Author SHA1 Message Date
thesabear e8c2cf37e9 Merge pull request 'docs: iac commit IDs after its history rewrite (ISO removed, content unchanged)' (#3) from iac-history-ids into main 2026-10-16 22:47:12 +03:00
automation-bot 10efff2298 docs: iac commit IDs after its history rewrite (ISO removed, content unchanged) 2026-10-16 22:46:46 +03:00
thesabear 503747ffb1 Merge pull request 'docs, stacks: Caddyfiles moved to a folder mount (conf/Caddyfile)' (#2) from caddy-folder-mount into main 2026-10-16 19:31:49 +03:00
automation-bot ecbcbe693c docs, stacks: Caddyfiles moved to a folder mount (conf/Caddyfile) 2026-10-16 19:30:23 +03:00
thesabear 41d5f12df0 Merge pull request 'docs: add Kodi <-> Music Assistant smart queue bridge writeup' (#1) from add-kodi-ma-bridge-doc into main 2026-10-16 12:35:08 +03:00
automation-bot fa5cbc1ec5 docs: add Kodi <-> Music Assistant smart queue bridge writeup
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.
2026-10-16 12:33:33 +03:00
19 changed files with 166 additions and 23 deletions

No files matched your search

+4
View File
@@ -93,6 +93,10 @@ Every physical host, VM/LXC, and running service in the fleet, re-derived from t
Packer builds a hardened base image, Terraform provisions each VM from it, and Ansible configures and continuously re-hardens every host after that — three tools, three separate responsibilities, connected by one tag that threads through all three stages instead of one tool trying to do every job. Terraform's part in that link ends at creation — it writes the tags file once, and that file then drives Ansible independently for the rest of that host's life. Packer builds a hardened base image, Terraform provisions each VM from it, and Ansible configures and continuously re-hardens every host after that — three tools, three separate responsibilities, connected by one tag that threads through all three stages instead of one tool trying to do every job. Terraform's part in that link ends at creation — it writes the tags file once, and that file then drives Ansible independently for the rest of that host's life.
→ [docs/iac-pipeline.md](docs/iac-pipeline.md) → [docs/iac-pipeline.md](docs/iac-pipeline.md)
### 🎧 Kodi <-> Music Assistant Smart Queue Bridge
Kodi stays the only music player, UI, and ratings authority; Music Assistant runs invisibly behind it as a similarity engine, never a player, never writing anything back to the library. A small daemon watches Kodi's live queue and auto-refills it with similar tracks via MA's recommendation API when it runs low. Three real issues surfaced only once it was tested against a live Kodi session — not exercised in isolation — including one where the daemon had silently never worked at all.
→ [docs/kodi-music-assistant-bridge.md](docs/kodi-music-assistant-bridge.md)
### 🔔 Notifications ### 🔔 Notifications
Every alert in the fleet — from apps, from hosts, from security events — funnels through one Node-RED instance and one shared formatter before reaching Slack, so a notification reads the same regardless of which of a dozen-plus sources triggered it. A dedicated role puts every host onto the same pipeline for OS-level health, independent of any single application. One shared endpoint handles every app's webhook regardless of shape — a source field where an app provides one, structural fingerprinting of the payload where it doesn't — instead of standing up a dedicated integration per app. Every alert in the fleet — from apps, from hosts, from security events — funnels through one Node-RED instance and one shared formatter before reaching Slack, so a notification reads the same regardless of which of a dozen-plus sources triggered it. A dedicated role puts every host onto the same pipeline for OS-level health, independent of any single application. One shared endpoint handles every app's webhook regardless of shape — a source field where an app provides one, structural fingerprinting of the payload where it doesn't — instead of standing up a dedicated integration per app.
→ [docs/notifications.md](docs/notifications.md) → [docs/notifications.md](docs/notifications.md)
Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Do not edit this file with editors other than draw.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent; color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="624px" height="632px" viewBox="0 0 624 632" content="&lt;mxfile host=&quot;embed.diagrams.net&quot; scale=&quot;1&quot; border=&quot;0&quot;&gt;&#xA; &lt;diagram name=&quot;Refill Cycle&quot; id=&quot;0&quot;&gt;&#xA; &lt;mxGraphModel dx=&quot;1398&quot; dy=&quot;830&quot; grid=&quot;0&quot; gridSize=&quot;10&quot; guides=&quot;1&quot; tooltips=&quot;1&quot; connect=&quot;1&quot; arrows=&quot;1&quot; fold=&quot;1&quot; page=&quot;1&quot; pageScale=&quot;1&quot; pageWidth=&quot;900&quot; pageHeight=&quot;700&quot; math=&quot;0&quot; shadow=&quot;0&quot;&gt;&#xA; &lt;root&gt;&#xA; &lt;mxCell id=&quot;0&quot; /&gt;&#xA; &lt;mxCell id=&quot;1&quot; parent=&quot;0&quot; /&gt;&#xA; &lt;mxCell id=&quot;poll&quot; parent=&quot;1&quot; style=&quot;rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;&quot; value=&quot;Poll each Kodi instance&amp;lt;br&amp;gt;(every 240s)&quot; vertex=&quot;1&quot;&gt;&#xA; &lt;mxGeometry height=&quot;60&quot; width=&quot;220&quot; x=&quot;340&quot; y=&quot;40&quot; as=&quot;geometry&quot; /&gt;&#xA; &lt;/mxCell&gt;&#xA; &lt;mxCell id=&quot;active&quot; parent=&quot;1&quot; style=&quot;rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;&quot; value=&quot;Active player + queue&amp;lt;br&amp;gt;length on this instance&quot; vertex=&quot;1&quot;&gt;&#xA; &lt;mxGeometry height=&quot;60&quot; width=&quot;220&quot; x=&quot;340&quot; y=&quot;150&quot; as=&quot;geometry&quot; /&gt;&#xA; &lt;/mxCell&gt;&#xA; &lt;mxCell id=&quot;gate&quot; parent=&quot;1&quot; style=&quot;rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;&quot; value=&quot;&amp;lt;font style=&amp;quot;font-size: 11px;&amp;quot;&amp;gt;Remaining &amp;amp;gt;&amp;lt;br&amp;gt;REFILL_THRESHOLD (5)?&amp;lt;/font&amp;gt;&quot; vertex=&quot;1&quot;&gt;&#xA; &lt;mxGeometry height=&quot;90&quot; width=&quot;190&quot; x=&quot;355&quot; y=&quot;260&quot; as=&quot;geometry&quot; /&gt;&#xA; &lt;/mxCell&gt;&#xA; &lt;mxCell id=&quot;resolve&quot; parent=&quot;1&quot; style=&quot;rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=13;&quot; value=&quot;Resolve current track&amp;lt;br&amp;gt;in MA (music/search)&quot; vertex=&quot;1&quot;&gt;&#xA; &lt;mxGeometry height=&quot;60&quot; width=&quot;220&quot; x=&quot;340&quot; y=&quot;400&quot; as=&quot;geometry&quot; /&gt;&#xA; &lt;/mxCell&gt;&#xA; &lt;mxCell id=&quot;similar&quot; parent=&quot;1&quot; style=&quot;rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=13;&quot; value=&quot;Query similar_tracks&amp;lt;br&amp;gt;(large candidate pool)&quot; vertex=&quot;1&quot;&gt;&#xA; &lt;mxGeometry height=&quot;60&quot; width=&quot;220&quot; x=&quot;340&quot; y=&quot;500&quot; as=&quot;geometry&quot; /&gt;&#xA; &lt;/mxCell&gt;&#xA; &lt;mxCell id=&quot;inject&quot; parent=&quot;1&quot; style=&quot;rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;&quot; value=&quot;Filter + translate paths,&amp;lt;br&amp;gt;Playlist.Add up to 10&quot; vertex=&quot;1&quot;&gt;&#xA; &lt;mxGeometry height=&quot;60&quot; width=&quot;220&quot; x=&quot;340&quot; y=&quot;600&quot; as=&quot;geometry&quot; /&gt;&#xA; &lt;/mxCell&gt;&#xA; &lt;mxCell id=&quot;e1&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;poll&quot; style=&quot;edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#333333;&quot; target=&quot;active&quot;&gt;&#xA; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot; /&gt;&#xA; &lt;/mxCell&gt;&#xA; &lt;mxCell id=&quot;e2&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;active&quot; style=&quot;edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#333333;&quot; target=&quot;gate&quot;&gt;&#xA; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot; /&gt;&#xA; &lt;/mxCell&gt;&#xA; &lt;mxCell id=&quot;e3&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;gate&quot; style=&quot;edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#b85450;exitX=1;exitY=0.5;labelBackgroundColor=#1C1D1E;exitDx=0;exitDy=0;&quot; target=&quot;poll&quot; value=&quot;&amp;lt;span style=&amp;quot;background-color: light-dark(rgb(255, 255, 255), rgb(28, 29, 30));&amp;quot;&amp;gt;yes &amp;amp;mdash; skip&amp;lt;/span&amp;gt;&quot;&gt;&#xA; &lt;mxGeometry relative=&quot;1&quot; x=&quot;0.0698&quot; y=&quot;-33&quot; as=&quot;geometry&quot;&gt;&#xA; &lt;mxPoint as=&quot;offset&quot; /&gt;&#xA; &lt;Array as=&quot;points&quot;&gt;&#xA; &lt;mxPoint x=&quot;60Line truncated

After

Width:  |  Height:  |  Size: 162 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
<!-- verified: 2026-09-18 <!-- verified: 2026-09-18
rocky-docker-lxc@7dd5025 : caddy/Caddyfile rocky-docker-lxc@d225776 : caddy/conf/Caddyfile
caddy-vm@9369f8b : caddy/Caddyfile caddy-vm@c5c3420 : caddy/conf/Caddyfile
trilium : 2wCPV6NXQa3l, 0mu75aAXGH8X, fscYadnUtmD5, QMTh5G7ldlLV, TIbCwYGitboy, Cl4TWBZ7ytYr, 6cJurv76PNDR, nAPJq9A1iuX8, DvKxK0gHXsng trilium : 2wCPV6NXQa3l, 0mu75aAXGH8X, fscYadnUtmD5, QMTh5G7ldlLV, TIbCwYGitboy, Cl4TWBZ7ytYr, 6cJurv76PNDR, nAPJq9A1iuX8, DvKxK0gHXsng
--> -->
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- verified: 2026-09-18 <!-- verified: 2026-09-18
iac@f770e1c : ansible/roles_metadata.yml iac@ae315de : ansible/roles_metadata.yml
ansible@24e1bf0 : roles/os_update, playbooks/os_update_vms.yml, playbooks/os_update_pve.yml, playbooks/os_update_pbs.yml, playbooks/app_update.yml ansible@24e1bf0 : roles/os_update, playbooks/os_update_vms.yml, playbooks/os_update_pve.yml, playbooks/os_update_pbs.yml, playbooks/app_update.yml
trilium : xJRSQovMncQr, 6AbdS9cUE45T, bW84ccRoAJ5i, p45qNP2sIuhA, 6oESvJYzj1at trilium : xJRSQovMncQr, 6AbdS9cUE45T, bW84ccRoAJ5i, p45qNP2sIuhA, 6oESvJYzj1at
--> -->
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- verified: 2026-09-06 <!-- verified: 2026-09-06
iac@49ff427 : ansible/roles/bootstrap_roles/firewall_ssh, ansible/roles/secure_vault/firewall_rich_rules_per_vm, ansible/roles/cis_hardening/password_policy iac@49d9773 : ansible/roles/bootstrap_roles/firewall_ssh, ansible/roles/secure_vault/firewall_rich_rules_per_vm, ansible/roles/cis_hardening/password_policy
trilium : 0mu75aAXGH8X, o3DDWo1v54vI, oEbKGleDKuvb, EpssgVXxqIbE, 0R8GeBmH7boV, TIbCwYGitboy, fscYadnUtmD5, yUJqswizuET2, QMTh5G7ldlLV, jBPOx07B1Y2Z, Cl4TWBZ7ytYr, 6cJurv76PNDR, kwbbLjeI6ody, MSVD5o5HiJ69, kA00FQiQWVem trilium : 0mu75aAXGH8X, o3DDWo1v54vI, oEbKGleDKuvb, EpssgVXxqIbE, 0R8GeBmH7boV, TIbCwYGitboy, fscYadnUtmD5, yUJqswizuET2, QMTh5G7ldlLV, jBPOx07B1Y2Z, Cl4TWBZ7ytYr, 6cJurv76PNDR, kwbbLjeI6ody, MSVD5o5HiJ69, kA00FQiQWVem
--> -->
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- verified: 2026-09-07 <!-- verified: 2026-09-07
ansible@9a4ba6d : roles/homelab_inventory_hardware, roles/homelab_inventory_vms, roles/homelab_inventory_services, playbooks/homelab_inventory_hardware.yml, playbooks/homelab_inventory_vms.yml, playbooks/homelab_inventory_services.yml ansible@9a4ba6d : roles/homelab_inventory_hardware, roles/homelab_inventory_vms, roles/homelab_inventory_services, playbooks/homelab_inventory_hardware.yml, playbooks/homelab_inventory_vms.yml, playbooks/homelab_inventory_services.yml
rocky-docker-lxc@145d3df : caddy/Caddyfile, dashy/docker-compose.yaml rocky-docker-lxc@d225776 : caddy/conf/Caddyfile, dashy/docker-compose.yaml
trilium : O0d4fqw0mr05 trilium : O0d4fqw0mr05
--> -->
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- verified: 2026-09-19 <!-- verified: 2026-09-19
iac@f770e1c : packer/rocky10-proxmox-template, terraform, ansible/roles_metadata.yml, ansible/playbooks/provisioning.yml, ansible/host_vars/technitium/host_tags.yml, ansible/roles/cis_hardening/password_policy iac@ae315de : packer/rocky10-proxmox-template, terraform, ansible/roles_metadata.yml, ansible/playbooks/provisioning.yml, ansible/host_vars/technitium/host_tags.yml, ansible/roles/cis_hardening/password_policy
trilium : c5JADrRVvjIn, f1uTcvXvq0u3, 7anU4VULpp7n, sovEBDmn16hh, sqVGFejKPOFn, kA00FQiQWVem, MSVD5o5HiJ69, NwwrSew7mP2z, QgviHP8r14t2 trilium : c5JADrRVvjIn, f1uTcvXvq0u3, 7anU4VULpp7n, sovEBDmn16hh, sqVGFejKPOFn, kA00FQiQWVem, MSVD5o5HiJ69, NwwrSew7mP2z, QgviHP8r14t2
--> -->
+134
View File
@@ -0,0 +1,134 @@
<!-- verified: 2026-10-16
rocky-docker-lxc@1ede96f : kodi/bridge.py, kodi/docker-compose.yaml, kodi/.env, music-assistant/docker-compose.yaml
trilium : Y2VzE58wL8w1
-->
# 🎧 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
```
1. 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.
2. If something's playing, check how many tracks remain after the current position (`Playlist.GetItems` + `Player.GetProperties`). Above `REFILL_THRESHOLD` (5 remaining), stop here — nothing to do yet.
3. Below the threshold: read the current track's artist/title and resolve it to an MA library item via `music/search`.
4. Ask MA's `music/tracks/similar_tracks` for 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 to `REFILL_COUNT` (10) new tracks straight into the live playlist via `Playlist.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:
![kodi-ma-bridge refill cycle: poll each Kodi instance, check remaining queue length, and only resolve+inject new tracks when the queue actually drops below threshold](../assets/kodi-music-assistant-bridge-refill-cycle.svg)
## 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:
![Kodi's playlist view, showing a long auto-queued run of similar indie/alt-rock tracks the bridge added on its own once the queue ran low](../assets/kodi-music-assistant-bridge-live-queue.png)
## 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`](secret-distribution.md)) — nothing hardcoded in the compose file or the bridge script itself.
+5 -4
View File
@@ -1,6 +1,6 @@
<!-- verified: 2026-09-19 <!-- verified: 2026-09-19
netbird-lxc@13b5d80 : netbird/Caddyfile, netbird/docker-compose.yaml, netbird/config.yaml.ctmpl netbird-lxc@ea8967e : netbird/conf/Caddyfile, netbird/docker-compose.yaml, netbird/config.yaml.ctmpl
iac@f770e1c : ansible/roles/secure_vault/netbird_agent, terraform/containers.tf iac@ae315de : ansible/roles/secure_vault/netbird_agent, terraform/containers.tf
trilium : qWLU4xqYLdJ3, oYuIjaczDrZA, nAPJq9A1iuX8, Cl4TWBZ7ytYr trilium : qWLU4xqYLdJ3, oYuIjaczDrZA, nAPJq9A1iuX8, Cl4TWBZ7ytYr
--> -->
@@ -56,9 +56,10 @@ The real path a request takes:
And this is the actual splitter doing that work — the real config, not a description of the logic: And this is the actual splitter doing that work — the real config, not a description of the logic:
``` ```
$ cat Caddyfile $ cat conf/Caddyfile
{ {
admin off # Container loopback only (port not published) - `caddy reload` needs it.
admin localhost:2019
auto_https off auto_https off
servers { servers {
protocols h1 h2 h2c protocols h1 h2 h2c
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- verified: 2026-09-19 <!-- verified: 2026-09-19
automation-vm@7c057de : mosquitto automation-vm@7c057de : mosquitto
iac@f770e1c : ansible/roles/monitoring/mqtt_client iac@ae315de : ansible/roles/monitoring/mqtt_client
trilium : tt4idCVJPpZM, EAU5LUwFFQ0E, rGfAb4CddKMi, otSPaU6IfjlP, klrmnlh3Wrnd trilium : tt4idCVJPpZM, EAU5LUwFFQ0E, rGfAb4CddKMi, otSPaU6IfjlP, klrmnlh3Wrnd
--> -->
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- verified: 2026-09-19 <!-- verified: 2026-09-19
iac@f770e1c : ansible/roles/monitoring/observability_agent iac@ae315de : ansible/roles/monitoring/observability_agent
trilium : eeiUz4iTEgPL, zquji4XOpw5M, s5tWZshDEpQb, oWr1jJhvqitv trilium : eeiUz4iTEgPL, zquji4XOpw5M, s5tWZshDEpQb, oWr1jJhvqitv
--> -->
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- verified: 2026-09-19 <!-- verified: 2026-09-19
ansible@24e1bf0 : host_vars/rocky-docker/docker_stacks.yml, host_vars/netbird/docker_stacks.yml, roles/container_update, playbooks/container_update.yml ansible@24e1bf0 : host_vars/rocky-docker/docker_stacks.yml, host_vars/netbird/docker_stacks.yml, roles/container_update, playbooks/container_update.yml
iac@f770e1c : ansible/roles/secure_vault/vault_agent/defaults/main.yml iac@ae315de : ansible/roles/secure_vault/vault_agent/defaults/main.yml
trilium : dcRUArk5dWH8, bW84ccRoAJ5i, hWiIGZIlvQiN trilium : dcRUArk5dWH8, bW84ccRoAJ5i, hWiIGZIlvQiN
--> -->
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- verified: 2026-09-19 <!-- verified: 2026-09-19
iac@f770e1c : ansible/roles/secure_vault/ssh_ca_client, ansible/roles/secure_vault/vault_agent/defaults/main.yml, ansible/roles/secure_vault/SecureVault – Vault Contract.md, ansible/roles/secure_vault/securevault-vault-facts.md iac@ae315de : ansible/roles/secure_vault/ssh_ca_client, ansible/roles/secure_vault/vault_agent/defaults/main.yml, ansible/roles/secure_vault/SecureVault – Vault Contract.md, ansible/roles/secure_vault/securevault-vault-facts.md
ansible@24e1bf0 : roles/app_update/defaults/main.yml ansible@24e1bf0 : roles/app_update/defaults/main.yml
trilium : DvKxK0gHXsng, wovVrgvoDVy2, GW8BMoTKtRBe, 1seDR2nBmxKO trilium : DvKxK0gHXsng, wovVrgvoDVy2, GW8BMoTKtRBe, 1seDR2nBmxKO
--> -->
+2 -2
View File
@@ -1,6 +1,6 @@
<!-- verified: 2026-09-19 <!-- verified: 2026-09-19
iac@f770e1c : ansible/roles/secure_vault, ansible/group_vars/all/vault_provisioner.yml, ansible/roles/monitoring/mqtt_client iac@ae315de : ansible/roles/secure_vault, ansible/group_vars/all/vault_provisioner.yml, ansible/roles/monitoring/mqtt_client
rocky-docker-lxc@7dd5025 : caddy/Caddyfile rocky-docker-lxc@d225776 : caddy/conf/Caddyfile
trilium : Eu0wns00QOBG, wovVrgvoDVy2, 1seDR2nBmxKO, GW8BMoTKtRBe, JbKe9CHzOP9D, Tu7FX48fxSQZ, DvKxK0gHXsng, otSPaU6IfjlP trilium : Eu0wns00QOBG, wovVrgvoDVy2, 1seDR2nBmxKO, GW8BMoTKtRBe, JbKe9CHzOP9D, Tu7FX48fxSQZ, DvKxK0gHXsng, otSPaU6IfjlP
--> -->
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- verified: 2026-09-19 <!-- verified: 2026-09-19
iac@f770e1c : ansible/roles/monitoring/wazuh_agent_tuning, ansible/roles/monitoring/docker_bench, ansible/roles/cis_hardening iac@ae315de : ansible/roles/monitoring/wazuh_agent_tuning, ansible/roles/monitoring/docker_bench, ansible/roles/cis_hardening
wazuh-vm@07ed482 : wazuh/docker-compose.yml wazuh-vm@07ed482 : wazuh/docker-compose.yml
trilium : KpvtX1USDhkY, TstjWVV5Q1TS, OtIgVyQC5lJg, MSVD5o5HiJ69, kA00FQiQWVem, oY5CbyuxZRyS trilium : KpvtX1USDhkY, TstjWVV5Q1TS, OtIgVyQC5lJg, MSVD5o5HiJ69, kA00FQiQWVem, oY5CbyuxZRyS
--> -->
+3 -3
View File
@@ -1,7 +1,7 @@
<!-- verified: 2026-09-18 <!-- verified: 2026-09-18
rocky-docker-lxc@7dd5025 : caddy/Caddyfile rocky-docker-lxc@d225776 : caddy/conf/Caddyfile
caddy-vm@9369f8b : caddy/Caddyfile caddy-vm@c5c3420 : caddy/conf/Caddyfile
iac@f770e1c : ansible/roles/bootstrap_roles/docker_git_setup iac@ae315de : ansible/roles/bootstrap_roles/docker_git_setup
trilium : baWhWTUejvY6, hWiIGZIlvQiN, nAPJq9A1iuX8 trilium : baWhWTUejvY6, hWiIGZIlvQiN, nAPJq9A1iuX8
--> -->
@@ -6,11 +6,11 @@ services:
ports: ports:
- "${HTTPS_PORT}:443" - "${HTTPS_PORT}:443"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile - ./conf:/etc/caddy:ro
- /path/to/caddy-internal/caddy_data:/data - /path/to/caddy-internal/caddy_data:/data
- /path/to/caddy-internal/caddy_config:/config - /path/to/caddy-internal/caddy_config:/config
- /path/to/caddy-internal/logs:/var/log/caddy - /path/to/caddy-internal/logs:/var/log/caddy
- ./certs/pki_int_ca.crt:/etc/caddy/certs/pki_int_ca.crt:ro - ./certs:/etc/caddy/certs:ro
networks: networks:
- caddy-net - caddy-net
+1 -1
View File
@@ -6,7 +6,7 @@ services:
ports: ports:
- "${HTTPS_PORT}:443" - "${HTTPS_PORT}:443"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile - ./conf:/etc/caddy:ro
- /path/to/caddy/caddy_data:/data - /path/to/caddy/caddy_data:/data
- /path/to/caddy/caddy_config:/config - /path/to/caddy/caddy_config:/config
- /etc/caddy/certs:/etc/caddy/certs - /etc/caddy/certs:/etc/caddy/certs