Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bcf5322e1 | ||
|
|
e12292f316 | ||
|
|
97c0e4dc04 | ||
|
|
1a2a97babc | ||
|
|
c4095d0be7 | ||
|
|
58cf62a41f | ||
|
|
9e26d7fe9c |
@@ -8,6 +8,7 @@ Home Assistant custom add-on repository maintained by Gordon Bolton.
|
|||||||
|
|
||||||
| Add-on | Description | Documentation |
|
| Add-on | Description | Documentation |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| **Deebot N95 Local Control** | Local MQTT discovery and control for an already-provisioned Ecovacs Deebot N95 — replace its legacy cloud bootstrap and XMPP endpoint on a trusted LAN | [README](n95-mqtt-bridge/README.md) · [DOCS](n95-mqtt-bridge/DOCS.md) |
|
||||||
| **Emby MCP** | Emby Model Context Protocol server with a REST bridge — search and control an Emby media library from MCP clients and Home Assistant conversation agents | [README](emby-mcp/README.md) · [DOCS](emby-mcp/DOCS.md) |
|
| **Emby MCP** | Emby Model Context Protocol server with a REST bridge — search and control an Emby media library from MCP clients and Home Assistant conversation agents | [README](emby-mcp/README.md) · [DOCS](emby-mcp/DOCS.md) |
|
||||||
| **OpenAI Codex Proxy** | OpenAI-compatible local endpoint backed by a ChatGPT account (Codex OAuth) — use ChatGPT Plus/Pro models from OpenAI-compatible integrations | [README](openai-codex-proxy/README.md) · [DOCS](openai-codex-proxy/DOCS.md) |
|
| **OpenAI Codex Proxy** | OpenAI-compatible local endpoint backed by a ChatGPT account (Codex OAuth) — use ChatGPT Plus/Pro models from OpenAI-compatible integrations | [README](openai-codex-proxy/README.md) · [DOCS](openai-codex-proxy/DOCS.md) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,48 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
- Restore `premiere_date` on item-shaped results (search, episode/season
|
||||||
|
lists, next-episode, playlist items, play queue). Emby's `PremiereDate`
|
||||||
|
is the first air date for episodes/series and the release date for
|
||||||
|
movies; it is emitted as `YYYY-MM-DD` and omitted when metadata is empty
|
||||||
|
- Request `PremiereDate` again in upstream `Fields`
|
||||||
|
- Conversation-agent system prompt in DOCS.md now tells the model to read
|
||||||
|
`premiere_date` for airdate questions
|
||||||
|
|
||||||
|
### Potential breaking change
|
||||||
|
|
||||||
|
- Item JSON grows a `premiere_date` key versus the 1.0.11 slim shape.
|
||||||
|
Home Assistant function *parameters* are unchanged (`value_template`
|
||||||
|
still returns `value_json.result`). Re-paste the DOCS.md system prompt
|
||||||
|
so the agent looks for `premiere_date`; the Functions YAML block does
|
||||||
|
not need to be replaced unless you also want the updated prompt text
|
||||||
|
nearby. Clients that reject unknown item keys must allow
|
||||||
|
`premiere_date`.
|
||||||
|
|
||||||
|
|
||||||
## 1.0.14
|
## 1.0.14
|
||||||
|
|
||||||
- `control_media_player` PlayNow now honours `time_milliseconds` as a start
|
- `control_media_player` PlayNow now honours `time_milliseconds` as a start
|
||||||
|
|||||||
+38
-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: 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`). 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:
|
||||||
@@ -285,6 +315,11 @@ Paste the following into the conversation agent's **Functions** list
|
|||||||
If you changed the add-on port, replace `8085` in every `resource_template`.
|
If you changed the add-on port, replace `8085` in every `resource_template`.
|
||||||
If **Restrict to localhost** is off, send an `Authorization` header on each request.
|
If **Restrict to localhost** is off, send an `Authorization` header on each request.
|
||||||
|
|
||||||
|
Item-shaped tool results (search, episode list, next episode, queues) include
|
||||||
|
`premiere_date` as `YYYY-MM-DD` when Emby metadata has a first air / release
|
||||||
|
date. The Functions YAML above does not declare that field — it arrives inside
|
||||||
|
`value_json.result`. Update the system prompt so the model reads it.
|
||||||
|
|
||||||
## Standalone usage (outside Home Assistant)
|
## Standalone usage (outside Home Assistant)
|
||||||
|
|
||||||
The same Go binary can run outside the add-on, e.g. on a desktop or NAS.
|
The same Go binary can run outside the add-on, e.g. on a desktop or NAS.
|
||||||
|
|||||||
+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.14" \
|
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.14
|
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.14"
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ type MediaItem struct {
|
|||||||
DiskNumber any `json:"disk_number,omitempty"`
|
DiskNumber any `json:"disk_number,omitempty"`
|
||||||
TrackNumber any `json:"track_number,omitempty"`
|
TrackNumber any `json:"track_number,omitempty"`
|
||||||
ProductionYear any `json:"production_year,omitempty"`
|
ProductionYear any `json:"production_year,omitempty"`
|
||||||
|
PremiereDate string `json:"premiere_date,omitempty"`
|
||||||
Genres []string `json:"genres,omitempty"`
|
Genres []string `json:"genres,omitempty"`
|
||||||
RunTime string `json:"run_time,omitempty"`
|
RunTime string `json:"run_time,omitempty"`
|
||||||
Played bool `json:"played"`
|
Played bool `json:"played"`
|
||||||
@@ -52,7 +53,7 @@ type ItemQuery struct {
|
|||||||
EnableUserData bool // include per-user watch state
|
EnableUserData bool // include per-user watch state
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemExtraFields = "Genres,ProductionYear,ParentIndexNumber,IndexNumber,SeriesName"
|
const itemExtraFields = "Genres,ProductionYear,PremiereDate,ParentIndexNumber,IndexNumber,SeriesName"
|
||||||
|
|
||||||
// GetItems queries Audio/Video items in a library (or all libraries when
|
// GetItems queries Audio/Video items in a library (or all libraries when
|
||||||
// libraryID is empty).
|
// libraryID is empty).
|
||||||
@@ -172,9 +173,26 @@ func toMediaItem(it *BaseItemDto) MediaItem {
|
|||||||
mi.DiskNumber = intOrEmpty(it.ParentIndexNumber)
|
mi.DiskNumber = intOrEmpty(it.ParentIndexNumber)
|
||||||
mi.TrackNumber = intOrEmpty(it.IndexNumber)
|
mi.TrackNumber = intOrEmpty(it.IndexNumber)
|
||||||
mi.ProductionYear = intOrEmpty(it.ProductionYear)
|
mi.ProductionYear = intOrEmpty(it.ProductionYear)
|
||||||
|
mi.PremiereDate = formatISODate(it.PremiereDate)
|
||||||
return mi
|
return mi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatISODate keeps YYYY-MM-DD from an Emby date-time and drops empty values
|
||||||
|
// so premiere_date is omitted from JSON when metadata is missing.
|
||||||
|
func formatISODate(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(s, 'T'); i > 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
if len(s) >= 10 && s[4] == '-' && s[7] == '-' {
|
||||||
|
return s[:10]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// ticksToHMS converts Emby ticks (100 ns) to "hh:mm:ss".
|
// ticksToHMS converts Emby ticks (100 ns) to "hh:mm:ss".
|
||||||
func ticksToHMS(ticks int64) string {
|
func ticksToHMS(ticks int64) string {
|
||||||
if ticks <= 0 {
|
if ticks <= 0 {
|
||||||
|
|||||||
@@ -75,3 +75,79 @@ func TestGetItemsQueryTranslation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToMediaItemPremiereDate(t *testing.T) {
|
||||||
|
it := &BaseItemDto{
|
||||||
|
ID: "e1", Name: "Pilot", Type: "Episode",
|
||||||
|
PremiereDate: "2025-03-31T00:00:00.0000000Z",
|
||||||
|
}
|
||||||
|
year := 2025
|
||||||
|
it.ProductionYear = &year
|
||||||
|
mi := toMediaItem(it)
|
||||||
|
if mi.PremiereDate != "2025-03-31" {
|
||||||
|
t.Fatalf("premiere_date = %q", mi.PremiereDate)
|
||||||
|
}
|
||||||
|
if mi.ProductionYear != 2025 {
|
||||||
|
t.Fatalf("production_year = %v", mi.ProductionYear)
|
||||||
|
}
|
||||||
|
empty := toMediaItem(&BaseItemDto{ID: "e2", Name: "Unknown"})
|
||||||
|
if empty.PremiereDate != "" {
|
||||||
|
t.Fatalf("empty premiere_date = %q", empty.PremiereDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetItemsRequestsPremiereDateField(t *testing.T) {
|
||||||
|
var q map[string][]string
|
||||||
|
c, srv := newTestClient(t, itemsHandler(t, nil, &q))
|
||||||
|
defer srv.Close()
|
||||||
|
if _, err := c.GetItems(context.Background(), "u1", "", ItemQuery{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fields := ""
|
||||||
|
if got := q["Fields"]; len(got) == 1 {
|
||||||
|
fields = got[0]
|
||||||
|
}
|
||||||
|
if fields == "" || !containsCSV(fields, "PremiereDate") {
|
||||||
|
t.Fatalf("Fields = %q, want PremiereDate", fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatISODate(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "",
|
||||||
|
"2025-03-31T00:00:00.0000000Z": "2025-03-31",
|
||||||
|
"2025-03-31": "2025-03-31",
|
||||||
|
" 2024-12-01T15:04:05+01:00 ": "2024-12-01",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := formatISODate(in); got != want {
|
||||||
|
t.Errorf("formatISODate(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsCSV(csv, needle string) bool {
|
||||||
|
for _, p := range splitComma(csv) {
|
||||||
|
if p == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitComma(s string) []string {
|
||||||
|
var out []string
|
||||||
|
cur := ""
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == ',' {
|
||||||
|
out = append(out, cur)
|
||||||
|
cur = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur += string(s[i])
|
||||||
|
}
|
||||||
|
if cur != "" || len(s) > 0 && s[len(s)-1] == ',' {
|
||||||
|
out = append(out, cur)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type PlaylistItem struct {
|
|||||||
DiskNumber any `json:"disk_number,omitempty"`
|
DiskNumber any `json:"disk_number,omitempty"`
|
||||||
TrackNumber any `json:"track_number,omitempty"`
|
TrackNumber any `json:"track_number,omitempty"`
|
||||||
ProductionYear any `json:"production_year,omitempty"`
|
ProductionYear any `json:"production_year,omitempty"`
|
||||||
|
PremiereDate string `json:"premiere_date,omitempty"`
|
||||||
Genres []string `json:"genres,omitempty"`
|
Genres []string `json:"genres,omitempty"`
|
||||||
RunTime string `json:"run_time,omitempty"`
|
RunTime string `json:"run_time,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -129,6 +130,7 @@ func (c *Client) GetPlaylistItems(ctx context.Context, userID, playlistID string
|
|||||||
DiskNumber: mi.DiskNumber,
|
DiskNumber: mi.DiskNumber,
|
||||||
TrackNumber: mi.TrackNumber,
|
TrackNumber: mi.TrackNumber,
|
||||||
ProductionYear: mi.ProductionYear,
|
ProductionYear: mi.ProductionYear,
|
||||||
|
PremiereDate: mi.PremiereDate,
|
||||||
Genres: mi.Genres,
|
Genres: mi.Genres,
|
||||||
RunTime: mi.RunTime,
|
RunTime: mi.RunTime,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -98,6 +149,7 @@ type PlayQueueItem struct {
|
|||||||
DiskNumber any `json:"disk_number,omitempty"`
|
DiskNumber any `json:"disk_number,omitempty"`
|
||||||
TrackNumber any `json:"track_number,omitempty"`
|
TrackNumber any `json:"track_number,omitempty"`
|
||||||
ProductionYear any `json:"production_year,omitempty"`
|
ProductionYear any `json:"production_year,omitempty"`
|
||||||
|
PremiereDate string `json:"premiere_date,omitempty"`
|
||||||
Genres []string `json:"genres,omitempty"`
|
Genres []string `json:"genres,omitempty"`
|
||||||
RunTime string `json:"run_time,omitempty"`
|
RunTime string `json:"run_time,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -151,6 +203,7 @@ func (c *Client) GetPlayQueueItems(ctx context.Context, sessionID string) ([]Pla
|
|||||||
DiskNumber: mi.DiskNumber,
|
DiskNumber: mi.DiskNumber,
|
||||||
TrackNumber: mi.TrackNumber,
|
TrackNumber: mi.TrackNumber,
|
||||||
ProductionYear: mi.ProductionYear,
|
ProductionYear: mi.ProductionYear,
|
||||||
|
PremiereDate: mi.PremiereDate,
|
||||||
Genres: mi.Genres,
|
Genres: mi.Genres,
|
||||||
RunTime: mi.RunTime,
|
RunTime: mi.RunTime,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ var mediaItemRespFields = []string{
|
|||||||
"Items", "TotalRecordCount",
|
"Items", "TotalRecordCount",
|
||||||
"Items.Name", "Items.Artists", "Items.Album", "Items.AlbumId",
|
"Items.Name", "Items.Artists", "Items.Album", "Items.AlbumId",
|
||||||
"Items.AlbumArtist", "Items.ParentIndexNumber", "Items.IndexNumber",
|
"Items.AlbumArtist", "Items.ParentIndexNumber", "Items.IndexNumber",
|
||||||
"Items.ProductionYear",
|
"Items.ProductionYear", "Items.PremiereDate",
|
||||||
"Items.Genres", "Items.MediaType", "Items.RunTimeTicks",
|
"Items.Genres", "Items.MediaType", "Items.RunTimeTicks",
|
||||||
"Items.Id", "Items.Type",
|
"Items.Id", "Items.Type",
|
||||||
"Items.SeriesName", "Items.LocationType",
|
"Items.SeriesName", "Items.LocationType",
|
||||||
@@ -191,7 +191,7 @@ var endpointUsages = []endpointUsage{
|
|||||||
{tool: "stop_sharing_playlist", callSite: "playlists.go:243", method: "post", specPath: "/Items/{Id}/MakePrivate", path: []string{"Id"}},
|
{tool: "stop_sharing_playlist", callSite: "playlists.go:243", method: "post", specPath: "/Items/{Id}/MakePrivate", path: []string{"Id"}},
|
||||||
{tool: "share_playlist_user_access", callSite: "playlists.go:253", method: "post", specPath: "/Items/Access", bodyFields: []string{"ItemIds", "UserIds", "ItemAccess"}, enumUse: map[string][]string{"UserItemShareLevel": {"None", "Read", "Write", "Manage", "ManageDelete"}}},
|
{tool: "share_playlist_user_access", callSite: "playlists.go:253", method: "post", specPath: "/Items/Access", bodyFields: []string{"ItemIds", "UserIds", "ItemAccess"}, enumUse: map[string][]string{"UserItemShareLevel": {"None", "Read", "Write", "Manage", "ManageDelete"}}},
|
||||||
{tool: "retrieve_player_list", callSite: "sessions.go:39", method: "get", specPath: "/Sessions", query: []string{"ControllableByUserId"}, respFields: []string{"Client", "Id", "DeviceId", "DeviceName", "RemoteEndPoint", "PlayableMediaTypes", "NowPlayingItem.Name", "NowPlayingItem.Artists", "NowPlayingItem.Album", "NowPlayingItem.IndexNumber", "NowPlayingItem.ParentIndexNumber", "NowPlayingItem.Id", "NowPlayingItem.RunTimeTicks", "PlayState.PositionTicks", "PlayState.IsPaused"}},
|
{tool: "retrieve_player_list", callSite: "sessions.go:39", method: "get", specPath: "/Sessions", query: []string{"ControllableByUserId"}, respFields: []string{"Client", "Id", "DeviceId", "DeviceName", "RemoteEndPoint", "PlayableMediaTypes", "NowPlayingItem.Name", "NowPlayingItem.Artists", "NowPlayingItem.Album", "NowPlayingItem.IndexNumber", "NowPlayingItem.ParentIndexNumber", "NowPlayingItem.Id", "NowPlayingItem.RunTimeTicks", "PlayState.PositionTicks", "PlayState.IsPaused"}},
|
||||||
{tool: "retrieve_player_queue", callSite: "sessions.go:136", method: "get", specPath: "/Sessions/PlayQueue", query: []string{"Id", "Fields"}, respFields: []string{"Items", "Items.Name", "Items.Artists", "Items.Album", "Items.AlbumId", "Items.AlbumArtist", "Items.ParentIndexNumber", "Items.IndexNumber", "Items.ProductionYear", "Items.Genres", "Items.MediaType", "Items.RunTimeTicks", "Items.Id", "Items.PlaylistItemId", "TotalRecordCount"}},
|
{tool: "retrieve_player_queue", callSite: "sessions.go:136", method: "get", specPath: "/Sessions/PlayQueue", query: []string{"Id", "Fields"}, respFields: []string{"Items", "Items.Name", "Items.Artists", "Items.Album", "Items.AlbumId", "Items.AlbumArtist", "Items.ParentIndexNumber", "Items.IndexNumber", "Items.ProductionYear", "Items.PremiereDate", "Items.Genres", "Items.MediaType", "Items.RunTimeTicks", "Items.Id", "Items.PlaylistItemId", "TotalRecordCount"}},
|
||||||
{tool: "control_media_player (PlayNow)", callSite: "sessions.go:246", method: "post", specPath: "/Sessions/{Id}/Playing", path: []string{"Id"}, query: []string{"ItemIds", "PlayCommand"}, bodyFields: []string{"PlayCommand", "ControllingUserId"}, enumUse: map[string][]string{"PlayCommand": {"PlayNow"}}},
|
{tool: "control_media_player (PlayNow)", callSite: "sessions.go:246", method: "post", specPath: "/Sessions/{Id}/Playing", path: []string{"Id"}, query: []string{"ItemIds", "PlayCommand"}, bodyFields: []string{"PlayCommand", "ControllingUserId"}, enumUse: map[string][]string{"PlayCommand": {"PlayNow"}}},
|
||||||
{tool: "control_media_player (other commands)", callSite: "sessions.go:267", method: "post", specPath: "/Sessions/{Id}/Playing/{Command}", path: []string{"Id", "Command"}, bodyFields: []string{"Command", "SeekPositionTicks", "ControllingUserId"}, enumUse: map[string][]string{"PlaystateCommand": {"Stop", "Pause", "Unpause", "NextTrack", "PreviousTrack", "Seek", "Rewind", "FastForward", "PlayPause", "SeekRelative"}}},
|
{tool: "control_media_player (other commands)", callSite: "sessions.go:267", method: "post", specPath: "/Sessions/{Id}/Playing/{Command}", path: []string{"Id", "Command"}, bodyFields: []string{"Command", "SeekPositionTicks", "ControllingUserId"}, enumUse: map[string][]string{"PlaystateCommand": {"Stop", "Pause", "Unpause", "NextTrack", "PreviousTrack", "Seek", "Rewind", "FastForward", "PlayPause", "SeekRelative"}}},
|
||||||
{tool: "retrieve_now_playing, set_subtitle, set_audio_track", callSite: "sessions.go:169", method: "get", specPath: "/Sessions", respFields: []string{"Id", "SupportedCommands", "NowPlayingItem.Id", "NowPlayingItem.Name", "NowPlayingItem.SeriesName", "NowPlayingItem.MediaType", "NowPlayingItem.MediaSources.MediaStreams.Index", "NowPlayingItem.MediaSources.MediaStreams.Type", "NowPlayingItem.MediaSources.MediaStreams.Language", "NowPlayingItem.MediaSources.MediaStreams.Codec", "NowPlayingItem.MediaSources.MediaStreams.Title", "NowPlayingItem.MediaSources.MediaStreams.DisplayTitle", "NowPlayingItem.MediaSources.MediaStreams.IsDefault", "NowPlayingItem.MediaSources.MediaStreams.IsForced", "NowPlayingItem.MediaSources.MediaStreams.IsExternal", "PlayState.PositionTicks", "PlayState.IsPaused", "PlayState.CanSeek", "PlayState.AudioStreamIndex", "PlayState.SubtitleStreamIndex"}},
|
{tool: "retrieve_now_playing, set_subtitle, set_audio_track", callSite: "sessions.go:169", method: "get", specPath: "/Sessions", respFields: []string{"Id", "SupportedCommands", "NowPlayingItem.Id", "NowPlayingItem.Name", "NowPlayingItem.SeriesName", "NowPlayingItem.MediaType", "NowPlayingItem.MediaSources.MediaStreams.Index", "NowPlayingItem.MediaSources.MediaStreams.Type", "NowPlayingItem.MediaSources.MediaStreams.Language", "NowPlayingItem.MediaSources.MediaStreams.Codec", "NowPlayingItem.MediaSources.MediaStreams.Title", "NowPlayingItem.MediaSources.MediaStreams.DisplayTitle", "NowPlayingItem.MediaSources.MediaStreams.IsDefault", "NowPlayingItem.MediaSources.MediaStreams.IsForced", "NowPlayingItem.MediaSources.MediaStreams.IsExternal", "PlayState.PositionTicks", "PlayState.IsPaused", "PlayState.CanSeek", "PlayState.AudioStreamIndex", "PlayState.SubtitleStreamIndex"}},
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ type BaseItemDto struct {
|
|||||||
ParentIndexNumber *int `json:"ParentIndexNumber"`
|
ParentIndexNumber *int `json:"ParentIndexNumber"`
|
||||||
IndexNumber *int `json:"IndexNumber"`
|
IndexNumber *int `json:"IndexNumber"`
|
||||||
ProductionYear *int `json:"ProductionYear"`
|
ProductionYear *int `json:"ProductionYear"`
|
||||||
|
PremiereDate string `json:"PremiereDate"`
|
||||||
Genres []string `json:"Genres"`
|
Genres []string `json:"Genres"`
|
||||||
MediaSources []MediaSource `json:"MediaSources"`
|
MediaSources []MediaSource `json:"MediaSources"`
|
||||||
MediaType string `json:"MediaType"`
|
MediaType string `json:"MediaType"`
|
||||||
|
|||||||
@@ -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.14"
|
Version = "1.0.16"
|
||||||
Purpose = `These MCP tools allow you to control an Emby media server. Using them you can retrieve
|
Purpose = `These MCP tools allow you to control an Emby media server. Using them you can retrieve
|
||||||
a list of libraries, genres, playlists, audio & video items, and player sessions.
|
a list of libraries, genres, playlists, audio & video items, and player sessions.
|
||||||
You can browse series, seasons and episodes, find the next episode to watch,
|
You can browse series, seasons and episodes, find the next episode to watch,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Returns:
|
|||||||
disk_number (int): the disk or series number of the item.
|
disk_number (int): the disk or series number of the item.
|
||||||
track_number (int): the track or episode number of the item.
|
track_number (int): the track or episode number of the item.
|
||||||
production_year (int): the release / broadcast year of the item.
|
production_year (int): the release / broadcast year of the item.
|
||||||
|
premiere_date (str): first air date for episodes/series, or release date for movies, as YYYY-MM-DD when metadata has it.
|
||||||
genres (list of str): the genres tagged to the item
|
genres (list of str): the genres tagged to the item
|
||||||
run_time (str): the run time / play length of the item as hh:mm:ss.
|
run_time (str): the run time / play length of the item as hh:mm:ss.
|
||||||
played (bool): whether the item has been fully played.
|
played (bool): whether the item has been fully played.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.2
|
||||||
|
|
||||||
|
- Build upstream `ha-n95-local-control` **v0.1.2**
|
||||||
|
(`7bed99cc4c3222bad648efcddcdfed95652277f7`).
|
||||||
|
- Health bind failure is no longer fatal. If 8080 is taken (OpenThread
|
||||||
|
Border Router and other add-ons), lookup 8007, firmware 8005, and
|
||||||
|
XMPP 5223 stay up. Set optional `health_port` if you still want
|
||||||
|
`/healthz`.
|
||||||
|
- Upstream logs each successful listen address.
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
- Declare TCP 8007, 8005, 5223, and 8080 in the add-on `ports` map so
|
||||||
|
Home Assistant OS Supervisor opens those ports on the host firewall.
|
||||||
|
Host networking is unchanged; Docker does not remap the ports.
|
||||||
|
- Potential operational change: after update, the add-on Network tab
|
||||||
|
lists the four listeners. Rebuild/reinstall so HA picks up 0.1.1.
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
- First Home Assistant add-on release
|
||||||
|
- Build the upstream Deebot N95 bridge from the pinned `v0.1.0` tag
|
||||||
|
(`75354b35180f4cc5e92187b6149322b0b94533ba`)
|
||||||
|
- Bind robot-facing lookup, firmware, and XMPP listeners on the Home Assistant
|
||||||
|
host network
|
||||||
|
- Discover Supervisor MQTT connection details automatically with per-field
|
||||||
|
configuration overrides and external-broker support
|
||||||
|
- Expose the complete bridge configuration, with advanced and risky settings
|
||||||
|
hidden under optional configuration by default
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Deebot N95 Local Control — documentation
|
||||||
|
|
||||||
|
Full installation, network, configuration, verification, and troubleshooting
|
||||||
|
information for the **Deebot N95 Local Control** Home Assistant add-on.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The add-on replaces the legacy Ecovacs bootstrap and XMPP destinations used by
|
||||||
|
an already-provisioned Deebot N95, then exposes the robot through Home
|
||||||
|
Assistant MQTT discovery.
|
||||||
|
|
||||||
|
```text
|
||||||
|
N95 ── DNS ──> your LAN resolver
|
||||||
|
N95 ── HTTP 8007 / 8005, XMPP 5223 ──> this add-on ──> MQTT broker ──> Home Assistant
|
||||||
|
```
|
||||||
|
|
||||||
|
The add-on does not provision a robot and does not run a DNS server. It supports
|
||||||
|
multiple robots; each robot receives its own MQTT client and topic tree after
|
||||||
|
it reaches XMPP READY state and reveals its serial number.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
You need:
|
||||||
|
|
||||||
|
* An already-provisioned Deebot N95 using the captured `wukong` / class `155`
|
||||||
|
protocol. Other Ecovacs models are not established as compatible.
|
||||||
|
* A stable LAN IPv4 address for the Home Assistant host, reserved in DHCP.
|
||||||
|
* Control of the DNS resolver supplied to the robot by DHCP.
|
||||||
|
* TCP `8007`, `8005`, and `5223` available on the Home Assistant host and
|
||||||
|
reachable from the robot. TCP `8080` is used for health checks.
|
||||||
|
* A broker reachable from the Home Assistant host and Home Assistant MQTT
|
||||||
|
configured with discovery enabled.
|
||||||
|
* A correct host clock and IANA timezone.
|
||||||
|
|
||||||
|
The robot caches its XMPP address after boot. If the address or listener ports
|
||||||
|
change, update DNS/configuration and reboot the robot; an ordinary reconnect
|
||||||
|
continues to use the cached destination.
|
||||||
|
|
||||||
|
## Step 1 — reserve the host address and configure DNS
|
||||||
|
|
||||||
|
Reserve the Home Assistant host's LAN IPv4 address. Configure the resolver sent
|
||||||
|
to the robot by DHCP with this A record:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lbo.ecouser.net A <HOME_ASSISTANT_LAN_IPV4>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the add-on's `advertise_ip` to the same literal IPv4 address. Do not use
|
||||||
|
`0.0.0.0`, a container address, or a hostname. The `155.ecorobot.net` XMPP JID
|
||||||
|
domain does not need a DNS override for the captured firmware.
|
||||||
|
|
||||||
|
## Step 2 — install and configure
|
||||||
|
|
||||||
|
1. Add this repository in Home Assistant
|
||||||
|
(**Settings → Add-ons → Add-on Store → ⋮ → Repositories**):
|
||||||
|
`https://git.i3omb.com/gronod/ha-gronod-addons`
|
||||||
|
2. Install **Deebot N95 Local Control**.
|
||||||
|
3. Enter the reserved host IPv4 under **Advertised IP address**.
|
||||||
|
4. Select the correct IANA timezone, for example `Europe/London`.
|
||||||
|
5. Leave advanced listener values unset unless their default ports conflict.
|
||||||
|
|
||||||
|
When a Supervisor MQTT service is available, the add-on automatically reads
|
||||||
|
its host, port, TLS flag, username, and password. Every manually supplied MQTT
|
||||||
|
option overrides only that individual discovered field. This permits, for
|
||||||
|
example, using discovered credentials with a manually supplied private CA.
|
||||||
|
|
||||||
|
For an external broker with no Supervisor service, set at least `mqtt_host` and
|
||||||
|
any required credentials/TLS options.
|
||||||
|
|
||||||
|
## Step 3 — start and redirect the robot
|
||||||
|
|
||||||
|
Start the add-on and inspect its log. Verify the DNS and HTTP endpoints using
|
||||||
|
the checks below, then reboot the robot. The robot should bootstrap through the
|
||||||
|
lookup endpoint, open XMPP to the add-on, and appear as an MQTT vacuum in Home
|
||||||
|
Assistant.
|
||||||
|
|
||||||
|
No MQTT connection is expected when the add-on first starts. The bridge creates
|
||||||
|
a broker client only after a robot reaches XMPP READY and identifies itself.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Normal setup shows `advertise_ip`, `timezone`, and `log_level`. Select **Show
|
||||||
|
unused optional configuration options** to reveal MQTT overrides and advanced
|
||||||
|
network/protocol controls.
|
||||||
|
|
||||||
|
| Option | Default | Environment | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `advertise_ip` | required | `ADVERTISE_IP` | Stable, nonzero host IPv4 returned in lookup responses |
|
||||||
|
| `timezone` | `Etc/UTC` | `TZ` | IANA timezone controlling the local offset sent to the robot |
|
||||||
|
| `log_level` | `info` | `LOG_LEVEL` | `debug`, `info`, `warn`, or `error` |
|
||||||
|
| `bind_address` | `0.0.0.0` | `BIND_ADDRESS` | Advanced listener bind IP; normally leave unset |
|
||||||
|
| `port_lookup` | `8007` | `PORT_LOOKUP` | Advanced HTTP `POST /lookup.do` listener |
|
||||||
|
| `port_firmware` | `8005` | `PORT_FIRMWARE` | Advanced firmware-check listener; expected to return 404 |
|
||||||
|
| `port_xmpp` | `5223` | `PORT_XMPP` | Advanced plaintext robot XMPP listener |
|
||||||
|
| `health_port` | `8080` | `HEALTH_PORT` | Advanced HTTP `GET /healthz` listener. Bind failure is non-fatal from v0.1.2; robot ports stay up. Pick another port if OTBR already owns 8080. |
|
||||||
|
| `mqtt_host` | Supervisor/required | `MQTT_HOST` | Broker host without scheme or port; overrides discovery |
|
||||||
|
| `mqtt_port` | `1883`/`8883` | `MQTT_PORT` | Broker port; upstream chooses 8883 when TLS is enabled |
|
||||||
|
| `mqtt_tls` | `false` | `MQTT_TLS` | Start the MQTT connection with TLS and hostname verification |
|
||||||
|
| `mqtt_ca_file` | unset | `MQTT_CA_FILE` | Readable PEM CA bundle, normally `/ssl/<file>.pem` |
|
||||||
|
| `mqtt_username` | Supervisor/unset | `MQTT_USERNAME` | Broker username override |
|
||||||
|
| `mqtt_password` | Supervisor/unset | `MQTT_PASSWORD` | Broker password override; nonempty requires username |
|
||||||
|
| `mqtt_client_id` | `n95bridge-<hostname>` | `MQTT_CLIENT_ID` | Client ID prefix; `[A-Za-z0-9_-]{1,64}` |
|
||||||
|
| `mqtt_base` | `ecovacs` | `MQTT_BASE` | One MQTT topic level before `/<serial>` |
|
||||||
|
| `ha_discovery_prefix` | `homeassistant` | `HA_DISCOVERY_PREFIX` | Must match Home Assistant MQTT discovery prefix |
|
||||||
|
| `controller_jid` | `n95bridge@ecouser.net/homeassistant` | `CONTROLLER_JID` | Advanced virtual controller JID in `local@domain/resource` form |
|
||||||
|
| `raw_commands` | `false` | `RAW_COMMANDS` | Risky protocol-level raw command input; keep disabled normally |
|
||||||
|
|
||||||
|
Optional values are exported only when configured, preserving upstream
|
||||||
|
defaults. Boolean values are passed as lowercase `true` or `false`. All four
|
||||||
|
listener ports must be distinct and in the range 1–65535.
|
||||||
|
|
||||||
|
## Host networking and ports
|
||||||
|
|
||||||
|
The add-on uses host networking because the robot must connect to the exact
|
||||||
|
address and ports returned by `/lookup.do`. The same ports are declared in
|
||||||
|
`config.yaml` so Supervisor opens them on the Home Assistant OS host
|
||||||
|
firewall. Without that map, the process can listen on the host while LAN
|
||||||
|
clients (including the robot) time out.
|
||||||
|
|
||||||
|
| Port | Purpose | Robot access |
|
||||||
|
|---|---|---|
|
||||||
|
| `8007/tcp` | Bootstrap lookup (`EcoMsgNew`, `EcoUpdate`) | required |
|
||||||
|
| `8005/tcp` | Firmware check (expected 404 response) | required |
|
||||||
|
| `5223/tcp` | Plaintext XMPP | required |
|
||||||
|
| `8080/tcp` | Health endpoint | not required |
|
||||||
|
|
||||||
|
Changing a robot-facing port changes both the listener and the value advertised
|
||||||
|
to the robot. Also change the matching `ports:` entry so Supervisor still
|
||||||
|
opens the host firewall for that port. Check that another host service does
|
||||||
|
not already occupy the port. Host networking means Docker does not remap
|
||||||
|
these ports; the `ports` map is for Supervisor visibility and firewall
|
||||||
|
allowance only.
|
||||||
|
|
||||||
|
## MQTT and Home Assistant discovery
|
||||||
|
|
||||||
|
Supervisor MQTT values are loaded first; explicitly configured options then
|
||||||
|
replace individual fields. The final broker host must come from one of those
|
||||||
|
sources or the add-on exits with an actionable error.
|
||||||
|
|
||||||
|
For TLS with a private CA, place a readable PEM bundle in Home Assistant's
|
||||||
|
`ssl` directory and set `mqtt_ca_file` to its in-container path, such as
|
||||||
|
`/ssl/broker-ca.pem`. TLS hostname verification remains enabled, so
|
||||||
|
`mqtt_host` must match the broker certificate.
|
||||||
|
|
||||||
|
The bridge publishes discovery below
|
||||||
|
`<ha_discovery_prefix>/vacuum/ecovacs_<serial>/config` and robot data below
|
||||||
|
`<mqtt_base>/<serial>/...`. Broker ACLs must allow each generated client to
|
||||||
|
publish/subscribe under those trees. The default client prefix is based on the
|
||||||
|
add-on hostname and has the robot serial appended.
|
||||||
|
|
||||||
|
## Robot features and entities
|
||||||
|
|
||||||
|
The tagged upstream release publishes a native MQTT vacuum with availability,
|
||||||
|
state, battery, fan speed, error information, and start, stop, dock, spot, and
|
||||||
|
locate commands. It also supports extension movement commands, cleaning modes,
|
||||||
|
schedule CRUD, consumable lifespan state, and diagnostic topics. Pause, resume,
|
||||||
|
mapping, and segment cleaning are not advertised. Multiple connected robots remain isolated by
|
||||||
|
serial number.
|
||||||
|
|
||||||
|
`raw_commands` permits protocol-level `<ctl>` input intended for controlled
|
||||||
|
experimentation. It bypasses normal high-level command constraints and should
|
||||||
|
remain disabled for ordinary use.
|
||||||
|
|
||||||
|
## Check the installation
|
||||||
|
|
||||||
|
Replace the example addresses with your resolver and Home Assistant host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nslookup lbo.ecouser.net 192.0.2.53
|
||||||
|
curl -sS -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data '{"todo":"FindBest","service":"EcoMsgNew"}' \
|
||||||
|
http://192.0.2.10:8007/lookup.do
|
||||||
|
curl -sS -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data '{"todo":"FindBest","service":"EcoUpdate"}' \
|
||||||
|
http://192.0.2.10:8007/lookup.do
|
||||||
|
curl -i http://192.0.2.10:8005/products/wukong/class/155/firmware/latest.json
|
||||||
|
curl -sS http://192.0.2.10:8080/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
The lookup responses must contain the configured advertised IPv4 and numeric
|
||||||
|
ports. The firmware request should return HTTP 404. The health endpoint should
|
||||||
|
report healthy listeners. After these pass, reboot the robot and check:
|
||||||
|
|
||||||
|
1. the add-on log for XMPP READY and the robot serial;
|
||||||
|
2. broker activity below `ecovacs/<serial>` and the discovery prefix;
|
||||||
|
3. a newly discovered MQTT vacuum entity in Home Assistant;
|
||||||
|
4. state refresh and a harmless command such as locating the robot.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
* Robot XMPP, including SASL PLAIN credentials, is plaintext. Keep ports 8005,
|
||||||
|
8007, and 5223 restricted to the trusted robot LAN.
|
||||||
|
* MQTT authentication and TLS protect only the broker connection; they do not
|
||||||
|
encrypt robot traffic.
|
||||||
|
* Protect broker passwords and private CA files. The wrapper and upstream
|
||||||
|
configuration log do not print the MQTT password.
|
||||||
|
* Do not expose the robot-facing listeners to the internet.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**The add-on exits with “No MQTT broker is available”**
|
||||||
|
No Supervisor MQTT service was found and `mqtt_host` is unset. Install/configure
|
||||||
|
a broker service or enter the external broker host.
|
||||||
|
|
||||||
|
**The robot still contacts the cloud**
|
||||||
|
Query the exact resolver supplied by DHCP and confirm `lbo.ecouser.net` returns
|
||||||
|
`advertise_ip`. Remove cached/secondary public DNS paths, then reboot the robot.
|
||||||
|
|
||||||
|
**LAN clients time out on 8007 even though the add-on log shows listeners**
|
||||||
|
On Home Assistant OS, Supervisor only opens inbound host ports listed under
|
||||||
|
`ports:` in `config.yaml`. Version 0.1.1 declares 8007, 8005, 5223, and 8080.
|
||||||
|
Rebuild/update the add-on so that version is installed, then confirm the
|
||||||
|
Network tab lists those ports. Loopback on the HAOS box can succeed while
|
||||||
|
the robot still times out if the firewall hole is missing.
|
||||||
|
|
||||||
|
**8007/8005/5223 never appear on the host while 8080 is owned by OTBR**
|
||||||
|
Before v0.1.2 a busy health port stopped every listener. From v0.1.2 the
|
||||||
|
process logs `health listener failed; robot listeners continue` and keeps
|
||||||
|
8007/8005/5223. Set optional `health_port` to a free port if you want
|
||||||
|
`/healthz`. Rebuild so the add-on is 0.1.2.
|
||||||
|
|
||||||
|
**The add-on reports an address-already-in-use error**
|
||||||
|
Another host service owns lookup, firmware, or XMPP. Stop that service or
|
||||||
|
set a distinct optional port. Reboot the robot after changing advertised ports.
|
||||||
|
|
||||||
|
**The robot does not reconnect after an address or port change**
|
||||||
|
The N95 caches XMPP details for its powered-on lifetime. Power-cycle/reboot it
|
||||||
|
to force a new bootstrap lookup.
|
||||||
|
|
||||||
|
**There is no MQTT connection immediately after startup**
|
||||||
|
This is expected until a robot reaches XMPP READY and provides its serial.
|
||||||
|
Investigate DNS, listener reachability, and XMPP logs first.
|
||||||
|
|
||||||
|
**The robot connects but no entity appears**
|
||||||
|
Confirm Home Assistant MQTT discovery is enabled and
|
||||||
|
`ha_discovery_prefix` matches its configured prefix. Check broker ACLs for both
|
||||||
|
the discovery and robot topic trees.
|
||||||
|
|
||||||
|
**MQTT authentication or TLS fails**
|
||||||
|
Check the effective host, port, username, and TLS override combination. For a
|
||||||
|
private CA, verify the `/ssl/...` path exists and is readable, and that the
|
||||||
|
broker certificate matches `mqtt_host`.
|
||||||
|
|
||||||
|
**Schedules run at the wrong time**
|
||||||
|
Set `timezone` to the correct IANA name and restart the add-on, then reconnect
|
||||||
|
the robot so it receives the updated time and UTC offset.
|
||||||
|
|
||||||
|
## Upstream and standalone usage
|
||||||
|
|
||||||
|
The add-on builds the immutable
|
||||||
|
[ha-n95-local-control v0.1.2 release](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2)
|
||||||
|
at commit `7bed99cc4c3222bad648efcddcdfed95652277f7`. See the
|
||||||
|
[tagged upstream README](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2/README.md)
|
||||||
|
for Docker Compose and direct Go-binary operation outside Home Assistant. The
|
||||||
|
upstream project is under the
|
||||||
|
[Apache License 2.0](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2/LICENCE.md).
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
ARG GO_VERSION=1.27.1
|
||||||
|
ARG BUILD_FROM=ghcr.io/home-assistant/base:3.22
|
||||||
|
FROM golang:${GO_VERSION}-alpine AS build
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates git
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
ARG UPSTREAM_REPOSITORY=https://git.i3omb.com/gronod/ha-n95-local-control.git
|
||||||
|
ARG UPSTREAM_REF=v0.1.2
|
||||||
|
ARG UPSTREAM_COMMIT=7bed99cc4c3222bad648efcddcdfed95652277f7
|
||||||
|
RUN git init . \
|
||||||
|
&& git remote add origin "${UPSTREAM_REPOSITORY}" \
|
||||||
|
&& git fetch --depth 1 origin "refs/tags/${UPSTREAM_REF}:refs/tags/${UPSTREAM_REF}" \
|
||||||
|
&& test "$(git rev-list -n 1 "${UPSTREAM_REF}^{commit}")" = "${UPSTREAM_COMMIT}" \
|
||||||
|
&& git checkout --detach "${UPSTREAM_REF}^{commit}"
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
ARG TARGETOS=linux
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
|
RUN case "${TARGETARCH}/${TARGETVARIANT}" in \
|
||||||
|
amd64/) goarch=amd64; goarm= ;; \
|
||||||
|
arm64/) goarch=arm64; goarm= ;; \
|
||||||
|
386/) goarch=386; goarm= ;; \
|
||||||
|
arm/v6) goarch=arm; goarm=6 ;; \
|
||||||
|
arm/v7) goarch=arm; goarm=7 ;; \
|
||||||
|
*) echo "Unsupported target: ${TARGETARCH}/${TARGETVARIANT}" >&2; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${goarch}" GOARM="${goarm}" \
|
||||||
|
go build -trimpath -ldflags="-s -w" -o /out/n95bridge ./cmd/n95bridge
|
||||||
|
|
||||||
|
FROM ${BUILD_FROM}
|
||||||
|
|
||||||
|
COPY --from=build /out/n95bridge /n95bridge
|
||||||
|
COPY rootfs /
|
||||||
|
RUN chmod a+x /run.sh
|
||||||
|
|
||||||
|
ARG BUILD_VERSION=0.1.2
|
||||||
|
ARG BUILD_ARCH
|
||||||
|
ARG UPSTREAM_REF=v0.1.2
|
||||||
|
ARG UPSTREAM_COMMIT=7bed99cc4c3222bad648efcddcdfed95652277f7
|
||||||
|
LABEL \
|
||||||
|
io.hass.name="Deebot N95 Local Control" \
|
||||||
|
io.hass.description="Local MQTT control for an already-provisioned Deebot N95" \
|
||||||
|
io.hass.type="addon" \
|
||||||
|
io.hass.version="${BUILD_VERSION}" \
|
||||||
|
io.hass.arch="${BUILD_ARCH}" \
|
||||||
|
org.opencontainers.image.title="deebot-n95-local-control" \
|
||||||
|
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons" \
|
||||||
|
org.opencontainers.image.version="${BUILD_VERSION}" \
|
||||||
|
org.opencontainers.image.revision="${UPSTREAM_COMMIT}" \
|
||||||
|
com.gronod.upstream.ref="${UPSTREAM_REF}"
|
||||||
|
|
||||||
|
CMD [ "/run.sh" ]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Deebot N95 Local Control
|
||||||
|
|
||||||
|
<img src="logo.png" alt="Deebot N95 Local Control" width="128" height="128">
|
||||||
|
|
||||||
|
Redirect an already-provisioned Ecovacs Deebot N95 from its legacy cloud
|
||||||
|
bootstrap and XMPP endpoint to a local bridge, packaged as a Home Assistant
|
||||||
|
add-on.
|
||||||
|
|
||||||
|
The bridge publishes each robot through MQTT discovery as a native Home
|
||||||
|
Assistant vacuum. Once the LAN DNS redirect is in place, robot control and
|
||||||
|
state stay on your local network.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* Local HTTP bootstrap and XMPP endpoint for compatible Deebot N95 robots
|
||||||
|
* Automatic Home Assistant MQTT vacuum discovery
|
||||||
|
* Multiple robots, with separate MQTT clients and topic trees by serial number
|
||||||
|
* Vacuum state and control, cleaning modes, movement, schedules, consumable
|
||||||
|
lifespan, and diagnostics
|
||||||
|
* Automatic Supervisor MQTT broker discovery with per-setting overrides
|
||||||
|
* Optional MQTT authentication, TLS, and private CA support
|
||||||
|
* Health endpoint for installation checks
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* An already-provisioned Deebot N95 using the `wukong` / class `155` protocol
|
||||||
|
* Control of the DNS resolver supplied to the robot by DHCP
|
||||||
|
* A stable IPv4 address for the Home Assistant host
|
||||||
|
* TCP ports `8005`, `8007`, and `5223` reachable from the robot
|
||||||
|
* Home Assistant MQTT configured with discovery enabled, and a reachable broker
|
||||||
|
* The correct IANA timezone for the robot's clock and schedules
|
||||||
|
|
||||||
|
> The robot-facing XMPP connection, including SASL PLAIN authentication, is
|
||||||
|
> plaintext. Run this add-on only on a trusted LAN and restrict access to its
|
||||||
|
> listener ports. MQTT TLS protects the broker connection, not robot XMPP.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Add this repository to Home Assistant
|
||||||
|
(**Settings → Add-ons → Add-on Store → ⋮ → Repositories**):
|
||||||
|
`https://git.i3omb.com/gronod/ha-gronod-addons`
|
||||||
|
2. Install **Deebot N95 Local Control**
|
||||||
|
3. Set **Advertised IP address** to the stable LAN IPv4 address of the Home
|
||||||
|
Assistant host and select the correct timezone
|
||||||
|
4. Configure LAN DNS so `lbo.ecouser.net` resolves to that address
|
||||||
|
5. Start the add-on, verify its health and lookup endpoints, then reboot the
|
||||||
|
robot so it performs bootstrap again
|
||||||
|
|
||||||
|
See [DOCS.md](DOCS.md) for the complete network setup, option reference,
|
||||||
|
verification procedure, MQTT behavior, security notes, and troubleshooting.
|
||||||
|
|
||||||
|
## Upstream and license
|
||||||
|
|
||||||
|
The add-on builds
|
||||||
|
[ha-n95-local-control v0.1.2](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2),
|
||||||
|
which also contains standalone Docker and Go instructions. The upstream
|
||||||
|
software is licensed under the
|
||||||
|
[Apache License 2.0](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2/LICENCE.md).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.1.2
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
build_from:
|
||||||
|
aarch64: ghcr.io/home-assistant/aarch64-base:3.22
|
||||||
|
amd64: ghcr.io/home-assistant/amd64-base:3.22
|
||||||
|
armhf: ghcr.io/home-assistant/armhf-base:3.22
|
||||||
|
armv7: ghcr.io/home-assistant/armv7-base:3.22
|
||||||
|
i386: ghcr.io/home-assistant/i386-base:3.22
|
||||||
|
args:
|
||||||
|
GO_VERSION: "1.27.1"
|
||||||
|
UPSTREAM_REPOSITORY: "https://git.i3omb.com/gronod/ha-n95-local-control.git"
|
||||||
|
UPSTREAM_REF: "v0.1.2"
|
||||||
|
UPSTREAM_COMMIT: "7bed99cc4c3222bad648efcddcdfed95652277f7"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: "Deebot N95 Local Control"
|
||||||
|
description: >-
|
||||||
|
Redirect an already-provisioned Deebot N95 to local MQTT discovery and
|
||||||
|
control in Home Assistant.
|
||||||
|
version: "0.1.2"
|
||||||
|
slug: "n95_mqtt_bridge"
|
||||||
|
url: "https://git.i3omb.com/gronod/ha-gronod-addons/src/branch/main/n95-mqtt-bridge"
|
||||||
|
init: false
|
||||||
|
startup: application
|
||||||
|
boot: auto
|
||||||
|
host_network: true
|
||||||
|
ports:
|
||||||
|
8007/tcp: 8007
|
||||||
|
8005/tcp: 8005
|
||||||
|
5223/tcp: 5223
|
||||||
|
8080/tcp: 8080
|
||||||
|
ports_description:
|
||||||
|
8007/tcp: Robot bootstrap lookup
|
||||||
|
8005/tcp: Firmware check
|
||||||
|
5223/tcp: Robot XMPP
|
||||||
|
8080/tcp: Health
|
||||||
|
arch:
|
||||||
|
- aarch64
|
||||||
|
- amd64
|
||||||
|
- armhf
|
||||||
|
- armv7
|
||||||
|
- i386
|
||||||
|
services:
|
||||||
|
- mqtt:want
|
||||||
|
map:
|
||||||
|
- ssl
|
||||||
|
options:
|
||||||
|
advertise_ip: null
|
||||||
|
timezone: "Etc/UTC"
|
||||||
|
log_level: info
|
||||||
|
schema:
|
||||||
|
advertise_ip: str
|
||||||
|
timezone: str
|
||||||
|
log_level: list(debug|info|warn|error)
|
||||||
|
bind_address: str?
|
||||||
|
port_lookup: int?
|
||||||
|
port_firmware: int?
|
||||||
|
port_xmpp: int?
|
||||||
|
health_port: int?
|
||||||
|
mqtt_host: str?
|
||||||
|
mqtt_port: int?
|
||||||
|
mqtt_tls: bool?
|
||||||
|
mqtt_ca_file: str?
|
||||||
|
mqtt_username: str?
|
||||||
|
mqtt_password: password?
|
||||||
|
mqtt_client_id: str?
|
||||||
|
mqtt_base: str?
|
||||||
|
ha_discovery_prefix: str?
|
||||||
|
controller_jid: str?
|
||||||
|
raw_commands: bool?
|
||||||
|
panel_icon: mdi:robot-vacuum
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export ADVERTISE_IP="$(bashio::config 'advertise_ip')"
|
||||||
|
export TZ="$(bashio::config 'timezone')"
|
||||||
|
export LOG_LEVEL="$(bashio::config 'log_level')"
|
||||||
|
|
||||||
|
mqtt_source=""
|
||||||
|
if bashio::services.available "mqtt"; then
|
||||||
|
export MQTT_HOST="$(bashio::services mqtt 'host')"
|
||||||
|
export MQTT_PORT="$(bashio::services mqtt 'port')"
|
||||||
|
export MQTT_USERNAME="$(bashio::services mqtt 'username')"
|
||||||
|
export MQTT_PASSWORD="$(bashio::services mqtt 'password')"
|
||||||
|
export MQTT_TLS="$(bashio::services mqtt 'ssl')"
|
||||||
|
mqtt_source="Supervisor MQTT service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export_option() {
|
||||||
|
local option="$1"
|
||||||
|
local variable="$2"
|
||||||
|
if bashio::config.exists "${option}"; then
|
||||||
|
export "${variable}=$(bashio::config "${option}")"
|
||||||
|
if [[ "${option}" == mqtt_* ]]; then
|
||||||
|
if [[ -n "${mqtt_source}" ]]; then
|
||||||
|
mqtt_source="${mqtt_source} with option overrides"
|
||||||
|
else
|
||||||
|
mqtt_source="add-on options"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
export_option bind_address BIND_ADDRESS
|
||||||
|
export_option port_lookup PORT_LOOKUP
|
||||||
|
export_option port_firmware PORT_FIRMWARE
|
||||||
|
export_option port_xmpp PORT_XMPP
|
||||||
|
export_option health_port HEALTH_PORT
|
||||||
|
export_option mqtt_host MQTT_HOST
|
||||||
|
export_option mqtt_port MQTT_PORT
|
||||||
|
export_option mqtt_tls MQTT_TLS
|
||||||
|
export_option mqtt_ca_file MQTT_CA_FILE
|
||||||
|
export_option mqtt_username MQTT_USERNAME
|
||||||
|
export_option mqtt_password MQTT_PASSWORD
|
||||||
|
export_option mqtt_client_id MQTT_CLIENT_ID
|
||||||
|
export_option mqtt_base MQTT_BASE
|
||||||
|
export_option ha_discovery_prefix HA_DISCOVERY_PREFIX
|
||||||
|
export_option controller_jid CONTROLLER_JID
|
||||||
|
export_option raw_commands RAW_COMMANDS
|
||||||
|
|
||||||
|
if [[ -z "${MQTT_HOST:-}" ]]; then
|
||||||
|
bashio::log.fatal "No MQTT broker is available. Configure a Supervisor MQTT service or set mqtt_host."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bashio::log.info "Starting Deebot N95 Local Control"
|
||||||
|
bashio::log.info "Advertising ${ADVERTISE_IP}; MQTT settings from ${mqtt_source}"
|
||||||
|
if [[ "${RAW_COMMANDS:-false}" == "true" ]]; then
|
||||||
|
bashio::log.warning "Raw MQTT commands are enabled; use them only for protocol testing."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec /n95bridge
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
configuration:
|
||||||
|
advertise_ip:
|
||||||
|
name: Advertised IP address
|
||||||
|
description: Stable Home Assistant host IPv4 address returned to the robot. Configure lbo.ecouser.net to resolve to this address.
|
||||||
|
timezone:
|
||||||
|
name: Time zone
|
||||||
|
description: IANA time zone used for the local time and UTC offset sent to the robot, for example Europe/London.
|
||||||
|
log_level:
|
||||||
|
name: Log level
|
||||||
|
description: Bridge log verbosity.
|
||||||
|
bind_address:
|
||||||
|
name: Bind address
|
||||||
|
description: Advanced. Address for all listeners. Keep 0.0.0.0 unless you have a specific host-network requirement.
|
||||||
|
port_lookup:
|
||||||
|
name: Lookup port
|
||||||
|
description: Advanced. Robot bootstrap HTTP port; defaults to 8007.
|
||||||
|
port_firmware:
|
||||||
|
name: Firmware port
|
||||||
|
description: Advanced. Robot firmware-check HTTP port; defaults to 8005.
|
||||||
|
port_xmpp:
|
||||||
|
name: XMPP port
|
||||||
|
description: Advanced. Plaintext robot XMPP port; defaults to 5223.
|
||||||
|
health_port:
|
||||||
|
name: Health port
|
||||||
|
description: Advanced. HTTP health endpoint port; defaults to 8080.
|
||||||
|
mqtt_host:
|
||||||
|
name: MQTT host
|
||||||
|
description: Broker host override without a scheme or port. The Supervisor MQTT service is used when available.
|
||||||
|
mqtt_port:
|
||||||
|
name: MQTT port
|
||||||
|
description: Broker port override. Defaults to 1883, or 8883 when TLS is enabled.
|
||||||
|
mqtt_tls:
|
||||||
|
name: MQTT TLS
|
||||||
|
description: Override whether the broker connection uses TLS with hostname verification.
|
||||||
|
mqtt_ca_file:
|
||||||
|
name: MQTT CA file
|
||||||
|
description: Optional PEM CA bundle inside the add-on, normally a path under /ssl.
|
||||||
|
mqtt_username:
|
||||||
|
name: MQTT username
|
||||||
|
description: Broker username override.
|
||||||
|
mqtt_password:
|
||||||
|
name: MQTT password
|
||||||
|
description: Broker password override. A nonempty password requires a username.
|
||||||
|
mqtt_client_id:
|
||||||
|
name: MQTT client ID prefix
|
||||||
|
description: Advanced. Prefix used for each robot's broker client ID.
|
||||||
|
mqtt_base:
|
||||||
|
name: MQTT base topic
|
||||||
|
description: Advanced. Single topic level before each robot serial; defaults to ecovacs.
|
||||||
|
ha_discovery_prefix:
|
||||||
|
name: Discovery prefix
|
||||||
|
description: Advanced. Home Assistant MQTT discovery prefix; defaults to homeassistant.
|
||||||
|
controller_jid:
|
||||||
|
name: Controller JID
|
||||||
|
description: Advanced. Virtual XMPP sender address presented to the robot.
|
||||||
|
raw_commands:
|
||||||
|
name: Raw commands
|
||||||
|
description: Advanced and risky. Allow protocol-level raw ctl commands over MQTT.
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.3
|
||||||
|
|
||||||
|
- Remove the `models` option and its `openai-oauth --models` passthrough
|
||||||
|
- Document `api_key` behavior: empty auto-generates a persisted key printed
|
||||||
|
in the log; paste your own to use a fixed value
|
||||||
|
- Option descriptions shown in the add-on Configuration tab
|
||||||
|
(`translations/en.yaml`)
|
||||||
|
|
||||||
## 1.0.2
|
## 1.0.2
|
||||||
|
|
||||||
- First release in Gronod's Home Assistant Add-Ons repository
|
- First release in Gronod's Home Assistant Add-Ons repository
|
||||||
@@ -9,5 +17,5 @@
|
|||||||
(override with the `api_key` option)
|
(override with the `api_key` option)
|
||||||
- `log_level` and `log_requests` options mapped to upstream
|
- `log_level` and `log_requests` options mapped to upstream
|
||||||
`CODEX_OPENAI_SERVER_LOG_REQUESTS`
|
`CODEX_OPENAI_SERVER_LOG_REQUESTS`
|
||||||
- `models` option passes an allowlist to `openai-oauth --models`
|
|
||||||
- `auth.json` read directly from `/share` so OAuth token refreshes persist
|
- `auth.json` read directly from `/share` so OAuth token refreshes persist
|
||||||
|
- Add-on packaging licensed Apache-2.0 to match upstream components
|
||||||
|
|||||||
@@ -81,11 +81,20 @@ curl http://<ha-host>:10531/v1/models \
|
|||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `api_key` | _(empty)_ | Local API key clients must send. Leave empty to auto-generate one on first start (persisted in the add-on config and printed in the log). |
|
| `api_key` | _(empty)_ | Local API key clients must send. Leave empty and a random key is generated on first start, saved to `/config/.api_key` (it survives restarts), and printed in the add-on log. Paste your own key here if you want to use a fixed value — the saved/generated key is ignored while this option is set. |
|
||||||
| `models` | _(empty)_ | Comma-separated allowlist passed to `openai-oauth --models`. Empty = expose every model your account can use. |
|
|
||||||
| `log_level` | `INFO` | `DEBUG`, `INFO`, or `WARN` — verbosity of the add-on wrapper. `DEBUG` also enables upstream request logging. |
|
| `log_level` | `INFO` | `DEBUG`, `INFO`, or `WARN` — verbosity of the add-on wrapper. `DEBUG` also enables upstream request logging. |
|
||||||
| `log_requests` | `false` | Set upstream `CODEX_OPENAI_SERVER_LOG_REQUESTS=1`: emits one JSON line per request (path, status, duration, token usage). |
|
| `log_requests` | `false` | Set upstream `CODEX_OPENAI_SERVER_LOG_REQUESTS=1`: emits one JSON line per request (path, status, duration, token usage). |
|
||||||
|
|
||||||
|
To generate a suitable key yourself, run any of these on a desktop:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -hex 32
|
||||||
|
# or, matching the add-on's own generator:
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
then paste the output into the `api_key` option and restart the add-on.
|
||||||
|
|
||||||
The host-side port mapping (`10531`) can be changed or disabled in the
|
The host-side port mapping (`10531`) can be changed or disabled in the
|
||||||
add-on's **Network** section; the container-internal port stays `10531`.
|
add-on's **Network** section; the container-internal port stays `10531`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -51,6 +51,8 @@ OpenAI-compatible API. Login uses OpenAI's
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2026 Gordon Bolton. GPL v3 — see `../LICENCE.md`. Upstream components are
|
© 2026 Gordon Bolton. Apache License 2.0 — see `LICENCE.md`, matching the
|
||||||
licensed separately: openai-oauth (Apache-2.0), @openai/codex (Apache-2.0).
|
licenses of the upstream components this add-on wraps:
|
||||||
|
[openai-oauth](https://github.com/EvanZhouDev/openai-oauth) (Apache-2.0) and
|
||||||
|
[@openai/codex](https://github.com/openai/codex) (Apache-2.0).
|
||||||
Use is subject to OpenAI's Terms of Use and your ChatGPT plan's rate limits.
|
Use is subject to OpenAI's Terms of Use and your ChatGPT plan's rate limits.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: "OpenAI Codex Proxy"
|
name: "OpenAI Codex Proxy"
|
||||||
description: "Local reverse proxy for ChatGPT Plus Codex OAuth"
|
description: "Local reverse proxy for ChatGPT Plus Codex OAuth"
|
||||||
version: "1.0.2"
|
version: "1.0.3"
|
||||||
slug: "openai_codex_proxy"
|
slug: "openai_codex_proxy"
|
||||||
init: false
|
init: false
|
||||||
startup: application
|
startup: application
|
||||||
@@ -15,12 +15,10 @@ map:
|
|||||||
- addon_config:rw
|
- addon_config:rw
|
||||||
options:
|
options:
|
||||||
api_key: ""
|
api_key: ""
|
||||||
models: ""
|
|
||||||
log_level: INFO
|
log_level: INFO
|
||||||
log_requests: false
|
log_requests: false
|
||||||
schema:
|
schema:
|
||||||
api_key: password?
|
api_key: password?
|
||||||
models: str?
|
|
||||||
log_level: list(DEBUG|INFO|WARN)
|
log_level: list(DEBUG|INFO|WARN)
|
||||||
log_requests: bool
|
log_requests: bool
|
||||||
panel_icon: mdi:api
|
panel_icon: mdi:api
|
||||||
|
|||||||
@@ -146,9 +146,6 @@ function startUpstream() {
|
|||||||
"--oauth-file",
|
"--oauth-file",
|
||||||
AUTH_FILE,
|
AUTH_FILE,
|
||||||
];
|
];
|
||||||
const models = String(options.models || "").trim();
|
|
||||||
if (models) args.push("--models", models);
|
|
||||||
|
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
env.OPENAI_OAUTH_INTERNAL_RUNTIME_DIR = "/data/openai-oauth";
|
env.OPENAI_OAUTH_INTERNAL_RUNTIME_DIR = "/data/openai-oauth";
|
||||||
const wantRequestLogs =
|
const wantRequestLogs =
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
configuration:
|
||||||
|
api_key:
|
||||||
|
name: API key
|
||||||
|
description: >-
|
||||||
|
Local API key clients must send (as "Authorization: Bearer <key>" or
|
||||||
|
"x-api-key"). Leave empty and a random key is generated on first start,
|
||||||
|
saved to the add-on configuration, and printed in the add-on log. Paste
|
||||||
|
your own key here if you want to use a fixed value.
|
||||||
|
log_level:
|
||||||
|
name: Log level
|
||||||
|
description: >-
|
||||||
|
Verbosity of the add-on wrapper: DEBUG, INFO, or WARN. DEBUG also
|
||||||
|
enables upstream request logging.
|
||||||
|
log_requests:
|
||||||
|
name: Log requests
|
||||||
|
description: >-
|
||||||
|
Emit one JSON line per proxied request (path, status, duration, token
|
||||||
|
usage) from the upstream openai-oauth server.
|
||||||
Reference in New Issue
Block a user