0.2.65 - End Credits wiring / Gateway: 0.1.45 - Credits

This commit is contained in:
ponzischeme89
2026-08-15 21:11:43 +12:00
parent d5d47473a2
commit e528d04b43
19 changed files with 574 additions and 65 deletions
+57 -8
View File
@@ -24,6 +24,7 @@ import (
type Client struct {
baseURL string
publicURL string
mediaURL string
clientName string
// gatewayClientName identifies the requests the gateway makes on its own behalf, so
// Emby's device list separates a television from the server standing behind it. The
@@ -72,8 +73,17 @@ type ItemsResult struct {
type AuthResult struct {
User struct {
ID string `json:"Id"`
Name string `json:"Name"`
ID string `json:"Id"`
Name string `json:"Name"`
Policy struct {
// IsAdministrator is a pointer because "Emby says this person is not an
// administrator" and "Emby did not answer the question" are different facts
// that must be acted on differently: the first refuses the console, the second
// is worth asking again rather than locking an operator out of their own
// server. Emby returns the policy in the authentication response itself, so
// the ordinary path costs no extra request.
IsAdministrator *bool `json:"IsAdministrator"`
} `json:"Policy"`
} `json:"User"`
AccessToken string `json:"AccessToken"`
ServerID string `json:"ServerId"`
@@ -83,7 +93,8 @@ type User struct {
ID string `json:"Id"`
Name string `json:"Name"`
Policy struct {
IsDisabled bool `json:"IsDisabled"`
IsDisabled bool `json:"IsDisabled"`
IsAdministrator bool `json:"IsAdministrator"`
} `json:"Policy"`
}
@@ -174,6 +185,18 @@ func New(baseURL, publicURL, clientName, gatewayClientName string, timeout time.
}
}
// SetMediaURL names the address server-side media reads go to, which is only ever credits
// detection. Unset — or set to nothing — media reads use the same address as metadata.
//
// A setter rather than a constructor argument because it is one deployment's answer to one
// subsystem's problem, and threading it through four test call sites that will never use it
// would be paying for it everywhere to spend it in one place. Call it during start-up,
// before anything is serving: nothing here is synchronised, for the same reason the client's
// other addresses are not.
func (c *Client) SetMediaURL(mediaURL string) {
c.mediaURL = strings.TrimRight(strings.TrimSpace(mediaURL), "/")
}
// Authenticate signs a television in. cred carries the device the record is created for
// and that set's ClientVersion — Emby stamps it on the record, so an empty one leaves the
// entry claiming the gateway's own build. cred.Gateway marks a sign-in the gateway is
@@ -247,6 +270,22 @@ func (c *Client) DeleteDevice(
return nil
}
// UserByID reads one account with that account's own token. It exists for the admin gate
// and is asked only when an authentication response carried no policy at all, which is
// why it takes the credentials it does: the question is "who did I just authenticate",
// and an Emby user may always read themselves.
func (c *Client) UserByID(ctx context.Context, cred Credentials, userID string) (*User, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/Users/"+url.PathEscape(userID), nil, cred, nil)
if err != nil {
return nil, err
}
var user User
if err := c.do(req, &user); err != nil {
return nil, err
}
return &user, 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) {
@@ -593,19 +632,29 @@ func (c *Client) StreamURL(cred Credentials, itemID string) string {
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
}
// InternalStreamURL is the same file, addressed the way the *gateway* reaches Emby rather
// InternalStreamURL is the same file, addressed the way the *gateway* reads bytes rather
// than the way a television does.
//
// It exists for credits detection, which is the only thing that reads media bytes on the
// server side. Using StreamURL there would send a ranged read out to the public address and
// back in through the reverse proxy — the two containers are on the same network, and a scan
// has no business leaving it. Nothing here is ever handed to a client.
// server side, and it reads them as ranged requests over the tail of a file. Using StreamURL
// there would send every one of those out to the public address and back in through the
// reverse proxy — and where a proxy buffers the response, the ranged read stops being a
// ranged read and the whole economy of the scan is gone. Nothing here is ever handed to a
// client.
//
// mediaURL is preferred and baseURL is the fallback, because how the gateway reaches Emby
// for *metadata* is not evidence about the best path for megabytes: a deployment whose
// MEMBY_EMBY_URL is a public hostname is an ordinary one, not a misconfiguration.
func (c *Client) InternalStreamURL(cred Credentials, itemID string) string {
host := c.mediaURL
if host == "" {
host = c.baseURL
}
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.baseURL, url.PathEscape(itemID), params.Encode())
return fmt.Sprintf("%s/Videos/%s/stream?%s", host, url.PathEscape(itemID), params.Encode())
}
// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL.
@@ -0,0 +1,74 @@
package emby
import (
"strings"
"testing"
"time"
)
// Where the gateway reads media bytes from is the one address that is not interchangeable
// with the others, so all three are pinned against each other here: a television must never
// be handed the internal address, and a scan must never be sent out the public way when an
// internal one has been named.
func TestInternalStreamURLPrefersTheMediaAddress(t *testing.T) {
client := New(
"https://molise.bounceme.net", "https://molise.bounceme.net",
"MbyATV", "MbyGateway", time.Second,
)
client.SetMediaURL("http://10.0.0.2:8096")
stream := client.InternalStreamURL(Credentials{Token: "tok", DeviceID: "dev"}, "item-1")
if !strings.HasPrefix(stream, "http://10.0.0.2:8096/Videos/item-1/stream?") {
t.Fatalf("scan should read from the media address, got %q", stream)
}
}
// The fallback is what makes the setting optional. A deployment that never names a media
// address must behave exactly as it did before there was one, or adding the option would be
// a breaking change to every install that ignores it.
func TestInternalStreamURLFallsBackToTheGatewayAddress(t *testing.T) {
client := New("http://emby:8096", "https://public.example", "MbyATV", "MbyGateway", time.Second)
stream := client.InternalStreamURL(Credentials{Token: "tok", DeviceID: "dev"}, "item-1")
if !strings.HasPrefix(stream, "http://emby:8096/Videos/item-1/stream?") {
t.Fatalf("with no media address the scan should use the gateway address, got %q", stream)
}
}
// A blank or whitespace value is the same as unset. It arrives from an environment variable
// somebody left empty, which is the ordinary way to turn the setting off again, and treating
// it as an address would build "/Videos/…" and read from the gateway itself.
func TestBlankMediaURLIsTreatedAsUnset(t *testing.T) {
client := New("http://emby:8096", "https://public.example", "MbyATV", "MbyGateway", time.Second)
client.SetMediaURL(" ")
stream := client.InternalStreamURL(Credentials{Token: "tok", DeviceID: "dev"}, "item-1")
if !strings.HasPrefix(stream, "http://emby:8096/Videos/") {
t.Fatalf("blank media address should fall back, got %q", stream)
}
}
// A trailing slash is what an operator actually types, and it must not reach the URL as a
// double slash — some reverse proxies answer that with a redirect ffmpeg will not follow.
func TestMediaURLTolerantOfATrailingSlash(t *testing.T) {
client := New("http://emby:8096", "https://public.example", "MbyATV", "MbyGateway", time.Second)
client.SetMediaURL("http://10.0.0.2:8096/")
stream := client.InternalStreamURL(Credentials{Token: "tok", DeviceID: "dev"}, "item-1")
if !strings.HasPrefix(stream, "http://10.0.0.2:8096/Videos/") {
t.Fatalf("media address should lose its trailing slash, got %q", stream)
}
}
// The television's URL is the public address and is not affected by any of this. Nothing
// reachable only from the NAS may ever be handed to a set in the house.
func TestStreamURLForTelevisionsIgnoresTheMediaAddress(t *testing.T) {
client := New("http://emby:8096", "https://public.example", "MbyATV", "MbyGateway", time.Second)
client.SetMediaURL("http://10.0.0.2:8096")
stream := client.StreamURL(Credentials{Token: "tok", DeviceID: "dev"}, "item-1")
if !strings.HasPrefix(stream, "https://public.example/Videos/item-1/stream?") {
t.Fatalf("televisions must keep the public address, got %q", stream)
}
}