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
## 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
- `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:
```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
@@ -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 **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)
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.description="Emby Model Context Protocol server with REST bridge" \
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.source="https://git.i3omb.com/gronod/ha-gronod-addons"
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: >-
Emby Model Context Protocol server with a Home Assistant REST bridge.
Serves streamable MCP at /mcp and REST tool calls at /call/{tool}.
version: "1.0.14"
version: "1.0.15"
slug: "emby_mcp"
init: false
startup: application
+19 -1
View File
@@ -23,6 +23,7 @@ type MediaItem struct {
DiskNumber any `json:"disk_number,omitempty"`
TrackNumber any `json:"track_number,omitempty"`
ProductionYear any `json:"production_year,omitempty"`
PremiereDate string `json:"premiere_date,omitempty"`
Genres []string `json:"genres,omitempty"`
RunTime string `json:"run_time,omitempty"`
Played bool `json:"played"`
@@ -52,7 +53,7 @@ type ItemQuery struct {
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
// libraryID is empty).
@@ -172,9 +173,26 @@ func toMediaItem(it *BaseItemDto) MediaItem {
mi.DiskNumber = intOrEmpty(it.ParentIndexNumber)
mi.TrackNumber = intOrEmpty(it.IndexNumber)
mi.ProductionYear = intOrEmpty(it.ProductionYear)
mi.PremiereDate = formatISODate(it.PremiereDate)
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".
func ticksToHMS(ticks int64) string {
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"`
TrackNumber any `json:"track_number,omitempty"`
ProductionYear any `json:"production_year,omitempty"`
PremiereDate string `json:"premiere_date,omitempty"`
Genres []string `json:"genres,omitempty"`
RunTime string `json:"run_time,omitempty"`
}
@@ -129,6 +130,7 @@ func (c *Client) GetPlaylistItems(ctx context.Context, userID, playlistID string
DiskNumber: mi.DiskNumber,
TrackNumber: mi.TrackNumber,
ProductionYear: mi.ProductionYear,
PremiereDate: mi.PremiereDate,
Genres: mi.Genres,
RunTime: mi.RunTime,
})
+2
View File
@@ -98,6 +98,7 @@ type PlayQueueItem struct {
DiskNumber any `json:"disk_number,omitempty"`
TrackNumber any `json:"track_number,omitempty"`
ProductionYear any `json:"production_year,omitempty"`
PremiereDate string `json:"premiere_date,omitempty"`
Genres []string `json:"genres,omitempty"`
RunTime string `json:"run_time,omitempty"`
}
@@ -151,6 +152,7 @@ func (c *Client) GetPlayQueueItems(ctx context.Context, sessionID string) ([]Pla
DiskNumber: mi.DiskNumber,
TrackNumber: mi.TrackNumber,
ProductionYear: mi.ProductionYear,
PremiereDate: mi.PremiereDate,
Genres: mi.Genres,
RunTime: mi.RunTime,
})
@@ -157,7 +157,7 @@ var mediaItemRespFields = []string{
"Items", "TotalRecordCount",
"Items.Name", "Items.Artists", "Items.Album", "Items.AlbumId",
"Items.AlbumArtist", "Items.ParentIndexNumber", "Items.IndexNumber",
"Items.ProductionYear",
"Items.ProductionYear", "Items.PremiereDate",
"Items.Genres", "Items.MediaType", "Items.RunTimeTicks",
"Items.Id", "Items.Type",
"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: "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_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 (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"}},
+1
View File
@@ -90,6 +90,7 @@ type BaseItemDto struct {
ParentIndexNumber *int `json:"ParentIndexNumber"`
IndexNumber *int `json:"IndexNumber"`
ProductionYear *int `json:"ProductionYear"`
PremiereDate string `json:"PremiereDate"`
Genres []string `json:"Genres"`
MediaSources []MediaSource `json:"MediaSources"`
MediaType string `json:"MediaType"`
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const (
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
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,
+1
View File
@@ -47,6 +47,7 @@ Returns:
disk_number (int): the disk or series 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.
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
run_time (str): the run time / play length of the item as hh:mm:ss.
played (bool): whether the item has been fully played.