Files
gronod 2d5763d6c6 feat: slim tool responses, drop lyrics search, rename module (1.0.11)
Item-shaped results now omit empty fields and drop low-value metadata
(overview, lyrics, file_path, creation_date, premiere_date, bitrate);
absent numeric fields drop the key instead of emitting "". Player
sessions report hh:mm:ss times only; playlists drop overview/date_created.
The lyrics_or_description search parameter is removed, along with the
unused DTO fields, and upstream Fields requests are slimmed to match.
Module path renamed to git.i3omb.com/gronod/emby-mcp.
2026-09-21 23:54:07 +01:00

515 lines
19 KiB
Go

package server
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
"git.i3omb.com/gronod/emby-mcp/internal/state"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// chunkedItems emits items as a JSON chunkResult, storing the remainder in the
// search state for retrieve_next_search_chunk when over the chunk limit.
// Shared by the browse tools with search_for_item's semantics.
func chunkedItems(st *state.State, items []emby.MediaItem) *mcp.CallToolResult {
total := len(items)
maxChunk := st.MaxChunkSize
st.ClearSearch()
if maxChunk > 0 && maxChunk < total {
st.SetSearch(&state.SearchChunk{
SearchID: uuid.NewString(),
TotalItems: total,
ChunkSize: maxChunk,
Items: items,
})
return textResult(nextChunkJSON(st))
}
return jsonResult(chunkResult{
SearchID: uuid.NewString(),
TotalNumberOfItems: total,
ChunkSize: min(maxChunk, total),
ChunkNumber: 1,
Items: items,
})
}
func registerBrowseTools(s *mcp.Server, st *state.State) {
mcp.AddTool(s, &mcp.Tool{
Name: "retrieve_item_children",
Description: `Retrieve the direct children of an item on the Emby media server in JSON format.
Use to browse a series' seasons, a season's episodes, an album's tracks, or a folder's contents.
Args:
item_id (str): The ID of the parent item (series, season, album or folder)
Returns:
JSON search result with the same shape as search_for_item, including an 'item_type' field
(e.g. 'Series', 'Season', 'Episode', 'Audio', 'Movie') on each item.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
ItemID string `json:"item_id" jsonschema:"The ID of the parent item (series, season, album or folder)"`
}) (*mcp.CallToolResult, any, error) {
if in.ItemID == "" {
return textResult("ERROR: no item_id was supplied. Supply the item_id of a series, season, album or folder"), nil, nil
}
items, err := st.Client.GetChildren(ctx, st.UserID, in.ItemID)
if err != nil {
return textResult(errf("ERROR: failed to retrieve item children because: %v", err)), nil, nil
}
return chunkedItems(st, items), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "retrieve_season_list",
Description: `Retrieve the seasons of a TV series on the Emby media server in JSON format.
Find the series' item_id via tool search_for_item with item_types 'Series'.
Args:
series_id (str): The ID of the series
Returns:
JSON list of seasons; each item's 'item_id' can be passed to retrieve_episode_list as 'season_id'.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
SeriesID string `json:"series_id" jsonschema:"The ID of the series, obtained via search_for_item with item_types 'Series'"`
}) (*mcp.CallToolResult, any, error) {
if in.SeriesID == "" {
return textResult("ERROR: no series_id was supplied. Find the series via tool search_for_item with item_types 'Series'"), nil, nil
}
items, err := st.Client.GetSeasons(ctx, st.UserID, in.SeriesID)
if err != nil {
return textResult(errf("ERROR: failed to retrieve season list because: %v", err)), nil, nil
}
return chunkedItems(st, items), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "retrieve_episode_list",
Description: `Retrieve the episodes of a TV series on the Emby media server in JSON format,
optionally restricted to one season. Episodes include watch state ('played', 'played_percentage',
'resume_position_milliseconds') so played/unplayed questions can be answered directly.
Args:
series_id (str): The ID of the series, obtained via search_for_item with item_types 'Series'
season_id (str, optional): Restrict to one season (season item_id from retrieve_season_list)
Returns:
JSON search result with the same shape as search_for_item; 'disk_number' is the season number
and 'track_number' the episode number.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
SeriesID string `json:"series_id" jsonschema:"The ID of the series"`
SeasonID string `json:"season_id" jsonschema:"Optional season item_id to restrict to one season, or empty for all"`
}) (*mcp.CallToolResult, any, error) {
if in.SeriesID == "" {
return textResult("ERROR: no series_id was supplied. Find the series via tool search_for_item with item_types 'Series'"), nil, nil
}
items, err := st.Client.GetEpisodes(ctx, st.UserID, in.SeriesID, in.SeasonID)
if err != nil {
return textResult(errf("ERROR: failed to retrieve episode list because: %v", err)), nil, nil
}
return chunkedItems(st, items), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "retrieve_next_episode",
Description: `Retrieve the episode of a TV series to play next on the Emby media server in JSON format.
Mode 'next_unplayed' (default) returns the user's next-up episode: the first episode not fully
played, including a partially-watched episode to resume (see 'resume_position_milliseconds').
Mode 'latest' returns the most recently premiered episode present on the server.
Supply the returned 'item_id' to tool control_media_player with command 'PlayNow' to play it.
Args:
series_name (str): The name of the series (e.g. 'Family Guy'), or empty if supplying series_id
series_id (str): The ID of the series if known, or empty to resolve by name
mode (str): 'next_unplayed' (default) or 'latest'
Returns:
Dict: a single item in search_for_item format plus 'mode' and 'series_id', or an error.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
SeriesName string `json:"series_name" jsonschema:"The name of the series, or empty if supplying series_id"`
SeriesID string `json:"series_id" jsonschema:"The ID of the series if known, or empty to resolve by name"`
Mode string `json:"mode" jsonschema:"'next_unplayed' (default) or 'latest'"`
}) (*mcp.CallToolResult, any, error) {
seriesID := in.SeriesID
seriesName := in.SeriesName
if seriesID == "" {
if seriesName == "" {
return textResult("ERROR: supply either 'series_name' or 'series_id'"), nil, nil
}
exact, candidates, err := st.Client.FindSeries(ctx, st.UserID, seriesName)
if err != nil {
return textResult(errf("ERROR: failed to find series because: %v", err)), nil, nil
}
if exact == nil {
if len(candidates) == 0 {
return textResult(errf("ERROR: no series found matching %q", seriesName)), nil, nil
}
var names []string
for _, c := range candidates {
names = append(names, fmt.Sprintf("%s (item_id %s)", c.Title, c.ItemID))
}
return textResult(errf("ERROR: no exact series match for %q. Candidates: %s. Retry with 'series_id'.", seriesName, strings.Join(names, ", "))), nil, nil
}
seriesID = exact.ItemID
seriesName = exact.Title
}
var ep *emby.MediaItem
var err error
mode := strings.ToLower(in.Mode)
if mode == "" {
mode = "next_unplayed"
}
switch mode {
case "next_unplayed":
ep, err = st.Client.GetNextUp(ctx, st.UserID, seriesID)
case "latest":
ep, err = st.Client.GetLatestEpisode(ctx, st.UserID, seriesID)
default:
return textResult("ERROR: invalid mode. Valid modes are 'next_unplayed' and 'latest'"), nil, nil
}
if err != nil {
return textResult(errf("ERROR: failed to retrieve next episode because: %v", err)), nil, nil
}
if ep == nil {
return textResult(errf("ERROR: no episode available for %q in mode %q", seriesName, mode)), nil, nil
}
return jsonResult(struct {
emby.MediaItem
Mode string `json:"mode"`
SeriesID string `json:"series_id"`
}{*ep, mode, seriesID}), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "retrieve_now_playing",
Description: `Retrieve details of the item currently playing on a media player session in JSON
format, including the available audio and subtitle tracks.
Args:
session_id (str): The ID of the player session, obtained from tool retrieve_player_list
Returns:
Dict: as JSON with keys: item_id, title, series_name, position_milliseconds, can_seek,
audio_stream_index, subtitle_stream_index, audio_streams, subtitle_streams (each stream has
index, language, codec, title, display_title, is_default, is_forced, is_external, is_selected),
supported_commands.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
SessionID string `json:"session_id" jsonschema:"The ID of the player session to query, obtained from tool retrieve_player_list"`
}) (*mcp.CallToolResult, any, error) {
if in.SessionID == "" {
return textResult("ERROR: no session_id was supplied. Obtain session_id from tool retrieve_player_list"), nil, nil
}
session, err := st.Client.GetSession(ctx, in.SessionID)
if err != nil {
return textResult(errf("ERROR: failed to retrieve session because: %v", err)), nil, nil
}
if session == nil {
return textResult("ERROR: session not found. Obtain a fresh session_id from tool retrieve_player_list"), nil, nil
}
if session.NowPlayingItem == nil {
return textResult("ERROR: nothing is currently playing on this session"), nil, nil
}
item := session.NowPlayingItem
if len(item.MediaSources) == 0 {
full, ferr := st.Client.GetItem(ctx, st.UserID, item.ID)
if ferr == nil && len(full.MediaSources) > 0 {
item = full
}
}
return jsonResult(nowPlayingResult(session, item)), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "set_subtitle",
Description: `Enable or change the subtitle track on a media player session in JSON format.
Identify the track by 'track' as a stream index, a language name (e.g. 'english'), or part of the
track title. Use 'off' or 'none' to disable subtitles. Use tool retrieve_now_playing to list the
available tracks.
If the player does not support switching tracks during playback, or the switch cannot be confirmed, playback is restarted with the
selected track at the current position.
Args:
session_id (str): The ID of the player session, obtained from tool retrieve_player_list
track (str): Stream index, language, or title of the subtitle track; 'off'/'none' to disable
Returns:
Str: success messsage or error message.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
SessionID string `json:"session_id" jsonschema:"The ID of the player session to control"`
Track string `json:"track" jsonschema:"Stream index, language, or title of the subtitle track; 'off'/'none' to disable"`
}) (*mcp.CallToolResult, any, error) {
return setStreamTrack(ctx, st, in.SessionID, in.Track, "Subtitle"), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "set_audio_track",
Description: `Change the audio track on a media player session in JSON format.
Identify the track by 'track' as a stream index, a language name (e.g. 'english'), or part of the
track title. Use tool retrieve_now_playing to list the available tracks.
If the player does not support switching tracks during playback, or the switch cannot be confirmed, playback is restarted with the
selected track at the current position.
Args:
session_id (str): The ID of the player session, obtained from tool retrieve_player_list
track (str): Stream index, language, or title of the audio track
Returns:
Str: success messsage or error message.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
SessionID string `json:"session_id" jsonschema:"The ID of the player session to control"`
Track string `json:"track" jsonschema:"Stream index, language, or title of the audio track"`
}) (*mcp.CallToolResult, any, error) {
return setStreamTrack(ctx, st, in.SessionID, in.Track, "Audio"), nil, nil
})
}
// streamDescriptor is one audio/subtitle track in retrieve_now_playing output.
type streamDescriptor struct {
Index int `json:"index"`
Language string `json:"language,omitempty"`
Codec string `json:"codec,omitempty"`
Title string `json:"title,omitempty"`
DisplayTitle string `json:"display_title,omitempty"`
IsDefault bool `json:"is_default"`
IsForced bool `json:"is_forced"`
IsExternal bool `json:"is_external"`
IsSelected bool `json:"is_selected"`
}
// nowPlayingResult is the output shape of retrieve_now_playing.
type nowPlaying struct {
ItemID string `json:"item_id"`
Title string `json:"title"`
SeriesName string `json:"series_name,omitempty"`
MediaType string `json:"media_type"`
PositionMilliseconds int64 `json:"position_milliseconds"`
CanSeek bool `json:"can_seek"`
AudioStreamIndex any `json:"audio_stream_index"`
SubtitleStreamIndex any `json:"subtitle_stream_index"`
AudioStreams []streamDescriptor `json:"audio_streams"`
SubtitleStreams []streamDescriptor `json:"subtitle_streams"`
SupportedCommands []string `json:"supported_commands"`
}
func nowPlayingResult(session *emby.SessionInfo, item *emby.BaseItemDto) nowPlaying {
var audioIdx, subIdx *int
var pos int64
var canSeek bool
if ps := session.PlayState; ps != nil {
audioIdx, subIdx = ps.AudioStreamIndex, ps.SubtitleStreamIndex
pos = ticksToMSLocal(ps.PositionTicks)
canSeek = ps.CanSeek
}
var audio, subs []streamDescriptor
for _, ms := range item.MediaSources {
for _, st := range ms.MediaStreams {
d := streamDescriptor{
Index: st.Index,
Language: st.Language,
Codec: st.Codec,
Title: st.Title,
DisplayTitle: st.DisplayTitle,
IsDefault: st.IsDefault,
IsForced: st.IsForced,
IsExternal: st.IsExternal,
}
switch st.Type {
case "Audio":
d.IsSelected = audioIdx != nil && *audioIdx == st.Index
audio = append(audio, d)
case "Subtitle":
d.IsSelected = subIdx != nil && *subIdx == st.Index
subs = append(subs, d)
}
}
}
return nowPlaying{
ItemID: item.ID,
Title: item.Name,
SeriesName: item.SeriesName,
MediaType: item.MediaType,
PositionMilliseconds: pos,
CanSeek: canSeek,
AudioStreamIndex: intPtrOrEmpty(audioIdx),
SubtitleStreamIndex: intPtrOrEmpty(subIdx),
AudioStreams: audio,
SubtitleStreams: subs,
SupportedCommands: session.SupportedCommands,
}
}
func ticksToMSLocal(ticks int64) int64 { return ticks / 10_000 }
func intPtrOrEmpty(p *int) any {
if p == nil {
return ""
}
return *p
}
// setStreamTrack implements set_subtitle / set_audio_track: resolve the track,
// switch mid-playback when the session supports it, else restart playback at
// the current position with the stream selected.
func setStreamTrack(ctx context.Context, st *state.State, sessionID, track, streamType string) *mcp.CallToolResult {
if sessionID == "" {
return textResult("ERROR: no session_id was supplied. Obtain session_id from tool retrieve_player_list")
}
if track == "" {
return textResult("ERROR: no track was supplied. Use tool retrieve_now_playing to list the available tracks")
}
session, err := st.Client.GetSession(ctx, sessionID)
if err != nil {
return textResult(errf("ERROR: failed to retrieve session because: %v", err))
}
if session == nil {
return textResult("ERROR: session not found. Obtain a fresh session_id from tool retrieve_player_list")
}
item := session.NowPlayingItem
if item == nil {
return textResult("ERROR: nothing is currently playing on this session")
}
if len(item.MediaSources) == 0 {
full, ferr := st.Client.GetItem(ctx, st.UserID, item.ID)
if ferr == nil {
item = full
}
}
var command, fieldName string
var streams []emby.MediaStream
switch streamType {
case "Subtitle":
command, fieldName = "SetSubtitleStreamIndex", "SubtitleStreamIndex"
case "Audio":
command, fieldName = "SetAudioStreamIndex", "AudioStreamIndex"
}
for _, ms := range item.MediaSources {
for _, sm := range ms.MediaStreams {
if sm.Type == streamType {
streams = append(streams, sm)
}
}
}
index, resolveErr := resolveStreamIndex(streams, track, streamType == "Subtitle")
if resolveErr != "" {
return textResult(resolveErr)
}
if session.SupportsCommand(command) {
args := map[string]string{"Index": strconv.Itoa(index)}
if err := st.Client.SendGeneralCommand(ctx, sessionID, command, args, st.UserID); err != nil {
return textResult(errf("ERROR: failed to set %s track because: %v", streamType, err))
}
// Some renderers (DLNA/Cast bridges) advertise the command but silently
// drop it — confirm the selection landed in PlayState before declaring
// success, otherwise fall through to the restart path.
for range 3 {
time.Sleep(400 * time.Millisecond)
s2, verr := st.Client.GetSession(ctx, sessionID)
if verr == nil && s2 != nil {
session = s2
if streamIndexMatches(session.PlayState, fieldName, index) {
return textResult("Success")
}
}
}
}
// Fallback: restart playback with the stream selected, resuming position.
opts := emby.PlayOptions{}
idx := index
if fieldName == "SubtitleStreamIndex" {
opts.SubtitleStreamIndex = &idx
} else {
opts.AudioStreamIndex = &idx
}
pos := int64(0)
if ps := session.PlayState; ps != nil {
pos = ps.PositionTicks
}
if pos > 0 {
opts.StartPositionTicks = &pos
}
if err := st.Client.PlayNowWithOptions(ctx, sessionID, item.ID, st.UserID, opts); err != nil {
return textResult(errf("ERROR: player does not support switching tracks mid-playback; failed to restart playback because: %v", err))
}
return textResult("Success (playback restarted with selected track)")
}
// streamIndexMatches reports whether PlayState reflects the requested stream
// index. A negative index (subtitles off) matches a nil or negative PlayState
// value.
func streamIndexMatches(ps *emby.PlayState, fieldName string, index int) bool {
var cur *int
if ps != nil {
if fieldName == "SubtitleStreamIndex" {
cur = ps.SubtitleStreamIndex
} else {
cur = ps.AudioStreamIndex
}
}
if index < 0 {
return cur == nil || *cur < 0
}
return cur != nil && *cur == index
}
// resolveStreamIndex maps a user-supplied track selector to a stream index:
// a numeric stream index, a language name, or a title/display-title substring.
// "off"/"none" (subtitles only) resolves to -1. Returns a non-empty error
// string on failure.
func resolveStreamIndex(streams []emby.MediaStream, track string, allowOff bool) (int, string) {
if allowOff && (strings.EqualFold(track, "off") || strings.EqualFold(track, "none")) {
return -1, ""
}
if n, err := strconv.Atoi(track); err == nil {
return n, ""
}
needle := strings.ToLower(track)
var matches []emby.MediaStream
for _, s := range streams {
if s.Language != "" && strings.EqualFold(s.Language, track) {
matches = append(matches, s)
continue
}
if strings.Contains(strings.ToLower(s.Title), needle) ||
strings.Contains(strings.ToLower(s.DisplayTitle), needle) {
matches = append(matches, s)
}
}
switch len(matches) {
case 1:
return matches[0].Index, ""
case 0:
var avail []string
for _, s := range streams {
avail = append(avail, fmt.Sprintf("%d: %s", s.Index, streamLabel(s)))
}
return 0, fmt.Sprintf("ERROR: no matching track for %q. Available tracks: %s", track, strings.Join(avail, "; "))
default:
var avail []string
for _, s := range matches {
avail = append(avail, fmt.Sprintf("%d: %s", s.Index, streamLabel(s)))
}
return 0, fmt.Sprintf("ERROR: multiple tracks match %q: %s. Supply a stream index.", track, strings.Join(avail, "; "))
}
}
func streamLabel(s emby.MediaStream) string {
label := s.DisplayTitle
if label == "" {
label = s.Title
}
if label == "" {
label = s.Language
}
if label == "" {
label = s.Codec
}
return label
}