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

283 lines
10 KiB
Go

package emby
import (
"context"
"fmt"
"net/url"
"strings"
)
// PlayerSession is the output shape for media player sessions.
// now_playing_* fields are omitted when the player is idle.
type PlayerSession struct {
ClientName string `json:"client_name"`
SessionID string `json:"session_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceIPAddress string `json:"device_ip_address"`
LocalToMediaServer bool `json:"local_to_media_server"`
MediaTypes []string `json:"media_types"`
NowPlayingTitle string `json:"now_playing_title,omitempty"`
NowPlayingArtists []string `json:"now_playing_artists,omitempty"`
NowPlayingAlbum string `json:"now_playing_album,omitempty"`
NowPlayingTrackNumber any `json:"now_playing_track_number,omitempty"`
NowPlayingDiskNumber any `json:"now_playing_disk_number,omitempty"`
NowPlayingItemID string `json:"now_playing_item_id,omitempty"`
NowPlayingTotalMilliseconds int64 `json:"now_playing_total_milliseconds,omitempty"`
NowPlayingTotalTime string `json:"now_playing_total_time,omitempty"`
NowPlayingPositionMilliseconds *int64 `json:"now_playing_position_milliseconds,omitempty"`
NowPlayingPositionTime string `json:"now_playing_position_time,omitempty"`
NowPlayingIsPaused *bool `json:"now_playing_is_paused,omitempty"`
}
// GetPlayerSessions lists controllable player sessions, optionally filtered
// by controlling user and media type.
func (c *Client) GetPlayerSessions(ctx context.Context, userID, mediaType string) ([]PlayerSession, error) {
q := url.Values{}
if userID != "" {
q.Set("ControllableByUserId", userID)
}
var sessions []SessionInfo
if err := c.Get(ctx, "/Sessions", q, &sessions); err != nil {
return nil, err
}
out := make([]PlayerSession, 0, len(sessions))
for _, s := range sessions {
if len(s.PlayableMediaTypes) == 0 {
continue
}
if mediaType != "" {
ok := false
for _, mt := range s.PlayableMediaTypes {
if strings.EqualFold(mt, mediaType) {
ok = true
break
}
}
if !ok {
continue
}
}
ps := PlayerSession{
ClientName: s.Client,
SessionID: s.ID,
DeviceID: s.DeviceID,
DeviceName: s.DeviceName,
DeviceIPAddress: s.RemoteEndPoint,
MediaTypes: s.PlayableMediaTypes,
LocalToMediaServer: s.RemoteEndPoint == "::1" || s.RemoteEndPoint == "127.0.0.1",
}
if np := s.NowPlayingItem; np != nil {
ps.NowPlayingTitle = np.Name
ps.NowPlayingArtists = np.Artists
ps.NowPlayingAlbum = np.Album
ps.NowPlayingTrackNumber = intOrEmpty(np.IndexNumber)
ps.NowPlayingDiskNumber = intOrEmpty(np.ParentIndexNumber)
ps.NowPlayingItemID = np.ID
ms := ticksToMS(np.RunTimeTicks)
ps.NowPlayingTotalMilliseconds = ms
ps.NowPlayingTotalTime = msToHMS(ms)
}
if st := s.PlayState; st != nil {
pos := ticksToMS(st.PositionTicks)
ps.NowPlayingPositionMilliseconds = &pos
ps.NowPlayingPositionTime = msToHMS(pos)
paused := st.IsPaused
ps.NowPlayingIsPaused = &paused
}
out = append(out, ps)
}
return out, nil
}
// PlayQueueItem is the output shape for play queue entries.
type PlayQueueItem 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"`
MediaType string `json:"media_type"`
Bitrate any `json:"bitrate"`
RunTime string `json:"run_time"`
ItemID string `json:"item_id"`
PlaylistItemID string `json:"playlist_item_id"`
}
// SessionUserID resolves the user that owns this client's Emby session by
// matching the client's DeviceID in GET /Sessions. It is used to identify the
// user behind a passed-through access token. Returns an error if no session
// matches or if matching sessions belong to different users.
func (c *Client) SessionUserID(ctx context.Context) (string, error) {
var sessions []SessionInfo
if err := c.Get(ctx, "/Sessions", nil, &sessions); err != nil {
return "", err
}
var id string
for _, s := range sessions {
if s.DeviceID != c.deviceID || s.UserID == "" {
continue
}
if id != "" && id != s.UserID {
return "", fmt.Errorf("token resolves to multiple users")
}
id = s.UserID
}
if id == "" {
return "", fmt.Errorf("no Emby session found for this token")
}
return id, nil
}
// GetPlayQueueItems returns the play queue of a session.
func (c *Client) GetPlayQueueItems(ctx context.Context, sessionID string) ([]PlayQueueItem, error) {
if sessionID == "" {
return nil, fmt.Errorf("the 'session_id' parameter cannot be empty")
}
var res QueryResult[BaseItemDto]
if err := c.Get(ctx, "/Sessions/PlayQueue", qp("Id", sessionID), &res); err != nil {
return nil, err
}
items := make([]PlayQueueItem, 0, len(res.Items))
for _, it := range res.Items {
mi := toMediaItem(&it, false)
items = append(items, PlayQueueItem{
Title: mi.Title,
Artists: mi.Artists,
Album: mi.Album,
AlbumID: mi.AlbumID,
AlbumArtist: mi.AlbumArtist,
DiskNumber: mi.DiskNumber,
TrackNumber: mi.TrackNumber,
CreationDate: mi.CreationDate,
PremiereDate: mi.PremiereDate,
ProductionYear: mi.ProductionYear,
Genres: mi.Genres,
Overview: mi.Overview,
MediaType: mi.MediaType,
Bitrate: mi.Bitrate,
RunTime: mi.RunTime,
ItemID: mi.ItemID,
PlaylistItemID: it.PlaylistItemID,
})
}
return items, nil
}
// GetSession fetches a single session by ID (GET /Sessions filtered
// client-side — the spec has no single-session endpoint). Returns nil when no
// session matches.
func (c *Client) GetSession(ctx context.Context, sessionID string) (*SessionInfo, error) {
if sessionID == "" {
return nil, fmt.Errorf("the 'session_id' parameter cannot be empty")
}
var sessions []SessionInfo
if err := c.Get(ctx, "/Sessions", nil, &sessions); err != nil {
return nil, err
}
for i := range sessions {
if sessions[i].ID == sessionID {
return &sessions[i], nil
}
}
return nil, nil
}
// SendGeneralCommand issues a GeneralCommand to a session via
// POST /Sessions/{Id}/Command (e.g. "SetSubtitleStreamIndex",
// "SetAudioStreamIndex").
func (c *Client) SendGeneralCommand(ctx context.Context, sessionID, name string, args map[string]string, userID string) error {
if sessionID == "" {
return fmt.Errorf("the 'session_id' parameter cannot be empty")
}
body := GeneralCommand{Name: name, ControllingUserID: userID, Arguments: args}
return c.Post(ctx, "/Sessions/"+sessionID+"/Command", nil, body, nil)
}
// PlayOptions carries optional stream/position overrides for PlayNow.
type PlayOptions struct {
SubtitleStreamIndex *int
AudioStreamIndex *int
StartPositionTicks *int64
}
// PlayNowWithOptions starts playback of itemIDs on a session with optional
// subtitle/audio stream selection and a start position (used to restart
// playback with different streams at the current position).
func (c *Client) PlayNowWithOptions(ctx context.Context, sessionID, itemIDs, userID string, opts PlayOptions) error {
if itemIDs == "" {
return fmt.Errorf("the 'item_ids' parameter is required for the 'PlayNow' command")
}
q := qp("ItemIds", itemIDs, "PlayCommand", "PlayNow")
body := playRequest{
PlayCommand: "PlayNow",
ControllingUserID: userID,
SubtitleStreamIndex: opts.SubtitleStreamIndex,
AudioStreamIndex: opts.AudioStreamIndex,
StartPositionTicks: opts.StartPositionTicks,
}
return c.Post(ctx, "/Sessions/"+sessionID+"/Playing", q, body, nil)
}
// SupportsCommand reports whether the session advertises the named general
// command (e.g. "SetSubtitleStreamIndex") in SupportedCommands.
func (s *SessionInfo) SupportsCommand(name string) bool {
for _, cmd := range s.SupportedCommands {
if strings.EqualFold(cmd, name) {
return true
}
}
return false
}
// playerCommands accepted via POST /Sessions/{id}/Playing/{command}.
var playerCommands = map[string]bool{
"Stop": true, "Pause": true, "Unpause": true, "NextTrack": true,
"PreviousTrack": true, "Seek": true, "Rewind": true, "FastForward": true,
"PlayPause": true, "SeekRelative": true,
}
// SendPlayerCommand sends a playback command to a session.
// command: PlayNow, Stop, Pause, Unpause, NextTrack, PreviousTrack, Seek,
// Rewind, FastForward, PlayPause, SeekRelative.
// itemIDs: comma-separated item ids (required for PlayNow).
// timeMS: position in ms for Seek/Rewind/FastForward/SeekRelative (0 → default 30s for the relative commands).
func (c *Client) SendPlayerCommand(ctx context.Context, sessionID, command, itemIDs, userID string, timeMS int64) error {
if command == "PlayNow" {
if itemIDs == "" {
return fmt.Errorf("the 'item_ids' parameter is required for the 'PlayNow' command")
}
q := qp("ItemIds", itemIDs, "PlayCommand", command)
body := playRequest{PlayCommand: command, ControllingUserID: userID}
return c.Post(ctx, "/Sessions/"+sessionID+"/Playing", q, body, nil)
}
if !playerCommands[command] {
return fmt.Errorf("unsupported command: %s. Valid commands are 'PlayNow', 'Stop', 'Pause', 'Unpause', 'NextTrack', 'PreviousTrack', 'Seek', 'Rewind', 'FastForward', 'PlayPause', 'SeekRelative'", command)
}
if userID == "" {
return fmt.Errorf("no user_id was supplied")
}
if timeMS == 0 && (command == "Rewind" || command == "FastForward" || command == "SeekRelative") {
timeMS = 30000
}
ticks := timeMS * 10000
// Emby's native Rewind/FastForward don't work on some players; use SeekRelative.
switch strings.ToLower(command) {
case "rewind":
command = "SeekRelative"
ticks = -ticks
case "fastforward":
command = "SeekRelative"
}
body := playstateRequest{Command: command, SeekPositionTicks: ticks, ControllingUserID: userID}
return c.Post(ctx, "/Sessions/"+sessionID+"/Playing/"+command, nil, body, nil)
}