diff --git a/emby-mcp/CHANGELOG.md b/emby-mcp/CHANGELOG.md index f79ad5c..a07b06e 100644 --- a/emby-mcp/CHANGELOG.md +++ b/emby-mcp/CHANGELOG.md @@ -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 diff --git a/emby-mcp/DOCS.md b/emby-mcp/DOCS.md index 27d3da9..0ca1d39 100644 --- a/emby-mcp/DOCS.md +++ b/emby-mcp/DOCS.md @@ -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. diff --git a/emby-mcp/Dockerfile b/emby-mcp/Dockerfile index 5012a07..4d7c586 100644 --- a/emby-mcp/Dockerfile +++ b/emby-mcp/Dockerfile @@ -20,7 +20,7 @@ LABEL \ io.hass.name="Emby MCP" \ io.hass.description="Emby Model Context Protocol server with REST bridge" \ io.hass.type="addon" \ - io.hass.version="1.0.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" ] diff --git a/emby-mcp/VERSION b/emby-mcp/VERSION index 5b09c67..a970716 100644 --- a/emby-mcp/VERSION +++ b/emby-mcp/VERSION @@ -1 +1 @@ -1.0.14 +1.0.15 diff --git a/emby-mcp/config.yaml b/emby-mcp/config.yaml index 1700b7a..7cced77 100644 --- a/emby-mcp/config.yaml +++ b/emby-mcp/config.yaml @@ -2,7 +2,7 @@ name: "Emby MCP" description: >- Emby Model Context Protocol server with a Home Assistant REST bridge. Serves streamable MCP at /mcp and REST tool calls at /call/{tool}. -version: "1.0.14" +version: "1.0.15" slug: "emby_mcp" init: false startup: application diff --git a/emby-mcp/internal/emby/items.go b/emby-mcp/internal/emby/items.go index 830b9a4..0f07589 100644 --- a/emby-mcp/internal/emby/items.go +++ b/emby-mcp/internal/emby/items.go @@ -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 { diff --git a/emby-mcp/internal/emby/items_test.go b/emby-mcp/internal/emby/items_test.go index 99a2f40..1ce3e83 100644 --- a/emby-mcp/internal/emby/items_test.go +++ b/emby-mcp/internal/emby/items_test.go @@ -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 +} diff --git a/emby-mcp/internal/emby/playlists.go b/emby-mcp/internal/emby/playlists.go index 4610492..a0e38e5 100644 --- a/emby-mcp/internal/emby/playlists.go +++ b/emby-mcp/internal/emby/playlists.go @@ -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, }) diff --git a/emby-mcp/internal/emby/sessions.go b/emby-mcp/internal/emby/sessions.go index 394e70b..89c90a2 100644 --- a/emby-mcp/internal/emby/sessions.go +++ b/emby-mcp/internal/emby/sessions.go @@ -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, }) diff --git a/emby-mcp/internal/emby/spec_conformance_test.go b/emby-mcp/internal/emby/spec_conformance_test.go index 6c52768..6fa7e28 100644 --- a/emby-mcp/internal/emby/spec_conformance_test.go +++ b/emby-mcp/internal/emby/spec_conformance_test.go @@ -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"}}, diff --git a/emby-mcp/internal/emby/types.go b/emby-mcp/internal/emby/types.go index a5f7515..57c1a58 100644 --- a/emby-mcp/internal/emby/types.go +++ b/emby-mcp/internal/emby/types.go @@ -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"` diff --git a/emby-mcp/internal/server/server.go b/emby-mcp/internal/server/server.go index 820416d..90c0aa0 100644 --- a/emby-mcp/internal/server/server.go +++ b/emby-mcp/internal/server/server.go @@ -13,7 +13,7 @@ import ( const ( Name = "HA Emby MCP" - Version = "1.0.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, diff --git a/emby-mcp/internal/server/tools_items.go b/emby-mcp/internal/server/tools_items.go index 91fff9c..81d468a 100644 --- a/emby-mcp/internal/server/tools_items.go +++ b/emby-mcp/internal/server/tools_items.go @@ -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.