Files
emby-mcp/internal/emby/shows.go
T
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

152 lines
4.6 KiB
Go

package emby
import (
"context"
"fmt"
"net/url"
"strings"
)
// browseFields requests the extra item fields used by the browse/episode tools.
const browseFields = itemExtraFields + ",SeriesName,SeasonName"
// GetChildren lists the direct children of an item: seasons of a series,
// episodes of a season, tracks of an album, or folder contents. Unlike
// GetItems it does not restrict MediaTypes, so container items (Series,
// Season, Folder) are returned too.
func (c *Client) GetChildren(ctx context.Context, userID, parentID string) ([]MediaItem, error) {
if parentID == "" {
return nil, fmt.Errorf("the 'item_id' parameter cannot be empty")
}
q := url.Values{
"Recursive": {"false"},
"ParentId": {parentID},
"Fields": {browseFields},
"SortBy": {"SortName"},
}
var res QueryResult[BaseItemDto]
if err := c.Get(ctx, "/Users/"+userID+"/Items", q, &res); err != nil {
return nil, err
}
items := make([]MediaItem, 0, len(res.Items))
for _, it := range res.Items {
items = append(items, toMediaItem(&it, true))
}
return items, nil
}
// GetSeasons lists the seasons of a series.
func (c *Client) GetSeasons(ctx context.Context, userID, seriesID string) ([]MediaItem, error) {
if seriesID == "" {
return nil, fmt.Errorf("the 'series_id' parameter cannot be empty")
}
q := url.Values{
"UserId": {userID},
"Fields": {browseFields},
}
var res QueryResult[BaseItemDto]
if err := c.Get(ctx, "/Shows/"+seriesID+"/Seasons", q, &res); err != nil {
return nil, err
}
items := make([]MediaItem, 0, len(res.Items))
for _, it := range res.Items {
items = append(items, toMediaItem(&it, true))
}
return items, nil
}
// GetEpisodes lists the episodes of a series, optionally restricted to one
// season. Episodes are sorted by season/episode number and include per-user
// watch state.
func (c *Client) GetEpisodes(ctx context.Context, userID, seriesID, seasonID string) ([]MediaItem, error) {
if seriesID == "" {
return nil, fmt.Errorf("the 'series_id' parameter cannot be empty")
}
q := url.Values{
"UserId": {userID},
"Fields": {browseFields},
"SortBy": {"ParentIndexNumber,IndexNumber"},
"EnableUserData": {"true"},
}
if seasonID != "" {
q.Set("SeasonId", seasonID)
}
var res QueryResult[BaseItemDto]
if err := c.Get(ctx, "/Shows/"+seriesID+"/Episodes", q, &res); err != nil {
return nil, err
}
items := make([]MediaItem, 0, len(res.Items))
for _, it := range res.Items {
items = append(items, toMediaItem(&it, true))
}
return items, nil
}
// GetNextUp returns the user's next-up episode for a series: the first episode
// that is not fully played, including partially-watched resume candidates.
// Returns nil when the series has no next-up episode (e.g. fully watched).
func (c *Client) GetNextUp(ctx context.Context, userID, seriesID string) (*MediaItem, error) {
q := url.Values{
"UserId": {userID},
"SeriesId": {seriesID},
"Fields": {browseFields},
"EnableUserData": {"true"},
"Limit": {"1"},
}
var res QueryResult[BaseItemDto]
if err := c.Get(ctx, "/Shows/NextUp", q, &res); err != nil {
return nil, err
}
if len(res.Items) == 0 {
return nil, nil
}
mi := toMediaItem(&res.Items[0], true)
return &mi, nil
}
// GetLatestEpisode returns the most recently premiered episode of a series
// that is actually present (virtual/missing placeholders are skipped).
// Returns nil when the series has no episodes.
func (c *Client) GetLatestEpisode(ctx context.Context, userID, seriesID string) (*MediaItem, error) {
q := url.Values{
"UserId": {userID},
"Fields": {browseFields},
"SortBy": {"PremiereDate"},
"SortOrder": {"Descending"},
"Limit": {"10"},
"EnableUserData": {"true"},
}
var res QueryResult[BaseItemDto]
if err := c.Get(ctx, "/Shows/"+seriesID+"/Episodes", q, &res); err != nil {
return nil, err
}
for i := range res.Items {
if strings.EqualFold(res.Items[i].LocationType, "Virtual") {
continue
}
mi := toMediaItem(&res.Items[i], true)
return &mi, nil
}
return nil, nil
}
// FindSeries resolves a series by name across all libraries. An exact
// case-insensitive name match wins; otherwise all candidates are returned so
// the caller can disambiguate.
func (c *Client) FindSeries(ctx context.Context, userID, name string) (exact *MediaItem, candidates []MediaItem, err error) {
items, err := c.GetItems(ctx, userID, "", ItemQuery{
SearchTerm: name,
IncludeItemTypes: "Series",
})
if err != nil {
return nil, nil, err
}
for i := range items {
if strings.EqualFold(items[i].Title, name) {
mi := items[i]
return &mi, nil, nil
}
}
return nil, items, nil
}