feat: restore premiere_date on item results (1.0.15) #1

Merged
gronod merged 1 commits from develop into main 2026-09-23 15:21:27 +01:00
13 changed files with 134 additions and 8 deletions
+21
View File
@@ -1,5 +1,26 @@
# Changelog # Changelog
## 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
+6 -1
View File
@@ -101,7 +101,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. Use `emby_search` to find films, TV series, episodes, or music tracks. Use `emby_episode_list` to list or count a series' episodes (seasons via `emby_season_list`). Each episode item includes `premiere_date` (YYYY-MM-DD first air date) when Emby has that metadata; use it for airdate questions. Use `emby_list_players` to find active playback sessions, and `emby_playback_control` to send playback commands (PlayNow, Pause, Unpause, Stop, NextTrack). Use `emby_next_episode` when asked what episode to watch next.
``` ```
### Functions ### Functions
@@ -285,6 +285,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
View File
@@ -20,7 +20,7 @@ LABEL \
io.hass.name="Emby MCP" \ io.hass.name="Emby MCP" \
io.hass.description="Emby Model Context Protocol server with REST bridge" \ io.hass.description="Emby Model Context Protocol server with REST bridge" \
io.hass.type="addon" \ io.hass.type="addon" \
io.hass.version="1.0.14" \ io.hass.version="1.0.15" \
org.opencontainers.image.title="emby-mcp" \ org.opencontainers.image.title="emby-mcp" \
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons" org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons"
CMD [ "/run.sh" ] CMD [ "/run.sh" ]
+1 -1
View File
@@ -1 +1 @@
1.0.14 1.0.15
+1 -1
View File
@@ -2,7 +2,7 @@ name: "Emby MCP"
description: >- description: >-
Emby Model Context Protocol server with a Home Assistant REST bridge. Emby Model Context Protocol server with a Home Assistant REST bridge.
Serves streamable MCP at /mcp and REST tool calls at /call/{tool}. Serves streamable MCP at /mcp and REST tool calls at /call/{tool}.
version: "1.0.14" version: "1.0.15"
slug: "emby_mcp" slug: "emby_mcp"
init: false init: false
startup: application startup: application
+19 -1
View File
@@ -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 {
+76
View File
@@ -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
}
+2
View File
@@ -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,
}) })
+2
View File
@@ -98,6 +98,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 +152,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,
}) })
@@ -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"}},
+1
View File
@@ -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"`
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const ( const (
Name = "HA Emby MCP" Name = "HA Emby MCP"
Version = "1.0.14" Version = "1.0.15"
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,
+1
View File
@@ -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.