Files
gronod 42570915ed
Build and publish / Test and build (darwin) (push) Successful in 1m43s
Build and publish / Test and build (linux) (push) Successful in 2m30s
Build and publish / Test and build (windows) (push) Successful in 3m5s
Build and publish / Build and publish Docker image (push) Successful in 1m46s
Add browse, next-episode, and track-control tools
Implements the three roadmap feature sections:

- Browse: retrieve_item_children, retrieve_season_list, retrieve_episode_list;
  search_for_item gains item_types (e.g. "Series") so shows are findable
  without a selected library. Items now expose item_type, series_name, and
  per-user played state/resume position.
- retrieve_next_episode: next_unplayed (Emby NextUp, resume counts) or latest
  (most recent present episode; virtual placeholders skipped).
- retrieve_now_playing + set_subtitle/set_audio_track: stream listing and
  track switching via SetSubtitleStreamIndex/SetAudioStreamIndex GeneralCommands,
  with a PlayNow restart-at-position fallback for sessions that cannot switch
  mid-play (DLNA/Chromecast bridges).

Conformance manifest grows to 34 call sites with three new documented spec
gaps (StartPositionTicks on PlayRequest, missing /Shows/{Id}/Episodes response
schema, undeclared Fields/EnableUserData on /Users/{UserId}/Items/{Id}).
2026-09-21 00:09:49 +01:00

167 lines
7.4 KiB
Go

