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
+85 -6
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
@@ -60,7 +61,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Compatibility for APKs released before device naming. New clients require an
// editable name in their UI, but an older TV must still be able to sign in while
// the household rollout is in progress.
req.DeviceName = "Memby TV"
req.DeviceName = store.DefaultDeviceName
}
if len([]rune(req.DeviceName)) > 80 {
writeError(w, http.StatusBadRequest, "device name is too long")
@@ -68,12 +69,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
auth, err := s.emby.Authenticate(
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName,
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName, clientVersion(r),
)
if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong
// password could be reflected back.
s.log.Warn("emby authentication failed", "username", req.Username)
s.loggerFor(r.Context()).Warn("sign-in rejected",
"username", req.Username, "device", req.DeviceName, "device_id", req.DeviceID,
"reason", "emby refused the credentials",
)
writeError(w, http.StatusUnauthorized, "sign-in failed")
return
}
@@ -100,24 +104,41 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if sess.Username == "" {
sess.Username = req.Username
}
replacedHash, err := s.store.CreateSession(r.Context(), sess)
created, err := s.store.CreateSession(r.Context(), sess)
if err != nil {
_ = s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
ClientVersion: sess.ClientVersion,
})
s.log.Error("session persist failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start a session")
return
}
if len(replacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
if len(created.ReplacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(created.ReplacedHash)))
}
s.retireSupersededDevices(r.Context(), created.Superseded)
// Recorded after the session exists, so a build history can only describe a
// television that got as far as signing in.
if err := s.store.RecordDeviceVersion(r.Context(), sess.DeviceID, sess.ClientVersion); err != nil {
s.loggerFor(r.Context()).Warn("device version record failed",
"device_id", sess.DeviceID, "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
// Now that the session exists, the request line this call ends with can name it too.
identify(r.Context(), sess)
s.loggerFor(r.Context()).Info("signed in",
"emby_user", sess.EmbyUserID,
"device_id", sess.DeviceID,
"protocol", clientLogValue(sess.ClientProtocol),
"replaced_session", len(created.ReplacedHash) > 0,
)
writeJSON(w, http.StatusOK, loginResponse{
Token: token, UserID: sess.EmbyUserID, Username: sess.Username, ServerID: sess.ServerID,
})
@@ -129,6 +150,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.loggerFor(r.Context()).Info("signed out", "device_id", sess.DeviceID)
w.WriteHeader(http.StatusNoContent)
}
@@ -178,9 +200,63 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, curr
return
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(tokenHash)))
s.retireEmbyDevice(r.Context(), deviceID)
if err := s.store.DeleteDeviceVersions(r.Context(), deviceID); err != nil {
s.loggerFor(r.Context()).Warn("device version cleanup failed",
"removed_device_id", deviceID, "error", err)
}
// A device disappearing from a household is worth a line: the next thing that TV
// reports is a sign-in, and the two together explain each other.
s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID)
w.WriteHeader(http.StatusNoContent)
}
// retireSupersededDevices finishes what CreateSession started: the rows for a television
// under a device id it no longer uses are already gone from Postgres, and this takes the
// rest of that identity with them — the cached session, the build history and the record
// Emby is still holding in its own devices list.
//
// Best-effort throughout, and deliberately after the sign-in has succeeded: tidying up a
// set's previous life must never be what stops it getting in.
func (s *Server) retireSupersededDevices(ctx context.Context, devices []store.SupersededDevice) {
if len(devices) == 0 {
return
}
ids := make([]string, 0, len(devices))
for _, device := range devices {
ids = append(ids, device.DeviceID)
if s.cache != nil && len(device.TokenHash) > 0 {
_ = s.cache.Delete(ctx, cache.SessionKey(hexHash(device.TokenHash)))
}
s.retireEmbyDevice(ctx, device.DeviceID)
}
if err := s.store.DeleteDeviceVersions(ctx, ids...); err != nil {
s.loggerFor(ctx).Warn("device version cleanup failed", "error", err)
}
s.loggerFor(ctx).Info("device identity superseded", "retired_device_ids", ids)
}
// retireEmbyDevice deletes the Emby device record a removed television left behind.
//
// Revoking the gateway session only takes the TV out of Settings → Devices; Emby keeps
// its own record until the record itself is deleted, so without this a set removed from
// one list stays visible in the other. Deliberately best-effort: the session is already
// gone, which is what actually ends that TV's access, and a lingering Emby row is not
// worth failing the request the operator made. It needs the sync credentials because a
// device record belongs to Emby's server, not to the viewer whose session was removed.
func (s *Server) retireEmbyDevice(ctx context.Context, deviceID string) {
if s.emby == nil || s.cfg.SyncAPIKey == "" || deviceID == "" {
return
}
if err := s.emby.DeleteDevice(ctx, emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
}, deviceID); err != nil {
s.loggerFor(ctx).Warn("emby device cleanup failed",
"removed_device_id", deviceID, "error", err)
}
}
func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request, current store.Session) {
deviceID := strings.TrimSpace(r.PathValue("deviceID"))
var req renameDeviceRequest
@@ -205,5 +281,8 @@ func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request, curr
writeError(w, http.StatusInternalServerError, "could not rename device")
return
}
s.loggerFor(r.Context()).Info("device renamed",
"renamed_device_id", deviceID, "new_name", req.DeviceName,
)
w.WriteHeader(http.StatusNoContent)
}