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:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user