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
+47 -13
View File
@@ -19,6 +19,11 @@ const (
installerSessionTTL = 30 * time.Minute
installerDeviceID = "memby-web-installer"
installerDeviceName = "Memby Web Installer"
// installerRenewWithin is how close to expiry a session must be before an operator's
// own request re-issues it. Half the TTL, so a cookie is rewritten at most once every
// fifteen minutes rather than on every request of a working session.
installerRenewWithin = installerSessionTTL / 2
)
func (s *Server) installerSecret() []byte {
@@ -50,29 +55,58 @@ func (s *Server) newInstallerSession() (string, error) {
base64.RawURLEncoding.EncodeToString(signature), nil
}
func (s *Server) validInstallerSession(r *http.Request) bool {
// installerSessionExpiry reports when the request's session runs out. A cookie that is
// missing, malformed, forged or already expired is reported the same way: no session.
func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
if len(s.installerSecret()) == 0 {
return false
return time.Time{}, false
}
cookie, err := r.Cookie(installerCookieName)
if err != nil {
return false
return time.Time{}, false
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 2 {
return false
return time.Time{}, false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || len(payload) != 24 {
return false
return time.Time{}, false
}
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) {
return false
return time.Time{}, false
}
expires := int64(binary.BigEndian.Uint64(payload[:8]))
now := time.Now().Unix()
return expires > now && expires <= now+int64(installerSessionTTL/time.Second)+60
if expires <= now || expires > now+int64(installerSessionTTL/time.Second)+60 {
return time.Time{}, false
}
return time.Unix(expires, 0), true
}
func (s *Server) validInstallerSession(r *http.Request) bool {
_, ok := s.installerSessionExpiry(r)
return ok
}
// renewInstallerSession slides a valid session's expiry forward. The TTL was absolute and
// nothing extended it, so an operator working the admin console was signed out from under
// themselves after thirty minutes and the page's poll became a permanent "invalid admin
// token" banner with no sign-in to return to. Callers must only reach here for a request
// an operator actually made — see operatorPresent — or an abandoned tab's own polling
// would keep the session alive indefinitely, which is what the TTL exists to stop.
func (s *Server) renewInstallerSession(w http.ResponseWriter, r *http.Request) {
expires, ok := s.installerSessionExpiry(r)
if !ok || time.Until(expires) > installerRenewWithin {
return
}
session, err := s.newInstallerSession()
if err != nil {
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
return
}
s.setInstallerCookie(w, session)
}
func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) {
@@ -132,7 +166,7 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
// Password authentication necessarily registers a device with Emby. Do not start
// it unless the service credential needed to remove that temporary record exists.
if s.cfg.SyncAPIKey == "" {
s.log.Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured")
s.loggerFor(r.Context()).Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured")
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
http.StatusServiceUnavailable, "/install")
return
@@ -151,10 +185,10 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
}
auth, err := s.emby.Authenticate(
r.Context(), username, password, installerDeviceID, installerDeviceName,
r.Context(), username, password, installerDeviceID, installerDeviceName, "",
)
if err != nil {
s.log.Warn("installer Emby authentication failed", "username", username)
s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username)
s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next)
return
}
@@ -164,13 +198,13 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: installerDeviceID, DeviceName: installerDeviceName,
}); err != nil {
s.log.Warn("installer Emby session cleanup failed", "error", err)
s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err)
}
if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
}, installerDeviceID); err != nil {
s.log.Error("installer Emby device cleanup failed", "error", err)
s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err)
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
http.StatusBadGateway, next)
return
@@ -178,7 +212,7 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
session, err := s.newInstallerSession()
if err != nil {
s.log.Error("installer session generation failed", "error", err)
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start installer session")
return
}