Files
emby-mcp/internal/emby/items.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

265 lines
7.8 KiB
Go

package emby
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/gordon/emby-mcp/internal/textutil"
)
// MediaItem is the output shape for media items (matches Python output keys,
// extended with browse/watch-state fields).
// Integer-or-empty fields are `any` to mirror the Python dicts, which emit ""
// when a value is absent.
type MediaItem struct {
Title string `json:"title"`
Artists []string `json:"artists"`
Album string `json:"album"`
AlbumID string `json:"album_id"`
AlbumArtist string `json:"album_artist"`
DiskNumber any `json:"disk_number"`
TrackNumber any `json:"track_number"`
CreationDate string `json:"creation_date"`
PremiereDate string `json:"premiere_date"`
ProductionYear any `json:"production_year"`
Genres []string `json:"genres"`
Overview string `json:"overview"`
Lyrics string `json:"lyrics"`
MediaType string `json:"media_type"`
RunTime string `json:"run_time"`
Bitrate any `json:"bitrate"`
ItemID string `json:"item_id"`
FilePath string `json:"file_path"`
ItemType string `json:"item_type,omitempty"`
SeriesName string `json:"series_name,omitempty"`
Played bool `json:"played"`
PlayedPercentage any `json:"played_percentage"`
ResumePositionMilliseconds int64 `json:"resume_position_milliseconds,omitempty"`
}
// ItemQuery carries the optional search filters for GetItems.
// Multiple values within one field (e.g. Years "1999,2001") widen the match;
// different fields narrow it.
type ItemQuery struct {
SearchTerm string // title/album
Artist string
Genre string
Lyrics string // client-side lyrics/overview phrase search
Years string // comma-separated
FirstDate string // ISO date, MinStartDate
LastDate string // ISO date, MaxEndDate
IsUnplayed bool
IsPlayed bool
IsFavorite bool
Limit string
ParentID string // browse children of this item/library
IncludeItemTypes string // e.g. "Series"; when set, MediaTypes is omitted
MediaTypes string // override; default "Audio,Video"
SortBy string // e.g. "PremiereDate" or "ParentIndexNumber,IndexNumber"
SortOrder string // "Ascending" or "Descending"
EnableUserData bool // include per-user watch state
}
const itemExtraFields = "Genres,MediaSources,DateCreated,Overview,ProductionYear,PremiereDate,Path"
// GetItems queries Audio/Video items in a library (or all libraries when
// libraryID is empty) and applies client-side lyrics filtering.
func (c *Client) GetItems(ctx context.Context, userID, libraryID string, iq ItemQuery) ([]MediaItem, error) {
q := url.Values{
"Recursive": {"true"},
"Fields": {itemExtraFields},
}
if iq.IncludeItemTypes != "" {
q.Set("IncludeItemTypes", iq.IncludeItemTypes)
} else {
mediaTypes := iq.MediaTypes
if mediaTypes == "" {
mediaTypes = "Audio,Video"
}
q.Set("MediaTypes", mediaTypes)
}
parentID := iq.ParentID
if libraryID != "" {
parentID = libraryID
}
if parentID != "" {
q.Set("ParentId", parentID)
}
if iq.SortBy != "" {
q.Set("SortBy", iq.SortBy)
}
if iq.SortOrder != "" {
q.Set("SortOrder", iq.SortOrder)
}
if iq.EnableUserData {
q.Set("EnableUserData", "true")
}
if iq.SearchTerm != "" {
q.Set("SearchTerm", iq.SearchTerm)
}
if iq.Artist != "" {
q.Set("Artists", iq.Artist)
}
if iq.Genre != "" {
q.Set("Genres", iq.Genre)
}
if iq.Years != "" {
q.Set("Years", iq.Years)
}
if iq.FirstDate != "" {
q.Set("MinStartDate", iq.FirstDate)
}
if iq.LastDate != "" {
q.Set("MaxEndDate", iq.LastDate)
}
if iq.Limit != "" {
q.Set("Limit", iq.Limit)
}
var filters []string
if iq.IsUnplayed {
filters = append(filters, "IsUnplayed")
}
if iq.IsPlayed {
filters = append(filters, "IsPlayed")
}
if iq.IsFavorite {
filters = append(filters, "IsFavorite")
}
if len(filters) > 0 {
q.Set("Filters", strings.Join(filters, ","))
}
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))
}
if iq.Lyrics != "" {
needle := textutil.Fold(iq.Lyrics)
kept := items[:0]
for _, it := range items {
if strings.Contains(textutil.Fold(it.Lyrics), needle) ||
strings.Contains(textutil.Fold(it.Overview), needle) {
kept = append(kept, it)
}
}
items = kept
}
return items, nil
}
// GetItem fetches a single item by ID with full field data (media sources,
// user data). Used when a session's NowPlayingItem lacks stream details.
func (c *Client) GetItem(ctx context.Context, userID, itemID string) (*BaseItemDto, error) {
if itemID == "" {
return nil, fmt.Errorf("the 'item_id' parameter cannot be empty")
}
q := url.Values{"Fields": {browseFields}, "EnableUserData": {"true"}}
var item BaseItemDto
if err := c.Get(ctx, "/Users/"+userID+"/Items/"+itemID, q, &item); err != nil {
return nil, err
}
return &item, nil
}
// toMediaItem maps a BaseItemDto to the output shape, extracting lyrics from
// text subtitle streams titled "lyrics" and formatting the run time.
func toMediaItem(it *BaseItemDto, withPath bool) MediaItem {
mi := MediaItem{
Title: it.Name,
Artists: orEmpty(it.Artists),
Album: it.Album,
AlbumID: it.AlbumID,
AlbumArtist: it.AlbumArtist,
Genres: orEmpty(it.Genres),
Overview: it.Overview,
MediaType: it.MediaType,
RunTime: ticksToHMS(it.RunTimeTicks),
ItemID: it.ID,
ItemType: it.Type,
SeriesName: it.SeriesName,
}
if withPath {
mi.FilePath = it.Path
}
if ud := it.UserData; ud != nil {
mi.Played = ud.Played
mi.PlayedPercentage = floatOrEmpty(ud.PlayedPercentage)
if ud.PlaybackPositionTicks > 0 {
mi.ResumePositionMilliseconds = ticksToMS(ud.PlaybackPositionTicks)
}
}
mi.DiskNumber = intOrEmpty(it.ParentIndexNumber)
mi.TrackNumber = intOrEmpty(it.IndexNumber)
mi.ProductionYear = intOrEmpty(it.ProductionYear)
mi.Bitrate = intOrEmpty(it.Bitrate)
if it.DateCreated != "" {
mi.CreationDate = it.DateCreated
}
if it.PremiereDate != "" {
mi.PremiereDate = it.PremiereDate
}
mi.Lyrics = extractLyrics(it.MediaSources)
return mi
}
// extractLyrics finds the first text subtitle stream titled "lyrics" and
// returns its ExtraData.
func extractLyrics(sources []MediaSource) string {
for _, ms := range sources {
for _, st := range ms.MediaStreams {
if st.IsTextSubtitleStream && strings.EqualFold(st.Title, "lyrics") {
return st.ExtraData
}
}
}
return ""
}
// ticksToHMS converts Emby ticks (100 ns) to "hh:mm:ss".
func ticksToHMS(ticks int64) string {
if ticks <= 0 {
return ""
}
total := ticks / 10_000_000
return fmt.Sprintf("%02d:%02d:%02d", total/3600, (total%3600)/60, total%60)
}
// ticksToMS converts Emby ticks to milliseconds.
func ticksToMS(ticks int64) int64 { return ticks / 10_000 }
// msToHMS converts milliseconds to "hh:mm:ss".
func msToHMS(ms int64) string {
total := ms / 1000
return fmt.Sprintf("%02d:%02d:%02d", total/3600, (total%3600)/60, total%60)
}
// floatOrEmpty mirrors intOrEmpty for *float64.
func floatOrEmpty(p *float64) any {
if p == nil || *p == 0 {
return ""
}
return *p
}
// intOrEmpty mirrors Python's `x if x else ""`: nil and 0 both yield "".
func intOrEmpty(p *int) any {
if p == nil || *p == 0 {
return ""
}
return *p
}
func orEmpty(s []string) []string {
if s == nil {
return []string{}
}
return s
}