feat: emby-mcp 1.0.16 player owner filter and HA links #4
@@ -1,5 +1,27 @@
|
||||
# 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
|
||||
|
||||
- Restore `premiere_date` on item-shaped results (search, episode/season
|
||||
|
||||
+33
-3
@@ -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_session_timeout` | `30m` | MCP session lifetime |
|
||||
| `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.mcp` | _(optional)_ | Shown under optional options. At DEBUG, log `/mcp` 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:
|
||||
|
||||
```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
|
||||
@@ -146,19 +148,47 @@ Paste the following into the conversation agent's **Functions** list
|
||||
|
||||
- spec:
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
media_type:
|
||||
type: string
|
||||
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:
|
||||
type: rest
|
||||
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_player_list"
|
||||
method: POST
|
||||
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 }}"
|
||||
|
||||
- spec:
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ LABEL \
|
||||
io.hass.name="Emby MCP" \
|
||||
io.hass.description="Emby Model Context Protocol server with REST bridge" \
|
||||
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.source="https://git.i3omb.com/gronod/ha-gronod-addons"
|
||||
CMD [ "/run.sh" ]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.15
|
||||
1.0.16
|
||||
|
||||
@@ -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.")
|
||||
|
||||
st := state.New(client, userID, cfg.MaxChunkSize)
|
||||
st.PlayerUsers = cfg.PlayerUsers
|
||||
st.PlayerLinks = cfg.PlayerLinks
|
||||
srv := server.New(st)
|
||||
|
||||
runErr := srv.Run(ctx, &mcp.StdioTransport{})
|
||||
|
||||
+13
-1
@@ -2,7 +2,7 @@ name: "Emby MCP"
|
||||
description: >-
|
||||
Emby Model Context Protocol server with a Home Assistant REST bridge.
|
||||
Serves streamable MCP at /mcp and REST tool calls at /call/{tool}.
|
||||
version: "1.0.15"
|
||||
version: "1.0.16"
|
||||
slug: "emby_mcp"
|
||||
init: false
|
||||
startup: application
|
||||
@@ -31,6 +31,8 @@ options:
|
||||
mcp_listen_addr: "0.0.0.0:8085"
|
||||
mcp_session_timeout: "30m"
|
||||
log_level: INFO
|
||||
player_users: []
|
||||
player_links: []
|
||||
schema:
|
||||
emby_server_url: str
|
||||
emby_username: str
|
||||
@@ -44,6 +46,16 @@ schema:
|
||||
mcp_listen_addr: str
|
||||
mcp_session_timeout: str
|
||||
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:
|
||||
rest: bool?
|
||||
mcp: bool?
|
||||
|
||||
@@ -35,6 +35,19 @@ type Config struct {
|
||||
DebugREST bool // DEBUG_REST
|
||||
DebugMCP bool // DEBUG_MCP
|
||||
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
|
||||
@@ -58,6 +71,7 @@ func Load(path string) (*Config, error) {
|
||||
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
|
||||
"MCP_RESTRICT_LOCALHOST",
|
||||
"LOG_LEVEL", "DEBUG_REST", "DEBUG_MCP", "DEBUG_EMBY",
|
||||
"PLAYER_USERS", "PLAYER_LINKS",
|
||||
} {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
vals[k] = v
|
||||
@@ -102,6 +116,15 @@ func Load(path string) (*Config, error) {
|
||||
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 == "" {
|
||||
return nil, fmt.Errorf("missing required variable EMBY_SERVER_URL")
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func clearEnv(t *testing.T) {
|
||||
"EMBY_SERVER_URL", "EMBY_USERNAME", "EMBY_PASSWORD",
|
||||
"EMBY_API_KEY", "EMBY_USER_ID", "EMBY_VERIFY_SSL", "LLM_MAX_ITEMS",
|
||||
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
|
||||
"PLAYER_USERS", "PLAYER_LINKS",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
@@ -130,3 +131,25 @@ MCP_TRANSPORT=grpc`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,15 @@ import (
|
||||
// now_playing_* fields are omitted when the player is idle.
|
||||
type PlayerSession struct {
|
||||
ClientName string `json:"client_name"`
|
||||
SessionID string `json:"session_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
DeviceID string `json:"device_id"`
|
||||
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"`
|
||||
MediaTypes []string `json:"media_types"`
|
||||
MediaTypes []string `json:"media_types,omitempty"`
|
||||
NowPlayingTitle string `json:"now_playing_title,omitempty"`
|
||||
NowPlayingArtists []string `json:"now_playing_artists,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,
|
||||
DeviceID: s.DeviceID,
|
||||
DeviceName: s.DeviceName,
|
||||
DeviceIPAddress: s.RemoteEndPoint,
|
||||
DeviceIPAddress: HostOf(s.RemoteEndPoint),
|
||||
UserID: s.UserID,
|
||||
UserName: s.UserName,
|
||||
Online: true,
|
||||
MediaTypes: s.PlayableMediaTypes,
|
||||
LocalToMediaServer: s.RemoteEndPoint == "::1" || s.RemoteEndPoint == "127.0.0.1",
|
||||
LocalToMediaServer: isLoopback(s.RemoteEndPoint),
|
||||
}
|
||||
if np := s.NowPlayingItem; np != nil {
|
||||
ps.NowPlayingTitle = np.Name
|
||||
@@ -85,6 +91,51 @@ func (c *Client) GetPlayerSessions(ctx context.Context, userID, mediaType string
|
||||
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.
|
||||
type PlayQueueItem struct {
|
||||
Title string `json:"title"`
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestGetPlayerSessions(t *testing.T) {
|
||||
{
|
||||
"Client": "Emby Web", "Id": "s1", "DeviceId": "d1",
|
||||
"DeviceName": "Chrome", "RemoteEndPoint": "127.0.0.1",
|
||||
"UserId": "u1", "UserName": "Gordon",
|
||||
"PlayableMediaTypes": []string{"Audio", "Video"},
|
||||
"NowPlayingItem": map[string]any{
|
||||
"Name": "Track", "Id": "i1", "Artists": []string{"A"},
|
||||
@@ -48,6 +49,23 @@ func TestGetPlayerSessions(t *testing.T) {
|
||||
if s.NowPlayingIsPaused == nil || !*s.NowPlayingIsPaused {
|
||||
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) {
|
||||
|
||||
@@ -28,7 +28,10 @@ func NewHandler(cfg *config.Config, hostname string) http.Handler {
|
||||
}
|
||||
// Per-session state: library selection and search chunking are
|
||||
// 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{
|
||||
SessionTimeout: cfg.SessionTimeout,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
const (
|
||||
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
|
||||
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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.i3omb.com/gronod/emby-mcp/internal/emby"
|
||||
"git.i3omb.com/gronod/emby-mcp/internal/state"
|
||||
"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.
|
||||
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.
|
||||
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:
|
||||
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:
|
||||
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 {
|
||||
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) {
|
||||
sessions, err := st.Client.GetPlayerSessions(ctx, st.UserID, in.MediaType)
|
||||
if err != 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
|
||||
})
|
||||
|
||||
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{
|
||||
Name: "retrieve_player_queue",
|
||||
Description: `Retrieve a list of items in the play queue of a media player in JSON format.
|
||||
|
||||
@@ -6,6 +6,7 @@ package state
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"git.i3omb.com/gronod/emby-mcp/internal/config"
|
||||
"git.i3omb.com/gronod/emby-mcp/internal/emby"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,8 @@ type State struct {
|
||||
Client *emby.Client
|
||||
UserID string
|
||||
MaxChunkSize int
|
||||
PlayerUsers []string
|
||||
PlayerLinks []config.PlayerLink
|
||||
|
||||
libraries []emby.Library
|
||||
current *emby.Library
|
||||
|
||||
@@ -29,4 +29,9 @@ else
|
||||
export MCP_RESTRICT_LOCALHOST="false"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user