This commit is contained in:
ponzischeme89
2026-08-16 12:13:51 +12:00
parent bd3732fba5
commit b9374baaf1
47 changed files with 2793 additions and 364 deletions
+7
View File
@@ -102,6 +102,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/tasks", s.adminAuth(s.handleAdminTasks))
mux.Handle("POST /admin/api/tasks/{taskID}/run", s.adminAuth(s.handleAdminRunTask))
mux.Handle("PUT /admin/api/tasks/{taskID}", s.adminAuth(s.handleAdminTaskSettings))
mux.Handle("GET /admin/api/credits", s.adminAuth(s.handleAdminCredits))
mux.Handle("PUT /admin/api/credits", s.adminAuth(s.handleAdminCredits))
// An unmatched API path is a 404, stated rather than left to the catch-all below —
// otherwise a mistyped or removed route would answer with the console's HTML shell,
@@ -229,6 +231,7 @@ type adminStatus struct {
// ServerVersion is what the page's footer reports. An operator reading the live log
// needs to know which build wrote it, and the page is the one place that is asked.
ServerVersion string `json:"serverVersion"`
CurrentUser string `json:"currentUser,omitempty"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
@@ -293,6 +296,10 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, adminStatus{
ServerVersion: buildinfo.Version(),
CurrentUser: func() string {
_, username, _ := s.browserSession(r, adminSessionPurpose)
return username
}(),
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
+4
View File
@@ -153,6 +153,10 @@ func TestAdministratorSignInMintsAnAdminSession(t *testing.T) {
if !s.validAdminSession(req) {
t.Fatal("an administrator's session was not accepted by the console")
}
_, username, ok := s.browserSession(req, adminSessionPurpose)
if !ok || username != "Viewer" {
t.Fatalf("admin session identity = %q, valid %t; want Viewer", username, ok)
}
}
// An Emby that returns no policy at all is a question, not a refusal — reading its silence
+141
View File
@@ -0,0 +1,141 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/credits"
"github.com/ponzischeme89/memby/server/internal/store"
)
type adminCreditsSettings struct {
CandidateLimit int `json:"candidateLimit"`
PrefetchEpisodes int `json:"prefetchEpisodes"`
MaxPrefetch int `json:"maxPrefetch"`
RetryHours int `json:"retryHours"`
}
type adminCreditsCandidate struct {
ItemID string `json:"itemId"`
SeriesID string `json:"seriesId"`
Season int `json:"season"`
Episode int `json:"episode"`
Priority int `json:"priority"`
Reason string `json:"reason"`
UserCount int `json:"userCount"`
LastViewed time.Time `json:"lastViewed"`
}
type adminCreditsResponse struct {
Enabled bool `json:"enabled"`
Settings adminCreditsSettings `json:"settings"`
QueueDepth int `json:"queueDepth"`
Pending []adminCreditsCandidate `json:"pending"`
History []store.CreditsScanHistoryRow `json:"history"`
}
func creditsAdminSettings(cfg credits.Config) adminCreditsSettings {
cfg = credits.NormaliseConfig(cfg)
return adminCreditsSettings{
CandidateLimit: cfg.QueueLimit, PrefetchEpisodes: cfg.PrefetchEpisodes,
MaxPrefetch: cfg.MaxPrefetchEpisodes,
RetryHours: int(cfg.RetryCooldown / time.Hour),
}
}
func (s *Server) defaultCreditsConfig() credits.Config {
defaults := credits.DefaultConfig()
defaults.PrefetchEpisodes = s.cfg.CreditsPrefetchEpisodes
defaults.MaxPrefetchEpisodes = s.cfg.CreditsMaxPrefetch
defaults.QueueLimit = s.cfg.CreditsQueueLimit
return credits.NormaliseConfig(defaults)
}
func (s *Server) handleAdminCredits(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
s.handleAdminCreditsSettings(w, r)
return
}
s.writeAdminCredits(w, r)
}
func (s *Server) writeAdminCredits(w http.ResponseWriter, r *http.Request) {
cfg := s.defaultCreditsConfig()
pending := []adminCreditsCandidate{}
queueDepth := 0
if s.credits != nil {
cfg = s.credits.Configuration()
queueDepth = s.credits.QueueDepth()
for _, candidate := range s.credits.Pending() {
pending = append(pending, adminCreditsCandidate{
ItemID: candidate.ItemID, SeriesID: candidate.SeriesID,
Season: candidate.Season, Episode: candidate.Episode,
Priority: candidate.Priority, Reason: candidate.Reason,
UserCount: candidate.UserCount, LastViewed: candidate.LastViewed,
})
}
} else if saved, found, err := s.store.CreditsSettings(r.Context()); err == nil && found {
cfg.PrefetchEpisodes = saved.PrefetchEpisodes
cfg.MaxPrefetchEpisodes = saved.MaxPrefetch
cfg.QueueLimit = saved.CandidateLimit
cfg.RetryCooldown = time.Duration(saved.RetryHours) * time.Hour
cfg = credits.NormaliseConfig(cfg)
}
history, err := s.store.CreditsScanHistory(
r.Context(), queryInt(r, "limit", 100, 500),
)
if err != nil {
s.loggerFor(r.Context()).Warn("credits scan history read failed", "error", err)
history = []store.CreditsScanHistoryRow{}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, adminCreditsResponse{
Enabled: s.credits != nil, Settings: creditsAdminSettings(cfg),
QueueDepth: queueDepth, Pending: pending, History: history,
})
}
func (s *Server) handleAdminCreditsSettings(w http.ResponseWriter, r *http.Request) {
var req adminCreditsSettings
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if req.CandidateLimit < 1 || req.CandidateLimit > 100 {
writeError(w, http.StatusBadRequest, "candidate limit must be between 1 and 100")
return
}
if req.PrefetchEpisodes < 1 || req.PrefetchEpisodes > 10 {
writeError(w, http.StatusBadRequest, "ordinary look-ahead must be between 1 and 10 episodes")
return
}
if req.MaxPrefetch < req.PrefetchEpisodes || req.MaxPrefetch > 20 {
writeError(w, http.StatusBadRequest, "maximum look-ahead must be at least the ordinary look-ahead and no more than 20")
return
}
if req.RetryHours < 0 || req.RetryHours > 24*30 {
writeError(w, http.StatusBadRequest, "retry delay must be between 0 and 720 hours")
return
}
settings := store.CreditsSettings{
CandidateLimit: req.CandidateLimit, PrefetchEpisodes: req.PrefetchEpisodes,
MaxPrefetch: req.MaxPrefetch, RetryHours: req.RetryHours,
}
if err := s.store.SetCreditsSettings(r.Context(), settings); err != nil {
writeError(w, http.StatusInternalServerError, "could not save credits settings")
return
}
if s.credits != nil {
cfg := s.credits.Configuration()
cfg.QueueLimit = req.CandidateLimit
cfg.PrefetchEpisodes = req.PrefetchEpisodes
cfg.MaxPrefetchEpisodes = req.MaxPrefetch
cfg.RetryCooldown = time.Duration(req.RetryHours) * time.Hour
s.credits.Configure(cfg)
}
s.loggerFor(r.Context()).Info("credits detection settings changed",
"candidate_limit", req.CandidateLimit, "prefetch", req.PrefetchEpisodes,
"max_prefetch", req.MaxPrefetch, "retry_hours", req.RetryHours)
s.writeAdminCredits(w, r)
}
+39 -13
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/ponzischeme89/memby/server/internal/emby"
)
@@ -84,11 +85,24 @@ func (s *Server) newInstallerSession() (string, error) {
}
func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, error) {
payload := make([]byte, 8+16)
return s.newBrowserSessionFor(purpose, ttl, "")
}
// newBrowserSessionFor includes the verified account name in an admin session. It remains
// inside the signed, HttpOnly cookie: the console learns who is at the keyboard from the
// status response, without adding a second identity cookie that JavaScript could alter.
// Sessions minted by older gateways had only the 24-byte prefix and remain valid.
func (s *Server) newBrowserSessionFor(purpose string, ttl time.Duration, username string) (string, error) {
username = strings.TrimSpace(username)
if len(username) > 512 || !utf8.ValidString(username) {
username = ""
}
payload := make([]byte, 8+16+len(username))
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix()))
if _, err := rand.Read(payload[8:]); err != nil {
if _, err := rand.Read(payload[8:24]); err != nil {
return "", err
}
copy(payload[24:], username)
signature := s.signInstallerValue(purpose, payload)
return base64.RawURLEncoding.EncodeToString(payload) + "." +
base64.RawURLEncoding.EncodeToString(signature), nil
@@ -98,31 +112,39 @@ func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, e
// cookie that is missing, malformed, forged, signed for a different purpose or already
// expired is reported the same way: no session.
func (s *Server) browserSessionExpiry(r *http.Request, purpose string) (time.Time, bool) {
expires, _, ok := s.browserSession(r, purpose)
return expires, ok
}
// browserSession verifies the session once and returns the optional identity carried by
// newer cookies. The empty identity is legitimate for a session issued before it was
// added, so validity is reported separately.
func (s *Server) browserSession(r *http.Request, purpose string) (time.Time, string, bool) {
if len(s.installerSecret()) == 0 {
return time.Time{}, false
return time.Time{}, "", false
}
cookie, err := r.Cookie(installerCookieName)
if err != nil {
return time.Time{}, false
return time.Time{}, "", false
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 2 {
return time.Time{}, false
return time.Time{}, "", false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || len(payload) != 24 {
return time.Time{}, false
if err != nil || len(payload) < 24 || len(payload) > 536 || !utf8.Valid(payload[24:]) {
return time.Time{}, "", false
}
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(signature, s.signInstallerValue(purpose, payload)) {
return time.Time{}, false
return time.Time{}, "", false
}
expires := int64(binary.BigEndian.Uint64(payload[:8]))
now := time.Now().Unix()
if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 {
return time.Time{}, false
return time.Time{}, "", false
}
return time.Unix(expires, 0), true
return time.Unix(expires, 0), strings.TrimSpace(string(payload[24:])), true
}
// validInstallerSession gates the public installer, which an administrator's own session
@@ -149,11 +171,11 @@ func (s *Server) validAdminSession(r *http.Request) bool {
// 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) renewAdminSession(w http.ResponseWriter, r *http.Request) {
expires, ok := s.browserSessionExpiry(r, adminSessionPurpose)
expires, username, ok := s.browserSession(r, adminSessionPurpose)
if !ok || time.Until(expires) > adminRenewWithin {
return
}
session, err := s.newBrowserSession(adminSessionPurpose, adminSessionTTL)
session, err := s.newBrowserSessionFor(adminSessionPurpose, adminSessionTTL, username)
if err != nil {
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
return
@@ -317,7 +339,11 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
}
purpose, ttl = adminSessionPurpose, adminSessionTTL
}
session, err := s.newBrowserSession(purpose, ttl)
verifiedUsername := strings.TrimSpace(auth.User.Name)
if verifiedUsername == "" {
verifiedUsername = username
}
session, err := s.newBrowserSessionFor(purpose, ttl, verifiedUsername)
if err != nil {
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start installer session")
+55 -2
View File
@@ -94,8 +94,12 @@ type playbackReportResponse struct {
// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so
// the client decodes it into the same BaseItem it uses everywhere else.
type nextEpisodeResponse struct {
Item json.RawMessage `json:"item"`
Title string `json:"title"`
Item json.RawMessage `json:"item"`
Title string `json:"title"`
// StreamReady says whether this answer carries a negotiated stream. A client asking
// with `stream=0` gets identity, artwork and runtime and nothing that touches Emby's
// PlaybackInfo — see [handleNextEpisode] for why that separation exists.
StreamReady bool `json:"streamReady"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
@@ -302,6 +306,20 @@ func episodeCode(item emby.Summary) string {
//
// "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an
// unreadable series all come back as 404 and the client simply shows no banner.
//
// It answers in two shapes, and the separation is the whole reason auto-advance stopped
// showing a viewer the machinery. `stream=0` asks for **identity only** — the episode's
// item JSON, its title and its artwork — and touches nothing but the episode list. The
// full answer additionally negotiates PlaybackInfo, which mints a play session and, on a
// title that has to be transcoded, a transcode session at Emby.
//
// The television asks for the cheap shape when the episode it is watching *starts*, which
// is where the banner's picture and wording come from, and for the full one shortly before
// the hand-over. Doing both at once is what the player used to do, and it was wrong twice:
// a session negotiated forty minutes early has long since been torn down by the time it is
// used — so the transition failed, retried, and narrated all of it to the viewer — and the
// transcode it may have started sat there being paid for by a viewer who had not yet
// decided to watch it.
func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
@@ -355,6 +373,27 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" {
title = series + " " + title
}
// The metadata-only shape. Everything below this point is a PlaybackInfo negotiation,
// and a client that said it does not want one yet must not be given one anyway.
if !nextEpisodeWantsStream(r) {
s.playbackTitles.remember(next.ID, title)
s.loggerFor(ctx).Debug("next episode identified",
"title", title, "item", next.ID, "after_item", itemID)
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
StreamReady: false,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: []playableSubtitle{},
SubtitlesEnabled: true,
TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx),
})
return
}
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", false,
s.effectivePlaybackCapabilities(ctx, sess),
@@ -380,6 +419,7 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
StreamReady: true,
URL: streamURL,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
@@ -395,6 +435,19 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
})
}
// nextEpisodeWantsStream reads the client's `stream` parameter, and **defaults to yes**.
// An app built before the two-phase lookup existed sends nothing and expects the whole
// answer; reading a missing parameter as "identity only" would hand every one of those
// televisions a next-up banner with no stream behind it.
func nextEpisodeWantsStream(r *http.Request) bool {
switch strings.TrimSpace(strings.ToLower(r.URL.Query().Get("stream"))) {
case "0", "false", "no":
return false
default:
return true
}
}
func (s *Server) playbackSubtitles(
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,