App v0.2.26 and gateway 0.1.20

Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+54 -7
View File
@@ -16,13 +16,19 @@ import (
"net/url"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/buildinfo"
)
type Client struct {
baseURL string
publicURL string
clientName string
http *http.Client
// 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.
@@ -31,6 +37,10 @@ type Credentials struct {
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 {
@@ -123,9 +133,10 @@ func (e *APIError) Error() string {
func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
clientName: clientName,
baseURL: strings.TrimRight(baseURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
clientName: clientName,
gatewayVersion: buildinfo.Version(),
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
@@ -137,13 +148,17 @@ func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
}
}
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName string) (*AuthResult, error) {
// 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}, bytes.NewReader(body))
Credentials{DeviceID: deviceID, DeviceName: deviceName, ClientVersion: clientVersion},
bytes.NewReader(body))
if err != nil {
return nil, err
}
@@ -357,6 +372,30 @@ func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string,
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,
@@ -531,10 +570,18 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
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="1.0"`,
`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)