feat: emby-mcp 1.0.16 player owner filter and HA links #5

Merged
gronod merged 2 commits from develop into main 2026-09-25 21:13:42 +01:00
20 changed files with 696 additions and 15 deletions
+22
View File
@@ -1,5 +1,27 @@
# Changelog # Changelog
## 1.0.16
- Player rows include `user_id`, `user_name`, `online`, and keep
`device_ip_address` (Emby session `RemoteEndPoint`, host only)
- `retrieve_player_list` accepts `users` (comma-separated include-list)
and `include_offline` (merge `GET /Devices` last-used owners)
- Add-on options `player_users` and `player_links` feed a default
include-list and HA entity map (`ha_entity`, `ha_source`, `device_ip`,
`emby_device_id`)
- New tool `resolve_media_player` for "play on the lounge": returns
HA entity/source plus `session_id` when live, or `wake_required`
- Conversation Functions YAML and system prompt updated in DOCS.md
### Potential breaking change
- Player JSON grows `user_id`, `user_name`, and `online`. Re-paste the
DOCS.md Functions block to pick up `emby_list_players` parameters and
`emby_resolve_player`. Existing PlayNow calls are unchanged.
- Empty `player_users` means no default filter (same listing as 1.0.15,
plus owner fields). Non-empty `player_users` hides other accounts even
when the model omits `users`.
## 1.0.15 ## 1.0.15
- Restore `premiere_date` on item-shaped results (search, episode/season - Restore `premiere_date` on item-shaped results (search, episode/season
+33 -3
View File
@@ -34,6 +34,8 @@ The add-on runs the Go Emby.MCP server in one container and exposes:
| `mcp_listen_addr` | `0.0.0.0:8085` | Bind address | | `mcp_listen_addr` | `0.0.0.0:8085` | Bind address |
| `mcp_session_timeout` | `30m` | MCP session lifetime | | `mcp_session_timeout` | `30m` | MCP session lifetime |
| `log_level` | `INFO` | `DEBUG`, `INFO`, or `WARN` | | `log_level` | `INFO` | `DEBUG`, `INFO`, or `WARN` |
| `player_users` | `[]` | Default include-list of Emby usernames/ids applied when a tool call omits `users` |
| `player_links` | `[]` | Maps spoken names to HA `media_player` + Emby device. `names` is comma-separated. Set `device_ip` when the TV's LAN IP is known (Emby `device_ip_address` on a live session). WebOS often does **not** put that IP on the HA entity — copy it from the router or from an Emby session while the app is open. `emby_device_id` is the most stable key. |
| `debug.rest` | _(optional)_ | Shown under optional options. At DEBUG, log REST bodies | | `debug.rest` | _(optional)_ | Shown under optional options. At DEBUG, log REST bodies |
| `debug.mcp` | _(optional)_ | Shown under optional options. At DEBUG, log `/mcp` bodies | | `debug.mcp` | _(optional)_ | Shown under optional options. At DEBUG, log `/mcp` bodies |
| `debug.emby` | _(optional)_ | Shown under optional options. At DEBUG, log Emby API bodies | | `debug.emby` | _(optional)_ | Shown under optional options. At DEBUG, log Emby API bodies |
@@ -101,7 +103,7 @@ These steps assume the add-on has Emby credentials configured and
Add this line to the conversation agent's instructions: Add this line to the conversation agent's instructions:
```text ```text
- Media & Emby: Never use execute_services for Emby. Use `emby_search` to find films, TV series, episodes, or music tracks. Use `emby_episode_list` to list or count a series' episodes (seasons via `emby_season_list`). Each episode item includes `premiere_date` (YYYY-MM-DD first air date) when Emby has that metadata; use it for airdate questions. Use `emby_list_players` to find active playback sessions, and `emby_playback_control` to send playback commands (PlayNow, Pause, Unpause, Stop, NextTrack). Use `emby_next_episode` when asked what episode to watch next. - Media & Emby: Never use execute_services for Emby search or playback. Use `emby_search` to find films, TV series, episodes, or music tracks. Use `emby_episode_list` to list or count a series' episodes (seasons via `emby_season_list`). Each episode item includes `premiere_date` (YYYY-MM-DD first air date) when Emby has that metadata; use it for airdate questions. Use `emby_resolve_player` when the user names a room or TV ("play on the lounge"). If `wake_required` is true, use Home Assistant `media_player.turn_on` on `ha_entity` and `media_player.select_source` with `ha_source` (Emby), wait, then resolve again. Only then use `emby_playback_control` PlayNow with that `session_id`. Use `emby_list_players` with `users` set to the household include-list to hide other people's clients. Use `emby_next_episode` when asked what episode to watch next.
``` ```
### Functions ### Functions
@@ -146,19 +148,47 @@ Paste the following into the conversation agent's **Functions** list
- spec: - spec:
name: emby_list_players name: emby_list_players
description: List active Emby media players, active clients, and their session IDs. description: List Emby media players. Each row includes user_name, user_id, device_ip_address, and online. Pass users as a comma-separated include-list of Emby usernames so other household accounts are omitted. Set include_offline true to include last-used devices with no live session.
parameters: parameters:
type: object type: object
properties: properties:
media_type: media_type:
type: string type: string
description: Filter by player type ('Video', 'Audio', 'Photo'), or leave empty for all. description: Filter by player type ('Video', 'Audio', 'Photo'), or leave empty for all.
users:
type: string
description: Comma-separated Emby usernames or user ids to include (household include-list).
include_offline:
type: boolean
description: Also list known Emby devices that have no live session (last-used user).
function: function:
type: rest type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_player_list" resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_player_list"
method: POST method: POST
payload_template: >- payload_template: >-
{{ { "media_type": media_type | default("") } | to_json }} {{ { "media_type": media_type | default(""), "users": users | default(""), "include_offline": include_offline | default(false) } | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_resolve_player
description: Resolve a room name, person, HA entity, Emby device name, device id, or IP to ha_entity/ha_source plus a live session_id when the Emby client is running. If wake_required is true, turn on the HA media_player and launch Emby before PlayNow.
parameters:
type: object
properties:
query:
type: string
description: Spoken name (lounge), media_player entity_id, Emby device name, device id, or IP.
users:
type: string
description: Comma-separated Emby usernames or user ids to include.
required:
- query
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/resolve_media_player"
method: POST
payload_template: >-
{{ { "query": query, "users": users | default("") } | to_json }}
value_template: "{{ value_json.result }}" value_template: "{{ value_json.result }}"
- spec: - spec:
+1 -1
View File
@@ -20,7 +20,7 @@ LABEL \
io.hass.name="Emby MCP" \ io.hass.name="Emby MCP" \
io.hass.description="Emby Model Context Protocol server with REST bridge" \ io.hass.description="Emby Model Context Protocol server with REST bridge" \
io.hass.type="addon" \ io.hass.type="addon" \
io.hass.version="1.0.15" \ io.hass.version="1.0.16" \
org.opencontainers.image.title="emby-mcp" \ org.opencontainers.image.title="emby-mcp" \
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons" org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons"
CMD [ "/run.sh" ] CMD [ "/run.sh" ]
+1 -1
View File
@@ -1 +1 @@
1.0.15 1.0.16
+2
View File
@@ -122,6 +122,8 @@ func runStdio(ctx context.Context, cfg *config.Config, checkOnly bool) {
logf("Startup checks have completed.\n\nRunning HA Emby MCP in standalone mode, press CTRL-C to exit.") logf("Startup checks have completed.\n\nRunning HA Emby MCP in standalone mode, press CTRL-C to exit.")
st := state.New(client, userID, cfg.MaxChunkSize) st := state.New(client, userID, cfg.MaxChunkSize)
st.PlayerUsers = cfg.PlayerUsers
st.PlayerLinks = cfg.PlayerLinks
srv := server.New(st) srv := server.New(st)
runErr := srv.Run(ctx, &mcp.StdioTransport{}) runErr := srv.Run(ctx, &mcp.StdioTransport{})
+13 -1
View File
@@ -2,7 +2,7 @@ name: "Emby MCP"
description: >- description: >-
Emby Model Context Protocol server with a Home Assistant REST bridge. Emby Model Context Protocol server with a Home Assistant REST bridge.
Serves streamable MCP at /mcp and REST tool calls at /call/{tool}. Serves streamable MCP at /mcp and REST tool calls at /call/{tool}.
version: "1.0.15" version: "1.0.16"
slug: "emby_mcp" slug: "emby_mcp"
init: false init: false
startup: application startup: application
@@ -31,6 +31,8 @@ options:
mcp_listen_addr: "0.0.0.0:8085" mcp_listen_addr: "0.0.0.0:8085"
mcp_session_timeout: "30m" mcp_session_timeout: "30m"
log_level: INFO log_level: INFO
player_users: []
player_links: []
schema: schema:
emby_server_url: str emby_server_url: str
emby_username: str emby_username: str
@@ -44,6 +46,16 @@ schema:
mcp_listen_addr: str mcp_listen_addr: str
mcp_session_timeout: str mcp_session_timeout: str
log_level: list(DEBUG|INFO|WARN) log_level: list(DEBUG|INFO|WARN)
player_users:
- str
player_links:
- names: str
emby_device_id: str?
emby_device_name: str?
device_ip: str?
ha_entity: str
ha_source: str?
users: str?
debug: debug:
rest: bool? rest: bool?
mcp: bool? mcp: bool?
+23
View File
@@ -35,6 +35,19 @@ type Config struct {
DebugREST bool // DEBUG_REST DebugREST bool // DEBUG_REST
DebugMCP bool // DEBUG_MCP DebugMCP bool // DEBUG_MCP
DebugEmby bool // DEBUG_EMBY DebugEmby bool // DEBUG_EMBY
PlayerUsers []string // PLAYER_USERS include-list (names or ids)
PlayerLinks []PlayerLink // PLAYER_LINKS JSON mappings to HA entities
}
// PlayerLink maps spoken names / Emby device identity onto a Home Assistant media_player.
type PlayerLink struct {
Names []string `json:"names"`
EmbyDeviceID string `json:"emby_device_id"`
EmbyDeviceName string `json:"emby_device_name"`
DeviceIP string `json:"device_ip"`
HAEntity string `json:"ha_entity"`
HASource string `json:"ha_source"`
Users []string `json:"users"`
} }
// Load reads the .env file at path (if it exists), lets real environment // Load reads the .env file at path (if it exists), lets real environment
@@ -58,6 +71,7 @@ func Load(path string) (*Config, error) {
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT", "MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
"MCP_RESTRICT_LOCALHOST", "MCP_RESTRICT_LOCALHOST",
"LOG_LEVEL", "DEBUG_REST", "DEBUG_MCP", "DEBUG_EMBY", "LOG_LEVEL", "DEBUG_REST", "DEBUG_MCP", "DEBUG_EMBY",
"PLAYER_USERS", "PLAYER_LINKS",
} { } {
if v, ok := os.LookupEnv(k); ok && v != "" { if v, ok := os.LookupEnv(k); ok && v != "" {
vals[k] = v vals[k] = v
@@ -102,6 +116,15 @@ func Load(path string) (*Config, error) {
cfg.SessionTimeout = d cfg.SessionTimeout = d
} }
cfg.PlayerUsers = SplitCSV(vals["PLAYER_USERS"])
if s := strings.TrimSpace(vals["PLAYER_LINKS"]); s != "" {
links, err := ParsePlayerLinks(s)
if err != nil {
return nil, fmt.Errorf("invalid PLAYER_LINKS: %w", err)
}
cfg.PlayerLinks = links
}
if cfg.ServerURL == "" { if cfg.ServerURL == "" {
return nil, fmt.Errorf("missing required variable EMBY_SERVER_URL") return nil, fmt.Errorf("missing required variable EMBY_SERVER_URL")
} }
+23
View File
@@ -15,6 +15,7 @@ func clearEnv(t *testing.T) {
"EMBY_SERVER_URL", "EMBY_USERNAME", "EMBY_PASSWORD", "EMBY_SERVER_URL", "EMBY_USERNAME", "EMBY_PASSWORD",
"EMBY_API_KEY", "EMBY_USER_ID", "EMBY_VERIFY_SSL", "LLM_MAX_ITEMS", "EMBY_API_KEY", "EMBY_USER_ID", "EMBY_VERIFY_SSL", "LLM_MAX_ITEMS",
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT", "MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
"PLAYER_USERS", "PLAYER_LINKS",
} { } {
t.Setenv(k, "") t.Setenv(k, "")
} }
@@ -130,3 +131,25 @@ MCP_TRANSPORT=grpc`)
t.Fatal("expected error for invalid transport") t.Fatal("expected error for invalid transport")
} }
} }
func TestLoadPlayerPolicy(t *testing.T) {
clearEnv(t)
p := writeEnv(t, `EMBY_SERVER_URL="http://x"
EMBY_USERNAME=u
EMBY_PASSWORD=p
PLAYER_USERS=Gordon, Alice
PLAYER_LINKS=[{"names":"lounge,living room","ha_entity":"media_player.lounge","device_ip":"192.168.0.20","ha_source":"Emby"}]`)
cfg, err := Load(p)
if err != nil {
t.Fatal(err)
}
if len(cfg.PlayerUsers) != 2 || cfg.PlayerUsers[0] != "Gordon" {
t.Fatalf("users = %#v", cfg.PlayerUsers)
}
if len(cfg.PlayerLinks) != 1 || cfg.PlayerLinks[0].HAEntity != "media_player.lounge" {
t.Fatalf("links = %#v", cfg.PlayerLinks)
}
if len(cfg.PlayerLinks[0].Names) != 2 {
t.Fatalf("names = %#v", cfg.PlayerLinks[0].Names)
}
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"encoding/json"
"fmt"
"strings"
)
// SplitCSV splits a comma-separated list, trimming blanks.
func SplitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if strings.HasPrefix(s, "[") {
var arr []string
if err := json.Unmarshal([]byte(s), &arr); err == nil {
return compactStrings(arr)
}
}
parts := strings.Split(s, ",")
return compactStrings(parts)
}
func compactStrings(in []string) []string {
var out []string
for _, p := range in {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
type playerLinkWire struct {
Names flexStrings `json:"names"`
EmbyDeviceID string `json:"emby_device_id"`
EmbyDeviceName string `json:"emby_device_name"`
DeviceIP string `json:"device_ip"`
HAEntity string `json:"ha_entity"`
HASource string `json:"ha_source"`
Users flexStrings `json:"users"`
}
type flexStrings []string
func (f *flexStrings) UnmarshalJSON(b []byte) error {
b = bytesTrim(b)
if len(b) == 0 || string(b) == "null" {
return nil
}
if b[0] == '[' {
var a []string
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*f = compactStrings(a)
return nil
}
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
*f = SplitCSV(s)
return nil
}
func bytesTrim(b []byte) []byte {
return []byte(strings.TrimSpace(string(b)))
}
// ParsePlayerLinks accepts a JSON array of PlayerLink objects.
func ParsePlayerLinks(s string) ([]PlayerLink, error) {
s = strings.TrimSpace(s)
if s == "" || s == "null" || s == "[]" {
return nil, nil
}
var wires []playerLinkWire
if err := json.Unmarshal([]byte(s), &wires); err != nil {
return nil, fmt.Errorf("expected JSON array: %w", err)
}
out := make([]PlayerLink, 0, len(wires))
for _, w := range wires {
out = append(out, PlayerLink{
Names: []string(w.Names),
EmbyDeviceID: strings.TrimSpace(w.EmbyDeviceID),
EmbyDeviceName: strings.TrimSpace(w.EmbyDeviceName),
DeviceIP: strings.TrimSpace(w.DeviceIP),
HAEntity: strings.TrimSpace(w.HAEntity),
HASource: strings.TrimSpace(w.HASource),
Users: []string(w.Users),
})
}
return out, nil
}
+94
View File
@@ -0,0 +1,94 @@
package emby
import (
"context"
"encoding/json"
"net"
"strings"
)
// DeviceInfo is a subset of GET /Devices.
type DeviceInfo struct {
ID string `json:"Id"`
Name string `json:"Name"`
AppName string `json:"AppName"`
LastUserName string `json:"LastUserName"`
LastUserID string `json:"LastUserId"`
DateLastActivity string `json:"DateLastActivity"`
IPAddress string `json:"IpAddress"`
}
// GetDevices lists known Emby client devices (last user, last activity).
func (c *Client) GetDevices(ctx context.Context) ([]DeviceInfo, error) {
var raw json.RawMessage
if err := c.Get(ctx, "/Devices", nil, &raw); err != nil {
return nil, err
}
raw = json.RawMessage(strings.TrimSpace(string(raw)))
if len(raw) == 0 {
return nil, nil
}
if raw[0] == '[' {
var items []DeviceInfo
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
return items, nil
}
var wrap struct {
Items []DeviceInfo `json:"Items"`
}
if err := json.Unmarshal(raw, &wrap); err != nil {
return nil, err
}
return wrap.Items, nil
}
// MergeOfflineDevices appends Devices that have no live session, using last-used
// user as owner. Session rows win when DeviceID matches.
func MergeOfflineDevices(live []PlayerSession, devices []DeviceInfo) []PlayerSession {
seen := map[string]struct{}{}
for _, p := range live {
if p.DeviceID != "" {
seen[p.DeviceID] = struct{}{}
}
}
out := append([]PlayerSession(nil), live...)
for _, d := range devices {
id := d.ID
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
out = append(out, PlayerSession{
ClientName: d.AppName,
DeviceID: id,
DeviceName: d.Name,
DeviceIPAddress: HostOf(d.IPAddress),
UserID: d.LastUserID,
UserName: d.LastUserName,
Online: false,
})
}
return out
}
// HostOf returns the host part of an address (strips port). IPv6 brackets are removed.
func HostOf(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return ""
}
if strings.HasPrefix(addr, "[") {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return strings.Trim(addr, "[]")
}
if host, port, err := net.SplitHostPort(addr); err == nil && port != "" {
return host
}
return addr
}
+53
View File
@@ -0,0 +1,53 @@
package emby
import (
"context"
"encoding/json"
"net/http"
"testing"
)
func TestHostOf(t *testing.T) {
if HostOf("192.168.0.20:8096") != "192.168.0.20" {
t.Fatal(HostOf("192.168.0.20:8096"))
}
if HostOf("192.168.0.20") != "192.168.0.20" {
t.Fatal(HostOf("192.168.0.20"))
}
}
func TestGetDevicesWrapped(t *testing.T) {
c, srv := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/Devices" {
t.Errorf("path %s", r.URL.Path)
}
json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "d9", "Name": "Lounge", "AppName": "Emby for Android", "LastUserName": "Gordon", "LastUserId": "u1", "IpAddress": "192.168.0.20"},
},
})
})
defer srv.Close()
devs, err := c.GetDevices(context.Background())
if err != nil {
t.Fatal(err)
}
if len(devs) != 1 || devs[0].LastUserName != "Gordon" {
t.Fatalf("%+v", devs)
}
}
func TestMergeOfflineDevices(t *testing.T) {
live := []PlayerSession{{DeviceID: "d1", SessionID: "s1", Online: true, UserName: "Gordon"}}
devs := []DeviceInfo{
{ID: "d1", Name: "dup"},
{ID: "d2", Name: "Bedroom", LastUserName: "Alice", IPAddress: "192.168.0.21"},
}
got := MergeOfflineDevices(live, devs)
if len(got) != 2 {
t.Fatalf("%+v", got)
}
if got[1].Online || got[1].UserName != "Alice" {
t.Fatalf("%+v", got[1])
}
}
+56 -5
View File
@@ -11,12 +11,15 @@ import (
// now_playing_* fields are omitted when the player is idle. // now_playing_* fields are omitted when the player is idle.
type PlayerSession struct { type PlayerSession struct {
ClientName string `json:"client_name"` ClientName string `json:"client_name"`
SessionID string `json:"session_id"` SessionID string `json:"session_id,omitempty"`
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
DeviceIPAddress string `json:"device_ip_address"` DeviceIPAddress string `json:"device_ip_address,omitempty"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
Online bool `json:"online"`
LocalToMediaServer bool `json:"local_to_media_server"` LocalToMediaServer bool `json:"local_to_media_server"`
MediaTypes []string `json:"media_types"` MediaTypes []string `json:"media_types,omitempty"`
NowPlayingTitle string `json:"now_playing_title,omitempty"` NowPlayingTitle string `json:"now_playing_title,omitempty"`
NowPlayingArtists []string `json:"now_playing_artists,omitempty"` NowPlayingArtists []string `json:"now_playing_artists,omitempty"`
NowPlayingAlbum string `json:"now_playing_album,omitempty"` NowPlayingAlbum string `json:"now_playing_album,omitempty"`
@@ -62,9 +65,12 @@ func (c *Client) GetPlayerSessions(ctx context.Context, userID, mediaType string
SessionID: s.ID, SessionID: s.ID,
DeviceID: s.DeviceID, DeviceID: s.DeviceID,
DeviceName: s.DeviceName, DeviceName: s.DeviceName,
DeviceIPAddress: s.RemoteEndPoint, DeviceIPAddress: HostOf(s.RemoteEndPoint),
UserID: s.UserID,
UserName: s.UserName,
Online: true,
MediaTypes: s.PlayableMediaTypes, MediaTypes: s.PlayableMediaTypes,
LocalToMediaServer: s.RemoteEndPoint == "::1" || s.RemoteEndPoint == "127.0.0.1", LocalToMediaServer: isLoopback(s.RemoteEndPoint),
} }
if np := s.NowPlayingItem; np != nil { if np := s.NowPlayingItem; np != nil {
ps.NowPlayingTitle = np.Name ps.NowPlayingTitle = np.Name
@@ -85,6 +91,51 @@ func (c *Client) GetPlayerSessions(ctx context.Context, userID, mediaType string
return out, nil return out, nil
} }
// FilterPlayersByUsers keeps sessions whose UserName or UserId is in include.
// An empty include list means no extra filter.
func FilterPlayersByUsers(players []PlayerSession, include []string) []PlayerSession {
want := normalizeUserList(include)
if len(want) == 0 {
return players
}
out := make([]PlayerSession, 0, len(players))
for _, p := range players {
if userAllowed(p.UserID, p.UserName, want) {
out = append(out, p)
}
}
return out
}
func normalizeUserList(in []string) []string {
var out []string
for _, u := range in {
for _, part := range strings.Split(u, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, strings.ToLower(part))
}
}
}
return out
}
func userAllowed(userID, userName string, want []string) bool {
id := strings.ToLower(strings.TrimSpace(userID))
name := strings.ToLower(strings.TrimSpace(userName))
for _, w := range want {
if w == id || w == name {
return true
}
}
return false
}
func isLoopback(addr string) bool {
h := HostOf(addr)
return h == "127.0.0.1" || h == "::1" || h == "localhost"
}
// PlayQueueItem is the output shape for play queue entries. // PlayQueueItem is the output shape for play queue entries.
type PlayQueueItem struct { type PlayQueueItem struct {
Title string `json:"title"` Title string `json:"title"`
+18
View File
@@ -17,6 +17,7 @@ func TestGetPlayerSessions(t *testing.T) {
{ {
"Client": "Emby Web", "Id": "s1", "DeviceId": "d1", "Client": "Emby Web", "Id": "s1", "DeviceId": "d1",
"DeviceName": "Chrome", "RemoteEndPoint": "127.0.0.1", "DeviceName": "Chrome", "RemoteEndPoint": "127.0.0.1",
"UserId": "u1", "UserName": "Gordon",
"PlayableMediaTypes": []string{"Audio", "Video"}, "PlayableMediaTypes": []string{"Audio", "Video"},
"NowPlayingItem": map[string]any{ "NowPlayingItem": map[string]any{
"Name": "Track", "Id": "i1", "Artists": []string{"A"}, "Name": "Track", "Id": "i1", "Artists": []string{"A"},
@@ -48,6 +49,23 @@ func TestGetPlayerSessions(t *testing.T) {
if s.NowPlayingIsPaused == nil || !*s.NowPlayingIsPaused { if s.NowPlayingIsPaused == nil || !*s.NowPlayingIsPaused {
t.Error("expected is_paused") t.Error("expected is_paused")
} }
if s.UserName != "Gordon" || s.UserID != "u1" || !s.Online {
t.Errorf("owner = %+v", s)
}
}
func TestFilterPlayersByUsers(t *testing.T) {
in := []PlayerSession{
{SessionID: "a", UserName: "Gordon", UserID: "u1"},
{SessionID: "b", UserName: "Alice", UserID: "u2"},
}
got := FilterPlayersByUsers(in, []string{"gordon"})
if len(got) != 1 || got[0].SessionID != "a" {
t.Fatalf("got %+v", got)
}
if n := FilterPlayersByUsers(in, nil); len(n) != 2 {
t.Fatalf("unfiltered %+v", n)
}
} }
func TestGetPlayerSessionsMediaTypeFilter(t *testing.T) { func TestGetPlayerSessionsMediaTypeFilter(t *testing.T) {
+4 -1
View File
@@ -28,7 +28,10 @@ func NewHandler(cfg *config.Config, hostname string) http.Handler {
} }
// Per-session state: library selection and search chunking are // Per-session state: library selection and search chunking are
// isolated between concurrent HTTP clients. // isolated between concurrent HTTP clients.
return server.New(state.New(client, userID, cfg.MaxChunkSize)) st := state.New(client, userID, cfg.MaxChunkSize)
st.PlayerUsers = cfg.PlayerUsers
st.PlayerLinks = cfg.PlayerLinks
return server.New(st)
} }
mcpHandler := mcp.NewStreamableHTTPHandler(getServer, &mcp.StreamableHTTPOptions{ mcpHandler := mcp.NewStreamableHTTPHandler(getServer, &mcp.StreamableHTTPOptions{
SessionTimeout: cfg.SessionTimeout, SessionTimeout: cfg.SessionTimeout,
+159
View File
@@ -0,0 +1,159 @@
package server
import (
"strings"
"git.i3omb.com/gronod/emby-mcp/internal/config"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
)
// ResolvedPlayer is the output of resolve_media_player.
type ResolvedPlayer struct {
Query string `json:"query"`
MatchedName string `json:"matched_name,omitempty"`
HAEntity string `json:"ha_entity,omitempty"`
HASource string `json:"ha_source,omitempty"`
EmbyDeviceID string `json:"emby_device_id,omitempty"`
EmbyDeviceName string `json:"emby_device_name,omitempty"`
DeviceIP string `json:"device_ip,omitempty"`
SessionID string `json:"session_id,omitempty"`
Online bool `json:"online"`
UserName string `json:"user_name,omitempty"`
UserID string `json:"user_id,omitempty"`
WakeRequired bool `json:"wake_required"`
Note string `json:"note,omitempty"`
}
func resolvePlayer(query string, links []config.PlayerLink, players []emby.PlayerSession) (ResolvedPlayer, bool) {
q := strings.TrimSpace(query)
out := ResolvedPlayer{Query: q}
if q == "" {
out.Note = "ERROR: no query was supplied"
return out, false
}
ql := strings.ToLower(q)
if link, name := matchLink(ql, links); link != nil {
out.MatchedName = name
out.HAEntity = link.HAEntity
out.HASource = link.HASource
out.EmbyDeviceID = link.EmbyDeviceID
out.EmbyDeviceName = link.EmbyDeviceName
out.DeviceIP = emby.HostOf(link.DeviceIP)
if sess, ok := matchSession(link, players); ok {
fillFromSession(&out, sess)
} else {
out.WakeRequired = true
out.Note = "No live Emby session for this device. Turn on the Home Assistant media_player and launch the Emby client, then list or resolve again."
}
return out, true
}
for _, p := range players {
if sessionMatchesQuery(ql, p) {
out.MatchedName = firstNonEmpty(p.DeviceName, p.ClientName)
fillFromSession(&out, p)
if !p.Online {
out.WakeRequired = true
out.Note = "Device is known to Emby but has no live session. Turn on the Home Assistant media_player and launch the Emby client."
}
return out, true
}
}
out.Note = "ERROR: no player matched that name, device id, or IP"
return out, false
}
func matchLink(ql string, links []config.PlayerLink) (*config.PlayerLink, string) {
for i := range links {
l := &links[i]
for _, n := range l.Names {
if strings.ToLower(strings.TrimSpace(n)) == ql {
return l, n
}
}
if strings.ToLower(l.HAEntity) == ql {
return l, l.HAEntity
}
if l.EmbyDeviceID != "" && strings.ToLower(l.EmbyDeviceID) == ql {
return l, l.EmbyDeviceID
}
if l.EmbyDeviceName != "" && strings.ToLower(l.EmbyDeviceName) == ql {
return l, l.EmbyDeviceName
}
if ip := emby.HostOf(l.DeviceIP); ip != "" && strings.ToLower(ip) == ql {
return l, ip
}
}
return nil, ""
}
func matchSession(link *config.PlayerLink, players []emby.PlayerSession) (emby.PlayerSession, bool) {
linkIP := emby.HostOf(link.DeviceIP)
for _, p := range players {
if link.EmbyDeviceID != "" && p.DeviceID == link.EmbyDeviceID && p.Online {
return p, true
}
}
for _, p := range players {
if linkIP != "" && emby.HostOf(p.DeviceIPAddress) == linkIP && p.Online {
return p, true
}
if link.EmbyDeviceName != "" && strings.EqualFold(p.DeviceName, link.EmbyDeviceName) && p.Online {
return p, true
}
}
return emby.PlayerSession{}, false
}
func sessionMatchesQuery(ql string, p emby.PlayerSession) bool {
if strings.ToLower(p.DeviceID) == ql {
return true
}
if strings.ToLower(p.DeviceName) == ql {
return true
}
if strings.ToLower(p.ClientName) == ql {
return true
}
if ip := emby.HostOf(p.DeviceIPAddress); ip != "" && strings.ToLower(ip) == ql {
return true
}
if strings.ToLower(p.UserName) == ql {
return true
}
return false
}
func fillFromSession(out *ResolvedPlayer, p emby.PlayerSession) {
out.SessionID = p.SessionID
out.Online = p.Online
out.UserName = p.UserName
out.UserID = p.UserID
if out.EmbyDeviceID == "" {
out.EmbyDeviceID = p.DeviceID
}
if out.EmbyDeviceName == "" {
out.EmbyDeviceName = p.DeviceName
}
if out.DeviceIP == "" {
out.DeviceIP = emby.HostOf(p.DeviceIPAddress)
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func mergeUserFilters(toolUsers string, configured []string) []string {
fromTool := config.SplitCSV(toolUsers)
if len(fromTool) > 0 {
return fromTool
}
return configured
}
@@ -0,0 +1,41 @@
package server
import (
"testing"
"git.i3omb.com/gronod/emby-mcp/internal/config"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
)
func TestResolvePlayerByAliasAndIP(t *testing.T) {
links := []config.PlayerLink{{
Names: []string{"lounge", "living room"},
HAEntity: "media_player.lounge_tv",
HASource: "Emby",
DeviceIP: "192.168.0.20",
EmbyDeviceID: "dev-lounge",
}}
live := []emby.PlayerSession{{
SessionID: "sess-1",
DeviceID: "dev-lounge",
DeviceName: "Lounge Shield",
DeviceIPAddress: "192.168.0.20",
UserName: "Gordon",
Online: true,
}}
got, ok := resolvePlayer("lounge", links, live)
if !ok || got.SessionID != "sess-1" || got.HAEntity != "media_player.lounge_tv" || got.WakeRequired {
t.Fatalf("%+v ok=%v", got, ok)
}
offline, ok := resolvePlayer("living room", links, nil)
if !ok || !offline.WakeRequired || offline.SessionID != "" {
t.Fatalf("offline %+v ok=%v", offline, ok)
}
}
func TestResolvePlayerUnknown(t *testing.T) {
_, ok := resolvePlayer("attic", nil, nil)
if ok {
t.Fatal("expected miss")
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const ( const (
Name = "HA Emby MCP" Name = "HA Emby MCP"
Version = "1.0.15" Version = "1.0.16"
Purpose = `These MCP tools allow you to control an Emby media server. Using them you can retrieve Purpose = `These MCP tools allow you to control an Emby media server. Using them you can retrieve
a list of libraries, genres, playlists, audio & video items, and player sessions. a list of libraries, genres, playlists, audio & video items, and player sessions.
You can browse series, seasons and episodes, find the next episode to watch, You can browse series, seasons and episodes, find the next episode to watch,
+47 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"strings" "strings"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
"git.i3omb.com/gronod/emby-mcp/internal/state" "git.i3omb.com/gronod/emby-mcp/internal/state"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@@ -14,23 +15,68 @@ func registerPlayerTools(s *mcp.Server, st *state.State) {
Description: `Retrieve a list of media players that we can use with the supplied media type in JSON format. Description: `Retrieve a list of media players that we can use with the supplied media type in JSON format.
A human may use any JSON field to identify a player, but do not display the 'device_id' or 'session_id' A human may use any JSON field to identify a player, but do not display the 'device_id' or 'session_id'
You must only supply the 'session_id' field to identify the player when using the control_media_player or retrieve_player_queue tools. You must only supply the 'session_id' field to identify the player when using the control_media_player or retrieve_player_queue tools.
Each row includes user_name/user_id of the signed-in or last-used Emby user, device_ip_address when Emby reports it, and online=false for known devices with no live session.
When the user names a room or person, prefer resolve_media_player. Filter with users so other household accounts drop out.
Args: Args:
media_type (str, optional): List only players of this media type (one of: 'Audio', 'Video', 'Photo') or an empty string to list all players media_type (str, optional): List only players of this media type (one of: 'Audio', 'Video', 'Photo') or an empty string to list all players
users (str, optional): Comma-separated Emby usernames or user ids to include. Combined with the add-on player_users include-list when empty.
include_offline (bool, optional): When true, also list GET /Devices entries that have no live session (last-used user).
Returns: Returns:
List of dicts as JSON with keys including client_name, session_id, device_id, device_name, List of dicts as JSON with keys including client_name, session_id, device_id, device_name,
device_ip_address, local_to_media_server, media_types, and now_playing_* fields (times as hh:mm:ss).`, device_ip_address, user_id, user_name, online, local_to_media_server, media_types, and now_playing_* fields (times as hh:mm:ss).`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct { }, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
MediaType string `json:"media_type" jsonschema:"List only players of this media type (one of: 'Audio', 'Video', 'Photo') or empty for all"` MediaType string `json:"media_type" jsonschema:"List only players of this media type (one of: 'Audio', 'Video', 'Photo') or empty for all"`
Users string `json:"users" jsonschema:"Comma-separated Emby usernames or user ids to include"`
IncludeOffline bool `json:"include_offline" jsonschema:"Also list known devices with no live session"`
}) (*mcp.CallToolResult, any, error) { }) (*mcp.CallToolResult, any, error) {
sessions, err := st.Client.GetPlayerSessions(ctx, st.UserID, in.MediaType) sessions, err := st.Client.GetPlayerSessions(ctx, st.UserID, in.MediaType)
if err != nil { if err != nil {
return textResult(errf("ERROR: failed to retrieve player list because: %v", err)), nil, nil return textResult(errf("ERROR: failed to retrieve player list because: %v", err)), nil, nil
} }
if in.IncludeOffline {
devs, derr := st.Client.GetDevices(ctx)
if derr != nil {
return textResult(errf("ERROR: failed to retrieve device list because: %v", derr)), nil, nil
}
sessions = emby.MergeOfflineDevices(sessions, devs)
}
sessions = emby.FilterPlayersByUsers(sessions, mergeUserFilters(in.Users, st.PlayerUsers))
return jsonResult(sessions), nil, nil return jsonResult(sessions), nil, nil
}) })
mcp.AddTool(s, &mcp.Tool{
Name: "resolve_media_player",
Description: `Resolve a spoken room, person, HA entity, Emby device name, device id, or IP to a Home Assistant media_player and an Emby session when one is live.
Use this before PlayNow when the user says "play on the lounge" or similar. If wake_required is true, use Home Assistant to turn on ha_entity and select ha_source (Emby), wait, then resolve again. Do not call PlayNow until session_id is present.
Args:
query (str): Room alias, person, media_player entity_id, Emby device name, device id, or IP address
users (str, optional): Comma-separated include-list of Emby usernames or user ids
Returns:
JSON object with ha_entity, ha_source, emby_device_id, device_ip, session_id, online, user_name, wake_required.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
Query string `json:"query" jsonschema:"Room alias, HA entity, Emby device name, device id, or IP"`
Users string `json:"users" jsonschema:"Comma-separated Emby usernames or user ids to include"`
}) (*mcp.CallToolResult, any, error) {
sessions, err := st.Client.GetPlayerSessions(ctx, st.UserID, "")
if err != nil {
return textResult(errf("ERROR: failed to retrieve player list because: %v", err)), nil, nil
}
devs, derr := st.Client.GetDevices(ctx)
if derr == nil {
sessions = emby.MergeOfflineDevices(sessions, devs)
}
sessions = emby.FilterPlayersByUsers(sessions, mergeUserFilters(in.Users, st.PlayerUsers))
resolved, ok := resolvePlayer(in.Query, st.PlayerLinks, sessions)
if !ok {
return textResult(resolved.Note), nil, nil
}
return jsonResult(resolved), nil, nil
})
mcp.AddTool(s, &mcp.Tool{ mcp.AddTool(s, &mcp.Tool{
Name: "retrieve_player_queue", Name: "retrieve_player_queue",
Description: `Retrieve a list of items in the play queue of a media player in JSON format. Description: `Retrieve a list of items in the play queue of a media player in JSON format.
+3
View File
@@ -6,6 +6,7 @@ package state
import ( import (
"sync" "sync"
"git.i3omb.com/gronod/emby-mcp/internal/config"
"git.i3omb.com/gronod/emby-mcp/internal/emby" "git.i3omb.com/gronod/emby-mcp/internal/emby"
) )
@@ -25,6 +26,8 @@ type State struct {
Client *emby.Client Client *emby.Client
UserID string UserID string
MaxChunkSize int MaxChunkSize int
PlayerUsers []string
PlayerLinks []config.PlayerLink
libraries []emby.Library libraries []emby.Library
current *emby.Library current *emby.Library
+5
View File
@@ -29,4 +29,9 @@ else
export MCP_RESTRICT_LOCALHOST="false" export MCP_RESTRICT_LOCALHOST="false"
fi fi
if [ -f /data/options.json ]; then
export PLAYER_USERS="$(jq -c '.player_users // []' /data/options.json)"
export PLAYER_LINKS="$(jq -c '.player_links // []' /data/options.json)"
fi
exec /emby-mcp exec /emby-mcp