Files
gronod d40646211f
Build and publish / Test and build (darwin) (push) Failing after 1m42s
Build and publish / Test and build (linux) (push) Failing after 1m53s
Build and publish / Test and build (windows) (push) Failing after 2m50s
Build and publish / Build and publish Docker image (push) Skipped
Initial Go implementation of Emby.MCP
Go rewrite of the Python Emby.MCP server with tool parity (20 tools),
plus a streamable-HTTP transport with per-request Emby authentication
(Basic / Bearer token passthrough), a multi-arch Docker build, and a
Gitea CI workflow.
2026-09-20 22:52:46 +01:00

274 lines
9.3 KiB
Go

package emby
import (
"context"
"fmt"
"strings"
)
// PlaylistUserAccess is one entry of a playlist's user_access list.
type PlaylistUserAccess struct {
UserName string `json:"user_name"`
UserID string `json:"user_id"`
AccessLevel string `json:"access_level"`
}
// Playlist is the output shape for playlists (matches Python output keys).
type Playlist struct {
Name string `json:"name"`
Overview string `json:"overview"`
Genres []string `json:"genres"`
DateCreated string `json:"date_created"`
RunTime string `json:"run_time"`
CanShare bool `json:"can_share"`
UserAccess []PlaylistUserAccess `json:"user_access"`
MediaType string `json:"media_type"`
PlaylistID string `json:"playlist_id"`
}
// PlaylistItem is the output shape for playlist entries.
type PlaylistItem 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"`
Bitrate any `json:"bitrate"`
ItemID string `json:"item_id"`
PlaylistItemNumber string `json:"playlist_item_number"`
PlaylistItemIndex string `json:"playlist_item_index"`
RunTime string `json:"run_time"`
}
// playlistsLibraryID finds the first library of type "playlists".
func playlistsLibraryID(libs []Library) string {
for _, l := range libs {
if l.Type == "playlists" {
return l.ID
}
}
return ""
}
// GetPlaylists returns playlists from the first "playlists" library,
// optionally restricted to a single playlistID.
func (c *Client) GetPlaylists(ctx context.Context, userID string, libs []Library, playlistID string) ([]Playlist, error) {
libID := playlistsLibraryID(libs)
if libID == "" {
return nil, fmt.Errorf("no playlist libraries are available")
}
q := qp("ParentId", libID,
"Recursive", "true",
"Fields", "Genres,MediaSources,DateCreated,Overview,ProductionYear,PremiereDate,ParentId")
if playlistID != "" {
q.Set("Ids", playlistID)
}
var res QueryResult[BaseItemDto]
if err := c.Get(ctx, "/Users/"+userID+"/Items", q, &res); err != nil {
return nil, err
}
pls := make([]Playlist, 0, len(res.Items))
for _, it := range res.Items {
if !strings.EqualFold(it.Type, "playlist") {
continue
}
pl := Playlist{
Name: it.Name,
Overview: it.Overview,
Genres: orEmpty(it.Genres),
RunTime: ticksToHMS(it.RunTimeTicks),
UserAccess: []PlaylistUserAccess{},
MediaType: it.Type,
PlaylistID: it.ID,
}
if it.DateCreated != "" {
pl.DateCreated = it.DateCreated
}
// Per-user access levels; errors are ignored (often just not owner).
var access QueryResult[UserItemAccessEntry]
if err := c.Get(ctx, "/Users/ItemAccess", qp("ItemId", it.ID), &access); err == nil {
for _, a := range access.Items {
pl.UserAccess = append(pl.UserAccess, PlaylistUserAccess{
UserName: a.Name,
UserID: a.ID,
AccessLevel: a.UserItemShareLevel,
})
if a.ID == userID && (a.UserItemShareLevel == "Manage" || a.UserItemShareLevel == "ManageDelete") {
pl.CanShare = true
}
}
}
pls = append(pls, pl)
}
return pls, nil
}
// GetPlaylistItems returns the Audio/Video items on a playlist.
func (c *Client) GetPlaylistItems(ctx context.Context, userID, playlistID string) ([]PlaylistItem, error) {
q := qp("UserId", userID, "Fields", "Genres,MediaStreams,DateCreated,Overview")
var res QueryResult[BaseItemDto]
if err := c.Get(ctx, "/Playlists/"+playlistID+"/Items", q, &res); err != nil {
return nil, err
}
items := make([]PlaylistItem, 0, len(res.Items))
idx := 0
for _, it := range res.Items {
if !strings.EqualFold(it.MediaType, "audio") && !strings.EqualFold(it.MediaType, "video") {
continue
}
mi := toMediaItem(&it, false)
items = append(items, PlaylistItem{
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,
Lyrics: mi.Lyrics,
MediaType: mi.MediaType,
Bitrate: mi.Bitrate,
ItemID: mi.ItemID,
PlaylistItemNumber: it.PlaylistItemID,
PlaylistItemIndex: fmt.Sprintf("%d", idx),
RunTime: mi.RunTime,
})
idx++
}
return items, nil
}
// NewPlaylist creates a playlist; returns its ID. If overview is non-empty the
// playlist's metadata is updated afterwards.
func (c *Client) NewPlaylist(ctx context.Context, userID string, libs []Library, name, mediaType, overview string) (string, error) {
if strings.TrimSpace(name) == "" {
return "", fmt.Errorf("playlist name cannot be empty")
}
// Reject duplicate names (case-insensitive).
existing, err := c.GetPlaylists(ctx, userID, libs, "")
if err == nil {
for _, p := range existing {
if strings.EqualFold(p.Name, name) {
return "", fmt.Errorf("playlist with name %q already exists", name)
}
}
}
if mediaType == "" {
mediaType = "Audio"
}
var res playlistCreateResult
if err := c.Post(ctx, "/Playlists", qp("Name", name, "MediaType", mediaType), nil, &res); err != nil {
return "", err
}
if res.ID == "" {
return "", fmt.Errorf("failed to create playlist")
}
if overview != "" {
if err := c.updateItemMeta(ctx, userID, res.ID, "", overview); err != nil {
return "", err
}
}
return res.ID, nil
}
// SetPlaylistMeta updates a playlist's name and/or overview.
func (c *Client) SetPlaylistMeta(ctx context.Context, userID string, libs []Library, playlistID, name, overview string) error {
if name == "" && overview == "" {
return fmt.Errorf("no changes specified. Please provide at least one of: name, overview")
}
if name != "" {
existing, err := c.GetPlaylists(ctx, userID, libs, "")
if err == nil {
for _, p := range existing {
if strings.EqualFold(p.Name, name) && p.PlaylistID != playlistID {
return fmt.Errorf("playlist with name %q already exists", name)
}
}
}
}
return c.updateItemMeta(ctx, userID, playlistID, name, overview)
}
// updateItemMeta fetches the full item, mutates Name/Overview, and posts it
// back. The item is handled as map[string]any so the round-trip is lossless.
func (c *Client) updateItemMeta(ctx context.Context, userID, itemID, name, overview string) error {
var item map[string]any
if err := c.Get(ctx, "/Users/"+userID+"/Items/"+itemID, nil, &item); err != nil {
return err
}
if item == nil {
return fmt.Errorf("playlist with ID %s not found", itemID)
}
if name != "" {
item["Name"] = name
}
if overview != "" {
item["Overview"] = overview
}
return c.Post(ctx, "/Items/"+itemID, nil, item, nil)
}
// AddPlaylistItems appends comma-separated item IDs to a playlist;
// returns the count added.
func (c *Client) AddPlaylistItems(ctx context.Context, userID, playlistID, itemIDs string) (int, error) {
var res playlistAddResult
if err := c.Post(ctx, "/Playlists/"+playlistID+"/Items",
qp("Ids", itemIDs, "UserId", userID), nil, &res); err != nil {
return 0, err
}
if res.ItemAddedCount <= 0 {
return 0, fmt.Errorf("failed to add item(s) to playlist")
}
return res.ItemAddedCount, nil
}
// DeletePlaylistItems removes comma-separated playlist item numbers.
func (c *Client) DeletePlaylistItems(ctx context.Context, playlistID, entryIDs string) error {
return c.Post(ctx, "/Playlists/"+playlistID+"/Items/Delete",
qp("EntryIds", entryIDs), nil, nil)
}
// MovePlaylistItem moves one playlist item to a new zero-based index.
func (c *Client) MovePlaylistItem(ctx context.Context, playlistID, itemNumber, newIndex string) error {
return c.Post(ctx, "/Playlists/"+playlistID+"/Items/"+itemNumber+"/Move/"+newIndex,
nil, nil, nil)
}
// SetPlaylistSharing changes sharing: "Public", "Private", or "Shared"
// (Shared requires userIDs and itemAccess).
func (c *Client) SetPlaylistSharing(ctx context.Context, playlistID, shareType string, userIDs []string, itemAccess string) error {
switch strings.ToLower(shareType) {
case "public":
return c.Post(ctx, "/Items/"+playlistID+"/MakePublic", nil, nil, nil)
case "private":
return c.Post(ctx, "/Items/"+playlistID+"/MakePrivate", nil, nil, nil)
case "shared":
if len(userIDs) == 0 || itemAccess == "" {
return fmt.Errorf("share_type %s requires parameters 'user_ids' and 'item_access'", shareType)
}
body := updateUserItemAccess{
ItemIDs: []string{playlistID},
UserIDs: userIDs,
ItemAccess: itemAccess,
}
return c.Post(ctx, "/Items/Access", nil, body, nil)
default:
return fmt.Errorf("invalid share_type: %s. Must be one of: Public, Private, Shared", shareType)
}
}