418 lines
12 KiB
Go
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
|
|
}
|