feat: emby-mcp 1.0.16 player owner filter and HA links #5
@@ -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
@@ -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
@@ -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
@@ -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.")
|
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
@@ -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?
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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"`
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user