// 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" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/buildinfo" ) type Client struct { baseURL string publicURL string clientName string // gatewayVersion labels the requests the gateway makes on its own behalf — the // library sync, the health probe, device cleanup — which belong to no television and // so have no app version to report. gatewayVersion string http *http.Client } // Credentials identify one signed-in Emby user. type Credentials struct { UserID string Token string DeviceID string DeviceName string // ClientVersion is the app version of the television this request is being made for, // as it reported in X-Memby-Version. Emby shows it beside the device, so a blank one // makes every set in the house look like the same build. ClientVersion string } type Device struct { ID string `json:"Id"` ReportedDeviceID string `json:"ReportedDeviceId"` } type DevicesResult struct { Items []Device `json:"Items"` } 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"` } 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"` Overview string `json:"Overview"` SeriesName string `json:"SeriesName"` RunTimeTicks int64 `json:"RunTimeTicks"` ParentIndexNumber int `json:"ParentIndexNumber"` IndexNumber int `json:"IndexNumber"` UserData struct { PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` } `json:"UserData"` } type PlaybackInfo struct { MediaSources []MediaSourceInfo `json:"MediaSources"` PlaySessionID string `json:"PlaySessionId"` } type MediaSourceInfo struct { ID string `json:"Id"` MediaStreams []MediaStream `json:"MediaStreams"` SupportsDirectPlay bool `json:"SupportsDirectPlay"` SupportsDirectStream bool `json:"SupportsDirectStream"` SupportsTranscoding bool `json:"SupportsTranscoding"` 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, gatewayVersion: buildinfo.Version(), http: &http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 20, IdleConnTimeout: 90 * time.Second, }, }, } } // Authenticate signs a television in. clientVersion is the app version that set // reported; it is what Emby stamps on the device record it creates here, so an empty one // leaves the entry claiming the gateway's own build. func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName, clientVersion 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, Credentials{DeviceID: deviceID, DeviceName: deviceName, ClientVersion: clientVersion}, 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 } // 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) } // DeleteDevice removes the persistent Emby device record whose client-supplied ID // matches reportedDeviceID. Logging out only revokes the access token; Emby deliberately // keeps the device in its dashboard until the record itself is deleted. func (c *Client) DeleteDevice( ctx context.Context, cred Credentials, reportedDeviceID string, ) error { req, err := c.newRequest(ctx, http.MethodGet, "/Devices", nil, cred, nil) if err != nil { return err } var devices DevicesResult if err := c.do(req, &devices); err != nil { return err } for _, device := range devices.Items { if device.ReportedDeviceID != reportedDeviceID || device.ID == "" { continue } params := url.Values{"Id": {device.ID}} req, err := c.newRequest(ctx, http.MethodDelete, "/Devices", params, cred, nil) if err != nil { return err } if err := c.do(req, nil); err != nil { return err } } return nil } // 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 } func (c *Client) PlaybackInfo( ctx context.Context, cred Credentials, itemID string, startTicks int64, subtitleStreamIndex *int, currentPlaySessionID string, forceTranscode bool, capabilities PlaybackCapabilities, ) (*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, "EnableDirectPlay": !forceTranscode, "EnableDirectStream": !forceTranscode, "EnableTranscoding": true, "AllowVideoStreamCopy": true, "AllowAudioStreamCopy": true, "DeviceProfile": androidTVDeviceProfile(capabilities), }) 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 forceTranscode { profile := requestBody["DeviceProfile"].(map[string]any) profile["DirectPlayProfiles"] = []map[string]string{} } 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 directPlayVideoCodecs(supportsHEVC bool) string { if supportsHEVC { return "h264,hevc" } return "h264" } 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) } // RefreshItem asks Emby to re-scan one item's files. // // It exists for the subtitle download: Bazarr writes the new .srt beside the media file // and Emby has no idea it is there until something makes it look. Metadata and images are // deliberately left alone (ReplaceAllMetadata=false, ImageRefreshMode=None) — the point is // to notice a new sidecar, not to re-fetch a title's artwork from the internet because // somebody wanted Italian subtitles. func (c *Client) RefreshItem(ctx context.Context, cred Credentials, itemID string) error { params := url.Values{ "Recursive": {"false"}, "MetadataRefreshMode": {"Default"}, "ImageRefreshMode": {"None"}, "ReplaceAllMetadata": {"false"}, "ReplaceAllImages": {"false"}, } req, err := c.newRequest( ctx, http.MethodPost, "/Items/"+url.PathEscape(itemID)+"/Refresh", params, cred, nil, ) if err != nil { return err } return c.do(req, nil) } // ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped". 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, "MediaSourceId": mediaSourceID, "PlaySessionId": playSessionID, "PositionTicks": positionTicks, "IsPaused": isPaused, "IsMuted": false, "CanSeek": true, "PlayMethod": playMethod, }) 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 } // TrickplayBytes reads part of a title's preview-thumbnail file (BIF). // // It is always a ranged read, because the whole file is megabytes and nothing here ever // wants all of it: callers take the index off the front, then one frame at a time. Emby // answers ranges on this route correctly but does not say so — the response advertises // "Accept-Ranges: none" and a Content-Length taken from the media file — so the request is // made on the strength of the 206 rather than on what the headers promise, and a server // that ignored the range would simply return the head of the file, which is the index a // caller wanted anyway. func (c *Client) TrickplayBytes( ctx context.Context, cred Credentials, itemID string, width int, from, to int64, ) ([]byte, error) { params := url.Values{} params.Set("Width", strconv.Itoa(width)) path := "/Videos/" + url.PathEscape(itemID) + "/index.bif" req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil) if err != nil { return nil, err } req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", from, to)) resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)} } // Cap the read at what was asked for. A 200 means the range was ignored and the whole // file is on its way, which must not become a multi-megabyte read on a seek. return io.ReadAll(io.LimitReader(resp.Body, to-from+1)) } // 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()) } // 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" } deviceName := strings.TrimSpace(cred.DeviceName) if deviceName == "" { deviceName = "Memby TV" } // The version Emby records is the television's app version, not a constant: every // device in the dashboard read as one build before this, so there was no way to tell // which set was behind. A request the gateway makes for itself reports its own build. version := strings.TrimSpace(cred.ClientVersion) if version == "" { version = c.gatewayVersion } req.Header.Set("Accept", "application/json") req.Header.Set("X-Emby-Authorization", fmt.Sprintf( `MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`, c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID, strings.ReplaceAll(version, `"`, ""), )) 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 }