0.2.68
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user