Files
ha-gronod-addons/emby-mcp/internal/emby/items.go
gronod c4095d0be7 feat: restore premiere_date on item results (1.0.15)
Emby PremiereDate is the first air date for episodes/series and the
release date for movies. Request it again in Fields, emit it as
YYYY-MM-DD on search/browse/queue items, and document the conversation
prompt so Assist can answer airdate questions.

Potential breaking change: item JSON gains premiere_date versus the
1.0.11 slim shape. HA function parameter YAML is unchanged; re-paste
the DOCS.md system prompt.
2026-09-23 14:04:30 +00:00

229 lines
6.8 KiB
Go

package emby
import (
"context"
"fmt"
"net/url"
"strings"
)
// MediaItem is the output shape for media items (browse/watch-state fields
// extended). Optional fields are omitted from the JSON when empty; the `any`
// fields emit an integer when set and are absent otherwise.
type MediaItem struct {
Title string `json:"title"`
ItemID string `json:"item_id"`
MediaType string `json:"media_type,omitempty"`
ItemType string `json:"item_type,omitempty"`
Artists []string `json:"artists,omitempty"`
Album string `json:"album,omitempty"`
AlbumID string `json:"album_id,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
SeriesName string `json:"series_name,omitempty"`
DiskNumber any `json:"disk_number,omitempty"`
TrackNumber any `json:"track_number,omitempty"`
ProductionYear any `json:"production_year,omitempty"`
PremiereDate string `json:"premiere_date,omitempty"`
Genres []string `json:"genres,omitempty"`
RunTime string `json:"run_time,omitempty"`
Played bool `json:"played"`
PlayedPercentage any `json:"played_percentage,omitempty"`
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
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,ProductionYear,PremiereDate,ParentIndexNumber,IndexNumber,SeriesName"
// GetItems queries Audio/Video items in a library (or all libraries when
// libraryID is empty).
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))
}
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": {itemExtraFields}, "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.
func toMediaItem(it *BaseItemDto) MediaItem {
mi := MediaItem{
Title: it.Name,
ItemID: it.ID,
MediaType: it.MediaType,
ItemType: it.Type,
Artists: it.Artists,
Album: it.Album,
AlbumID: it.AlbumID,
AlbumArtist: it.AlbumArtist,
SeriesName: it.SeriesName,
Genres: it.Genres,
RunTime: ticksToHMS(it.RunTimeTicks),
}
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.PremiereDate = formatISODate(it.PremiereDate)
return mi
}
// formatISODate keeps YYYY-MM-DD from an Emby date-time and drops empty values
// so premiere_date is omitted from JSON when metadata is missing.
func formatISODate(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if i := strings.IndexByte(s, 'T'); i > 0 {
return s[:i]
}
if len(s) >= 10 && s[4] == '-' && s[7] == '-' {
return s[:10]
}
return s
}
// 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 returns nil when unset so the field is omitted from JSON.
func floatOrEmpty(p *float64) any {
if p == nil || *p == 0 {
return nil
}
return *p
}
// intOrEmpty returns nil when unset so the field is omitted from JSON.
func intOrEmpty(p *int) any {
if p == nil || *p == 0 {
return nil
}
return *p
}