Files
silo-server/internal/historyimport/emby_client.go
T

418 lines
12 KiB
Go

package historyimport
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"slices"
"strings"
"time"
)
type EmbyClient struct {
httpClient *http.Client
limiter *upstreamRateLimiter
}
func NewEmbyClient() *EmbyClient {
return &EmbyClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
limiter: sharedHistoryImportUpstreamLimiter,
}
}
type embyConnectAuthResponse struct {
ConnectAccessToken string
ConnectUserID string
ResponseKeys []string
}
type embyServerInfo struct {
Name string `json:"Name"`
SystemID string `json:"SystemId"`
URL string `json:"Url"`
LocalAddress string `json:"LocalAddress"`
AccessKey string `json:"AccessKey"`
}
type embyServerAuthResponse struct {
AccessToken string `json:"AccessToken"`
User struct {
ID string `json:"Id"`
} `json:"User"`
}
type embyExchangeResponse struct {
LocalUserID string `json:"LocalUserId"`
AccessToken string `json:"AccessToken"`
}
type embyItemsResponse struct {
Items []embyItem `json:"Items"`
}
type embyItem struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
ProductionYear int `json:"ProductionYear"`
RunTimeTicks int64 `json:"RunTimeTicks"`
SeriesName string `json:"SeriesName"`
SeriesID string `json:"SeriesId"`
ProviderIDs map[string]string `json:"ProviderIds"`
IndexNumber int `json:"IndexNumber"`
ParentIndexNumber int `json:"ParentIndexNumber"`
UserData struct {
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayCount int `json:"PlayCount"`
LastPlayedDate *time.Time `json:"LastPlayedDate"`
Played bool `json:"Played"`
} `json:"UserData"`
}
type embyUser struct {
ID string `json:"Id"`
}
type embyLocalAuth struct {
BaseURL string
UserID string
AccessToken string
}
func (c *EmbyClient) ConnectAuthenticate(ctx context.Context, username, password string) (*embyConnectAuthResponse, error) {
body, _ := json.Marshal(map[string]string{
"nameOrEmail": username,
"rawpw": password,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://connect.emby.media/service/user/authenticate", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Application", connectApplicationName)
var resp embyConnectAuthResponse
if err := c.doJSON(req, &resp); err != nil {
return nil, fmt.Errorf("authenticating with Emby Connect: %w", err)
}
if strings.TrimSpace(resp.ConnectUserID) == "" || strings.TrimSpace(resp.ConnectAccessToken) == "" {
return nil, fmt.Errorf(
"authenticating with Emby Connect: login succeeded but connect token payload was incomplete (keys=%s)",
strings.Join(resp.ResponseKeys, ","),
)
}
return &resp, nil
}
func (c *EmbyClient) ConnectServers(ctx context.Context, connectUserID, connectToken string) ([]ConnectServer, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://connect.emby.media/service/servers?userId="+url.QueryEscape(connectUserID),
nil,
)
if err != nil {
return nil, err
}
req.Header.Set("X-Application", connectApplicationName)
req.Header.Set("X-Connect-UserToken", connectToken)
var payload []embyServerInfo
if err := c.doJSON(req, &payload); err != nil {
return nil, fmt.Errorf("listing Emby Connect servers: %w", err)
}
servers := make([]ConnectServer, 0, len(payload))
for _, item := range payload {
serverURL := firstNonEmpty(item.URL, item.LocalAddress)
servers = append(servers, ConnectServer{
ID: firstNonEmpty(item.SystemID, item.Name),
Name: item.Name,
SystemID: item.SystemID,
URL: strings.TrimSpace(item.URL),
LocalAddress: strings.TrimSpace(item.LocalAddress),
AccessKey: item.AccessKey,
HasRemoteURL: strings.TrimSpace(item.URL) != "",
HasLocalURL: strings.TrimSpace(item.LocalAddress) != "",
})
if servers[len(servers)-1].ID == "" {
servers[len(servers)-1].ID = serverURL
}
}
return servers, nil
}
func (c *EmbyClient) ConnectExchange(ctx context.Context, baseURL, connectUserID, accessKey string) (*embyLocalAuth, error) {
path := "/Connect/Exchange?format=json&ConnectUserId=" + url.QueryEscape(connectUserID)
var lastErr error
for _, candidate := range baseCandidates(baseURL) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, candidate+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Emby-Token", accessKey)
req.Header.Set("X-Emby-Authorization", embyAuthorizationHeader("", accessKey))
var resp embyExchangeResponse
err = c.doJSON(req, &resp)
if err == nil {
return &embyLocalAuth{BaseURL: candidate, UserID: resp.LocalUserID, AccessToken: resp.AccessToken}, nil
}
if !shouldTryAnotherBase(err) {
return nil, fmt.Errorf("exchanging Emby Connect token: %w", err)
}
lastErr = err
}
if lastErr != nil {
return nil, fmt.Errorf("exchanging Emby Connect token: %w", lastErr)
}
return nil, fmt.Errorf("exchanging Emby Connect token: no reachable server URL")
}
func (c *EmbyClient) AuthenticateServerUser(ctx context.Context, baseURL, username, password string) (*embyLocalAuth, error) {
body, _ := json.Marshal(map[string]string{
"Username": username,
"Pw": password,
})
var lastErr error
for _, candidate := range baseCandidates(baseURL) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, candidate+"/Users/AuthenticateByName", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Emby-Authorization", embyAuthorizationHeader("", ""))
var resp embyServerAuthResponse
err = c.doJSON(req, &resp)
if err == nil {
return &embyLocalAuth{BaseURL: candidate, UserID: resp.User.ID, AccessToken: resp.AccessToken}, nil
}
if !shouldTryAnotherBase(err) {
return nil, fmt.Errorf("authenticating against Emby server: %w", err)
}
lastErr = err
}
if lastErr != nil {
return nil, fmt.Errorf("authenticating against Emby server: %w", lastErr)
}
return nil, fmt.Errorf("authenticating against Emby server: no reachable server URL")
}
func (c *EmbyClient) FetchItems(ctx context.Context, auth embyLocalAuth, filter string) ([]embyItem, error) {
query := url.Values{}
query.Set("Recursive", "true")
query.Set("EnableUserData", "true")
query.Set("Fields", "ProviderIds")
query.Set("IncludeItemTypes", "Movie,Episode")
query.Set("Filters", filter)
path := fmt.Sprintf("%s/Users/%s/Items?%s", auth.BaseURL, url.PathEscape(auth.UserID), query.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Emby-Token", auth.AccessToken)
req.Header.Set("X-Emby-Authorization", embyAuthorizationHeader(auth.UserID, auth.AccessToken))
var payload embyItemsResponse
if err := c.doJSON(req, &payload); err != nil {
return nil, fmt.Errorf("fetching Emby items with filter %s: %w", filter, err)
}
return payload.Items, nil
}
func (c *EmbyClient) FetchItemsByIDs(ctx context.Context, auth embyLocalAuth, ids []string, includeItemTypes string) ([]embyItem, error) {
if len(ids) == 0 {
return nil, nil
}
query := url.Values{}
query.Set("Recursive", "true")
query.Set("Fields", "ProviderIds")
query.Set("Ids", strings.Join(ids, ","))
if strings.TrimSpace(includeItemTypes) != "" {
query.Set("IncludeItemTypes", includeItemTypes)
}
path := fmt.Sprintf("%s/Users/%s/Items?%s", auth.BaseURL, url.PathEscape(auth.UserID), query.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Emby-Token", auth.AccessToken)
req.Header.Set("X-Emby-Authorization", embyAuthorizationHeader(auth.UserID, auth.AccessToken))
var payload embyItemsResponse
if err := c.doJSON(req, &payload); err != nil {
return nil, fmt.Errorf("fetching Emby items by ids: %w", err)
}
return payload.Items, nil
}
func (c *EmbyClient) doJSON(req *http.Request, out any) error {
if err := c.limiter.Wait(req.Context(), req.URL); err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return &embyHTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
if out == nil {
io.Copy(io.Discard, resp.Body) //nolint:errcheck
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
type embyHTTPError struct {
StatusCode int
Body string
}
func (e *embyHTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("emby http %d", e.StatusCode)
}
return fmt.Sprintf("emby http %d: %s", e.StatusCode, e.Body)
}
func UpstreamHTTPStatus(err error) int {
var embyErr *embyHTTPError
if errors.As(err, &embyErr) {
return embyErr.StatusCode
}
var plexErr *plexHTTPError
if errors.As(err, &plexErr) {
return plexErr.StatusCode
}
var jellyfinErr *jellyfinHTTPError
if errors.As(err, &jellyfinErr) {
return jellyfinErr.StatusCode
}
return 0
}
func shouldTryAnotherBase(err error) bool {
httpErr, ok := err.(*embyHTTPError)
if !ok {
return true
}
return httpErr.StatusCode == http.StatusNotFound
}
func embyAuthorizationHeader(userID, token string) string {
parts := []string{
`Client="Silo"`,
`Device="Silo"`,
`DeviceId="silo-history-import"`,
`Version="1.0.0"`,
}
if userID != "" {
parts = append(parts, fmt.Sprintf(`UserId="%s"`, userID))
}
if token != "" {
parts = append(parts, fmt.Sprintf(`Token="%s"`, token))
}
return "Emby " + strings.Join(parts, ", ")
}
func baseCandidates(raw string) []string {
trimmed := strings.TrimRight(strings.TrimSpace(raw), "/")
if trimmed == "" {
return nil
}
candidates := []string{trimmed}
if !strings.HasSuffix(strings.ToLower(trimmed), "/emby") {
candidates = append(candidates, trimmed+"/emby")
}
return candidates
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func (r *embyConnectAuthResponse) UnmarshalJSON(data []byte) error {
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
r.ResponseKeys = slices.Sorted(maps.Keys(payload))
var userID string
if nestedUser, ok := payload["User"].(map[string]any); ok {
userID = firstNonEmpty(
stringField(nestedUser, "Id"),
stringField(nestedUser, "ID"),
stringField(nestedUser, "id"),
stringField(nestedUser, "UserId"),
stringField(nestedUser, "userId"),
)
}
r.ConnectAccessToken = firstNonEmpty(
stringField(payload, "ConnectAccessToken"),
stringField(payload, "connectAccessToken"),
stringField(payload, "ConnectUserToken"),
stringField(payload, "connectUserToken"),
stringField(payload, "AccessToken"),
stringField(payload, "accessToken"),
)
r.ConnectUserID = firstNonEmpty(
stringField(payload, "ConnectUserId"),
stringField(payload, "ConnectUserID"),
stringField(payload, "connectUserId"),
stringField(payload, "UserId"),
stringField(payload, "userId"),
userID,
)
return nil
}
func stringField(payload map[string]any, key string) string {
value, ok := payload[key]
if !ok {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case float64:
return fmt.Sprintf("%.0f", typed)
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
// ListUsers returns all user accounts on the Emby server using an admin API token.
func (c *EmbyClient) ListUsers(ctx context.Context, baseURL, adminToken string) ([]ExternalUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/Users", nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Emby-Token", adminToken)
req.Header.Set("X-Emby-Authorization", embyAuthorizationHeader("", adminToken))
var users []struct {
ID string `json:"Id"`
Name string `json:"Name"`
}
if err := c.doJSON(req, &users); err != nil {
return nil, fmt.Errorf("listing Emby users: %w", err)
}
result := make([]ExternalUser, 0, len(users))
for _, u := range users {
if u.ID == "" {
continue
}
result = append(result, ExternalUser{ID: u.ID, Name: u.Name})
}
return result, nil
}