Big changes
This commit is contained in:
@@ -47,6 +47,14 @@ type AuthResult struct {
|
||||
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"`
|
||||
@@ -57,6 +65,35 @@ type Summary struct {
|
||||
} `json:"UserData"`
|
||||
}
|
||||
|
||||
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
|
||||
@@ -115,6 +152,20 @@ func (c *Client) Logout(ctx context.Context, cred Credentials) error {
|
||||
return c.do(req, 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)
|
||||
}
|
||||
@@ -167,6 +218,93 @@ func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields stri
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
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
|
||||
@@ -187,7 +325,13 @@ func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string,
|
||||
}
|
||||
|
||||
// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped".
|
||||
func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase string, itemID string, positionTicks int64, isPaused bool) error {
|
||||
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":
|
||||
@@ -202,12 +346,22 @@ func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase str
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"ItemId": itemID,
|
||||
"MediaSourceId": mediaSourceID,
|
||||
"PlaySessionId": playSessionID,
|
||||
"PositionTicks": positionTicks,
|
||||
"IsPaused": isPaused,
|
||||
"IsMuted": false,
|
||||
"CanSeek": true,
|
||||
"PlayMethod": "DirectPlay",
|
||||
"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
|
||||
}
|
||||
@@ -249,6 +403,50 @@ func (c *Client) StreamURL(cred Credentials, itemID string) string {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user