package server
import (
"context"
"encoding/json"
"github.com/gordon/emby-mcp/internal/emby"
"github.com/gordon/emby-mcp/internal/state"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// chunkResult is the JSON shape shared by search_for_item and
// retrieve_next_search_chunk.
type chunkResult struct {
SearchID string `json:"search_id"`
TotalNumberOfItems int `json:"total_number_of_items"`
ChunkSize int `json:"chunk_size"`
ChunkNumber int `json:"chunk_number"`
MoreChunksAvailable bool `json:"more_chunks_available"`
Items []emby.MediaItem `json:"items"`
}
const searchDescription = `Search for media items on the Emby server by item title or album name, artist name, genre name and release / broadcast years.
Parameters "and" together to narrow the results. Genre should be a name returned by tool retrieve_genre_list.
Returns search results as a JSON format, including control data 'total_number_of_items', 'chunk_size' and 'more_chunks_available'
which indicate whether further search results are available via tool retrieve_next_search_chunk.
A human may use any returned JSON field to identify an item. You must only supply the corresponding 'item_id' field when using other tools.
Returns:
Dict: as JSON with keys:
search_id (str): The unique ID of the current search
total_number_of_items (int): Total number of items in the current search
chunk_size (int): the number of items in the current chunk
chunk_number (int): the current chunk number (one-based)
more_chunks_available (bool): False if this is the last chunk, otherwise True.
items (list of dict): the actual items with keys:
title (str): the title of the item.
artists (list): the artists of the item, as a list of strings.
album (str): the album of the item.
album_id (str): the unique identifier of the album within this Emby server.
album_artist (str): the designated album artist.
disk_number (int): the disk or series number of the item.
track_number (int): the track or episode number of the item.
creation_date (str, ISO format): the date the media item was created (or downloaded).
premiere_date (str, ISO format): the date of first release / broadcast of the item.
production_year (int): the year part of premiere_date
genres (list of str): the genres tagged to the item
overview (str): the short description of the item.
lyrics (str): the lyrics or long description of the item
media_type (str): the item type, either 'Audio' or 'Video'.
run_time (str): the run time / play length of the item as hh:mm:ss.
bitrate (int): the bitrate of the item in bits per second.
item_id (str): the unique identifier of the item within this Emby server.
file_path (str): the file path of the item within the Emby server.`
func registerItemTools(s *mcp.Server, st *state.State) {
mcp.AddTool(s, &mcp.Tool{
Name: "search_for_item",
Description: searchDescription,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
TitleOrAlbum string `json:"title_or_album" jsonschema:"name of item, track, episode or album"`
ArtistName string `json:"artist_name" jsonschema:"name of artist"`
GenreName string `json:"genre_name" jsonschema:"genre that items are tagged with"`
BroadcastReleaseYears string `json:"broadcast_release_years" jsonschema:"The item release year(s). Allows multiple years, comma separated."`
LyricsOrDescription string `json:"lyrics_or_description" jsonschema:"a phrase to find in the lyrics or long description for the item"`
ItemTypes string `json:"item_types" jsonschema:"comma separated item types to match (e.g. 'Series' to find a TV show). Empty matches Audio and Video media items."`
}) (*mcp.CallToolResult, any, error) {
cur := st.CurrentLibrary()
if cur == nil && in.ItemTypes == "" {
b, _ := json.Marshal(map[string]string{"error": "ERROR: no library is currently selected. Select library using tool select_library"})
return textResult(string(b)), nil, nil
}
var libID string
if cur != nil {
libID = cur.ID
}
items, err := st.Client.GetItems(ctx, st.UserID, libID, emby.ItemQuery{
SearchTerm: in.TitleOrAlbum,
Artist: in.ArtistName,
Genre: in.GenreName,
Years: in.BroadcastReleaseYears,
Lyrics: in.LyricsOrDescription,
IncludeItemTypes: in.ItemTypes,
EnableUserData: true,
})
if err != nil {
msg := errf("ERROR: failed to retrieve item list because: %v", err)
b, _ := json.Marshal(map[string]string{"error": msg})
return textResult(string(b)), nil, nil
}
return chunkedItems(st, items), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "retrieve_next_search_chunk",
Description: `Retrieve the next chunk of search results that were found by tool search_for_item. Use retrieve_next_search_chunk when you are
ready to process more media items, and repeat until 'more_chunks_available' is no longer true or no data is returned.
Returns search results as a JSON format, including control data 'total_number_of_items', 'chunk_size' and 'more_chunks_available'
which indicate whether further search results are available via tool retrieve_next_search_chunk.
A human may use any returned JSON field to identify an item. You must only supply the corresponding 'item_id' field when using other tools.
Returns:
Dict: as JSON with keys:
search_id (str): The unique ID of the current search
total_number_of_items (int): Total number of items in the current search
chunk_size (int): the number of items in the current chunk
chunk_number (int): the current chunk number (one-based)
more_chunks_available (bool): False if this is the last chunk, otherwise True.
items (list of dict): the actual items`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct{}) (*mcp.CallToolResult, any, error) {
return textResult(nextChunkJSON(st)), nil, nil
})
}
// nextChunkJSON slices the next chunk out of the stored search state and
// returns it as a JSON string. It mirrors the Python chunking semantics,
// including the empty-object and zeroed-result edge cases.
func nextChunkJSON(st *state.State) string {
sc := st.Search()
if sc == nil {
return "{}"
}
// Defensive zeroed result for inconsistent state.
if sc.TotalItems <= 0 || len(sc.Items) == 0 || sc.ChunkSize <= 0 || sc.ChunkNum < 0 {
st.ClearSearch()
b, _ := json.Marshal(chunkResult{SearchID: sc.SearchID, Items: []emby.MediaItem{}})
return string(b)
}
start := sc.ChunkNum * sc.ChunkSize // zero-based slice index
remaining := sc.TotalItems - start
if remaining <= 0 {
st.ClearSearch()
b, _ := json.Marshal(chunkResult{
SearchID: sc.SearchID,
TotalNumberOfItems: sc.TotalItems,
ChunkNumber: sc.ChunkNum,
Items: []emby.MediaItem{},
})
return string(b)
}
size := sc.ChunkSize
more := true
end := start + size
if remaining <= size {
end = start + remaining
size = remaining
more = false
}
sc.ChunkNum++
chunkItems := sc.Items[start:end]
if !more {
st.ClearSearch()
}
b, _ := json.Marshal(chunkResult{
SearchID: sc.SearchID,
TotalNumberOfItems: sc.TotalItems,
ChunkSize: size,
ChunkNumber: sc.ChunkNum,
MoreChunksAvailable: more,
Items: chunkItems,
})
return string(b)
}