Files
memby/server/internal/emby/client.go
T

539 lines
16 KiB
Go
Raw Normal View History

// Package emby is a small client for the Emby REST API.
//
// Item payloads are deliberately carried as json.RawMessage and forwarded to the TV
// untouched: the Android client already models Emby's item shape, so passing it through
// verbatim means there is no second schema to keep in sync. Only the handful of fields
// the gateway itself reasons about (id, type, resume position) are ever unmarshalled.
package emby
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
baseURL string
publicURL string
clientName string
http *http.Client
}
// Credentials identify one signed-in Emby user.
type Credentials struct {
2026-07-27 21:06:51 +12:00
UserID string
Token string
DeviceID string
DeviceName string
}
type ItemsResult struct {
Items []json.RawMessage `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
}
type AuthResult struct {
User struct {
ID string `json:"Id"`
Name string `json:"Name"`
} `json:"User"`
AccessToken string `json:"AccessToken"`
ServerID string `json:"ServerId"`
}
2026-07-29 15:26:27 +12:00
type User struct {
ID string `json:"Id"`
Name string `json:"Name"`
Policy struct {
IsDisabled bool `json:"IsDisabled"`
} `json:"Policy"`
}
// Summary is the minimal view of an item the gateway needs for its own logic.
type Summary struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
UserData struct {
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
} `json:"UserData"`
}
2026-07-29 15:26:27 +12:00
type PlaybackInfo struct {
MediaSources []MediaSourceInfo `json:"MediaSources"`
PlaySessionID string `json:"PlaySessionId"`
}
type MediaSourceInfo struct {
ID string `json:"Id"`
MediaStreams []MediaStream `json:"MediaStreams"`
DirectStreamURL string `json:"DirectStreamUrl"`
TranscodingURL string `json:"TranscodingUrl"`
}
type MediaStream struct {
Index int `json:"Index"`
Type string `json:"Type"`
Codec string `json:"Codec"`
Title string `json:"Title"`
DisplayTitle string `json:"DisplayTitle"`
Language string `json:"Language"`
IsDefault bool `json:"IsDefault"`
IsForced bool `json:"IsForced"`
IsHearingImpaired bool `json:"IsHearingImpaired"`
IsExternal bool `json:"IsExternal"`
IsTextSubtitleStream bool `json:"IsTextSubtitleStream"`
SupportsExternalStream bool `json:"SupportsExternalStream"`
DeliveryURL string `json:"DeliveryUrl"`
DeliveryMethod string `json:"DeliveryMethod"`
}
// APIError carries an upstream Emby status code so handlers can mirror it.
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
clientName: clientName,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
2026-07-27 21:06:51 +12:00
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName string) (*AuthResult, error) {
body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
if err != nil {
return nil, err
}
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
2026-07-27 21:06:51 +12:00
Credentials{DeviceID: deviceID, DeviceName: deviceName}, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
var out AuthResult
if err := c.do(req, &out); err != nil {
return nil, err
}
if out.AccessToken == "" || out.User.ID == "" {
return nil, fmt.Errorf("emby: authentication returned no access token")
}
return &out, nil
}
2026-07-27 21:06:51 +12:00
// Logout retires a token created during authentication. The gateway uses this when a
// device is refused by policy so Emby is not left holding an orphaned session.
func (c *Client) Logout(ctx context.Context, cred Credentials) error {
req, err := c.newRequest(ctx, http.MethodPost, "/Sessions/Logout", nil, cred, nil)
if err != nil {
return err
}
return c.do(req, nil)
}
2026-07-29 15:26:27 +12:00
// Users returns the household accounts visible to an administrative/service token.
// It is used only by the background For You builder, never on a television request.
func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/Users", nil, cred, nil)
if err != nil {
return nil, err
}
var users []User
if err := c.do(req, &users); err != nil {
return nil, err
}
return users, nil
}
func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params)
}
func (c *Client) NextUp(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
params.Set("UserId", cred.UserID)
return c.items(ctx, cred, "/Shows/NextUp", params)
}
// Similar asks Emby which items resemble one the user already watched.
func (c *Client) Similar(ctx context.Context, cred Credentials, itemID string, params url.Values) (*ItemsResult, error) {
params.Set("UserId", cred.UserID)
return c.items(ctx, cred, "/Items/"+url.PathEscape(itemID)+"/Similar", params)
}
func (c *Client) Episodes(ctx context.Context, cred Credentials, seriesID string, params url.Values) (*ItemsResult, error) {
params.Set("UserId", cred.UserID)
return c.items(ctx, cred, "/Shows/"+url.PathEscape(seriesID)+"/Episodes", params)
}
// LocalTrailers returns the trailers Emby holds locally for an item.
func (c *Client) LocalTrailers(ctx context.Context, cred Credentials, itemID string) (*ItemsResult, error) {
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/LocalTrailers"
req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
if err != nil {
return nil, err
}
// This endpoint answers with a bare array rather than an Items envelope.
var items []json.RawMessage
if err := c.do(req, &items); err != nil {
return nil, err
}
return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil
}
func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields string) (json.RawMessage, error) {
params := url.Values{}
if fields != "" {
params.Set("Fields", fields)
}
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID)
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
if err != nil {
return nil, err
}
var raw json.RawMessage
if err := c.do(req, &raw); err != nil {
return nil, err
}
return raw, nil
}
2026-07-29 15:26:27 +12:00
func (c *Client) PlaybackInfo(
ctx context.Context,
cred Credentials,
itemID string,
startTicks int64,
subtitleStreamIndex *int,
currentPlaySessionID string,
) (*PlaybackInfo, error) {
params := url.Values{
"UserId": {cred.UserID},
"IsPlayback": {"true"},
}
body, err := json.Marshal(map[string]any{
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
"StartTimeTicks": startTicks,
"DeviceProfile": map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
"VideoCodec": "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
"AudioCodec": "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
"Type": "Video",
},
},
"TranscodingProfiles": []map[string]string{
{
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
},
"SubtitleProfiles": []map[string]string{
{"Format": "srt", "Method": "External"},
{"Format": "subrip", "Method": "External"},
{"Format": "ass", "Method": "External"},
{"Format": "ssa", "Method": "External"},
{"Format": "vtt", "Method": "External"},
{"Format": "webvtt", "Method": "External"},
{"Format": "mov_text", "Method": "External"},
{"Format": "tx3g", "Method": "External"},
{"Format": "pgs", "Method": "Encode"},
{"Format": "pgssub", "Method": "Encode"},
{"Format": "sup", "Method": "Encode"},
{"Format": "vobsub", "Method": "Encode"},
{"Format": "dvdsub", "Method": "Encode"},
},
},
})
var requestBody map[string]any
if err == nil {
err = json.Unmarshal(body, &requestBody)
}
if subtitleStreamIndex != nil {
requestBody["SubtitleStreamIndex"] = *subtitleStreamIndex
}
if currentPlaySessionID != "" {
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
}
if err == nil {
body, err = json.Marshal(requestBody)
}
if err != nil {
return nil, err
}
req, err := c.newRequest(
ctx,
http.MethodPost,
"/Items/"+url.PathEscape(itemID)+"/PlaybackInfo",
params,
cred,
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
var out PlaybackInfo
if err := c.do(req, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params)
}
// SetFavorite/SetPlayed return Emby's resulting UserData verbatim.
func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) {
method := http.MethodDelete
if favorite {
method = http.MethodPost
}
path := "/Users/" + url.PathEscape(cred.UserID) + "/FavoriteItems/" + url.PathEscape(itemID)
return c.userDataCall(ctx, method, path, cred)
}
func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string, played bool) (json.RawMessage, error) {
method := http.MethodDelete
if played {
method = http.MethodPost
}
path := "/Users/" + url.PathEscape(cred.UserID) + "/PlayedItems/" + url.PathEscape(itemID)
return c.userDataCall(ctx, method, path, cred)
}
// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped".
2026-07-29 15:26:27 +12:00
func (c *Client) ReportPlayback(
ctx context.Context,
cred Credentials,
phase, itemID, mediaSourceID, playSessionID, playMethod, eventName string,
positionTicks int64,
isPaused bool,
) error {
var path string
switch phase {
case "started":
path = "/Sessions/Playing"
case "progress":
path = "/Sessions/Playing/Progress"
case "stopped":
path = "/Sessions/Playing/Stopped"
default:
return fmt.Errorf("emby: unknown playback phase %q", phase)
}
body, err := json.Marshal(map[string]any{
"ItemId": itemID,
2026-07-29 15:26:27 +12:00
"MediaSourceId": mediaSourceID,
"PlaySessionId": playSessionID,
"PositionTicks": positionTicks,
"IsPaused": isPaused,
"IsMuted": false,
"CanSeek": true,
2026-07-29 15:26:27 +12:00
"PlayMethod": playMethod,
})
2026-07-29 15:26:27 +12:00
if phase == "progress" {
var fields map[string]any
if err := json.Unmarshal(body, &fields); err != nil {
return err
}
fields["EventName"] = eventName
body, err = json.Marshal(fields)
}
if err != nil {
return err
}
req, err := c.newRequest(ctx, http.MethodPost, path, nil, cred, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
return c.do(req, nil)
}
// ImageResponse streams an image straight from Emby so the caller can copy it to the TV.
// The caller owns closing the body.
func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, imageType string, params url.Values) (*http.Response, error) {
path := "/Items/" + url.PathEscape(itemID) + "/Images/" + url.PathEscape(imageType)
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
resp.Body.Close()
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
}
return resp, nil
}
// StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby
// address: video never flows through the gateway, only metadata does.
func (c *Client) StreamURL(cred Credentials, itemID string) string {
params := url.Values{}
params.Set("static", "true")
params.Set("api_key", cred.Token)
params.Set("DeviceId", cred.DeviceID)
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
}
2026-07-29 15:26:27 +12:00
// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL.
func (c *Client) DeliveryURL(cred Credentials, delivery string) string {
delivery = strings.TrimSpace(delivery)
if delivery == "" {
return ""
}
var resolved string
if parsed, err := url.Parse(delivery); err == nil && parsed.IsAbs() {
resolved = parsed.String()
} else {
resolved = c.publicURL + "/" + strings.TrimLeft(delivery, "/")
}
parsed, err := url.Parse(resolved)
if err != nil {
return resolved
}
query := parsed.Query()
if query.Get("api_key") == "" {
query.Set("api_key", cred.Token)
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
// SubtitleURL uses Emby's stable subtitle download route rather than the optional
// MediaStream.DeliveryUrl. VTT normalizes every text subtitle codec before it reaches
// the TV.
func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, index int, extensions ...string) string {
if mediaSourceID == "" {
mediaSourceID = itemID
}
extension := "vtt"
if len(extensions) > 0 && strings.TrimSpace(extensions[0]) != "" {
extension = extensions[0]
}
path := fmt.Sprintf(
"/Videos/%s/%s/Subtitles/%d/Stream.%s",
url.PathEscape(itemID),
url.PathEscape(mediaSourceID),
index, url.PathEscape(extension),
)
return c.DeliveryURL(cred, path)
}
// Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error {
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil)
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) items(ctx context.Context, cred Credentials, path string, params url.Values) (*ItemsResult, error) {
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
if err != nil {
return nil, err
}
var out ItemsResult
if err := c.do(req, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) userDataCall(ctx context.Context, method, path string, cred Credentials) (json.RawMessage, error) {
req, err := c.newRequest(ctx, method, path, nil, cred, nil)
if err != nil {
return nil, err
}
var raw json.RawMessage
if err := c.do(req, &raw); err != nil {
return nil, err
}
return raw, nil
}
func (c *Client) newRequest(ctx context.Context, method, path string, params url.Values, cred Credentials, body io.Reader) (*http.Request, error) {
full := c.baseURL + path
if len(params) > 0 {
full += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, full, body)
if err != nil {
return nil, err
}
deviceID := cred.DeviceID
if deviceID == "" {
deviceID = "memby-gateway"
}
2026-07-27 21:06:51 +12:00
deviceName := strings.TrimSpace(cred.DeviceName)
if deviceName == "" {
deviceName = "Memby TV"
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
2026-07-27 21:06:51 +12:00
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="1.0"`,
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
))
if cred.Token != "" {
req.Header.Set("X-Emby-Token", cred.Token)
}
return req, nil
}
// do executes a request and decodes into out (which may be nil to discard the body).
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
// Emby error bodies can echo request details; cap what we keep and never log it
// alongside a token.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return &APIError{StatusCode: resp.StatusCode, Body: string(body)}
}
if out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// Summarise unmarshals the fields the gateway reasons about from a raw item.
func Summarise(raw json.RawMessage) (Summary, error) {
var s Summary
err := json.Unmarshal(raw, &s)
return s, err
}