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
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.
274 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|