diff --git a/emby-mcp/CHANGELOG.md b/emby-mcp/CHANGELOG.md index a07b06e..d503221 100644 --- a/emby-mcp/CHANGELOG.md +++ b/emby-mcp/CHANGELOG.md @@ -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 diff --git a/emby-mcp/DOCS.md b/emby-mcp/DOCS.md index 0ca1d39..8e689e4 100644 --- a/emby-mcp/DOCS.md +++ b/emby-mcp/DOCS.md @@ -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: diff --git a/emby-mcp/Dockerfile b/emby-mcp/Dockerfile index 4d7c586..21d2fd4 100644 --- a/emby-mcp/Dockerfile +++ b/emby-mcp/Dockerfile @@ -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" ] diff --git a/emby-mcp/VERSION b/emby-mcp/VERSION index a970716..b668c3b 100644 --- a/emby-mcp/VERSION +++ b/emby-mcp/VERSION @@ -1 +1 @@ -1.0.15 +1.0.16 diff --git a/emby-mcp/cmd/emby-mcp/main.go b/emby-mcp/cmd/emby-mcp/main.go index ca3ce42..d7ee198 100644 --- a/emby-mcp/cmd/emby-mcp/main.go +++ b/emby-mcp/cmd/emby-mcp/main.go @@ -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{}) diff --git a/emby-mcp/config.yaml b/emby-mcp/config.yaml index 7cced77..944fc9a 100644 --- a/emby-mcp/config.yaml +++ b/emby-mcp/config.yaml @@ -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? diff --git a/emby-mcp/internal/config/config.go b/emby-mcp/internal/config/config.go index a381382..a10e8d4 100644 --- a/emby-mcp/internal/config/config.go +++ b/emby-mcp/internal/config/config.go @@ -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") } diff --git a/emby-mcp/internal/config/config_test.go b/emby-mcp/internal/config/config_test.go index 23cdf72..dbd0b93 100644 --- a/emby-mcp/internal/config/config_test.go +++ b/emby-mcp/internal/config/config_test.go @@ -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) + } +} diff --git a/emby-mcp/internal/config/playerlinks.go b/emby-mcp/internal/config/playerlinks.go new file mode 100644 index 0000000..d3500fb --- /dev/null +++ b/emby-mcp/internal/config/playerlinks.go @@ -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 +} diff --git a/emby-mcp/internal/emby/devices.go b/emby-mcp/internal/emby/devices.go new file mode 100644 index 0000000..ef441a0 --- /dev/null +++ b/emby-mcp/internal/emby/devices.go @@ -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 +} diff --git a/emby-mcp/internal/emby/devices_test.go b/emby-mcp/internal/emby/devices_test.go new file mode 100644 index 0000000..74864c8 --- /dev/null +++ b/emby-mcp/internal/emby/devices_test.go @@ -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]) + } +} diff --git a/emby-mcp/internal/emby/sessions.go b/emby-mcp/internal/emby/sessions.go index 89c90a2..20e3061 100644 --- a/emby-mcp/internal/emby/sessions.go +++ b/emby-mcp/internal/emby/sessions.go @@ -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"` diff --git a/emby-mcp/internal/emby/sessions_test.go b/emby-mcp/internal/emby/sessions_test.go index 72c2fd8..98e8b5d 100644 --- a/emby-mcp/internal/emby/sessions_test.go +++ b/emby-mcp/internal/emby/sessions_test.go @@ -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) { diff --git a/emby-mcp/internal/mcphttp/handler.go b/emby-mcp/internal/mcphttp/handler.go index 6dcfb0e..5f07f74 100644 --- a/emby-mcp/internal/mcphttp/handler.go +++ b/emby-mcp/internal/mcphttp/handler.go @@ -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, diff --git a/emby-mcp/internal/server/player_resolve.go b/emby-mcp/internal/server/player_resolve.go new file mode 100644 index 0000000..5ee0453 --- /dev/null +++ b/emby-mcp/internal/server/player_resolve.go @@ -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 +} diff --git a/emby-mcp/internal/server/player_resolve_test.go b/emby-mcp/internal/server/player_resolve_test.go new file mode 100644 index 0000000..b9585a5 --- /dev/null +++ b/emby-mcp/internal/server/player_resolve_test.go @@ -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") + } +} diff --git a/emby-mcp/internal/server/server.go b/emby-mcp/internal/server/server.go index 90c0aa0..bd2e8a1 100644 --- a/emby-mcp/internal/server/server.go +++ b/emby-mcp/internal/server/server.go @@ -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, diff --git a/emby-mcp/internal/server/tools_players.go b/emby-mcp/internal/server/tools_players.go index 66b2014..efe1a26 100644 --- a/emby-mcp/internal/server/tools_players.go +++ b/emby-mcp/internal/server/tools_players.go @@ -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. diff --git a/emby-mcp/internal/state/state.go b/emby-mcp/internal/state/state.go index ec12523..cc3ba05 100644 --- a/emby-mcp/internal/state/state.go +++ b/emby-mcp/internal/state/state.go @@ -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 diff --git a/emby-mcp/rootfs/run.sh b/emby-mcp/rootfs/run.sh index 68e8456..5debe3b 100755 --- a/emby-mcp/rootfs/run.sh +++ b/emby-mcp/rootfs/run.sh @@ -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