0.2.63 update
This commit is contained in:
@@ -61,6 +61,10 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||
mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||
mux.Handle("POST /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||
mux.Handle("GET /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
|
||||
mux.Handle("POST /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
|
||||
mux.Handle("GET /admin/api/arr-integrations", s.adminAuth(s.handleAdminArrIntegrations))
|
||||
mux.Handle("POST /admin/api/arr-integrations", s.adminAuth(s.handleAdminArrIntegrations))
|
||||
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
|
||||
mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch))
|
||||
mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy))
|
||||
@@ -459,8 +463,10 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
valid := make(map[string]bool, len(known))
|
||||
usernames := make(map[string]string, len(known))
|
||||
for _, user := range known {
|
||||
valid[user.ID] = true
|
||||
usernames[user.ID] = user.Username
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
allowed := make([]string, 0, len(req.AllowedUserIDs))
|
||||
@@ -482,7 +488,15 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusInternalServerError, "could not save request access")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed))
|
||||
allowedNames := make([]string, 0, len(allowed))
|
||||
for _, id := range allowed {
|
||||
allowedNames = append(allowedNames, usernames[id])
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("Media Request access level updated",
|
||||
"gateway_version", buildinfo.Version(),
|
||||
"allowed_usernames", strings.Join(allowedNames, ", "),
|
||||
"allowed_user_ids", strings.Join(allowed, ", "),
|
||||
"allowed_users", len(allowed))
|
||||
writeJSON(w, http.StatusOK, policy)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
type arrIntegrationStatus struct {
|
||||
SonarrConfigured bool `json:"sonarrConfigured"`
|
||||
RadarrConfigured bool `json:"radarrConfigured"`
|
||||
SonarrEnabled bool `json:"sonarrEnabled"`
|
||||
RadarrEnabled bool `json:"radarrEnabled"`
|
||||
}
|
||||
|
||||
func (s *Server) arrIntegrationStatus(r *http.Request) arrIntegrationStatus {
|
||||
policy, err := s.store.ArrIntegrationPolicy(r.Context())
|
||||
if err != nil {
|
||||
policy = store.DefaultArrIntegrationPolicy()
|
||||
}
|
||||
return arrIntegrationStatus{SonarrConfigured: s.sonarr != nil, RadarrConfigured: s.radarr != nil, SonarrEnabled: s.sonarr != nil && policy.SonarrEnabled, RadarrEnabled: s.radarr != nil && policy.RadarrEnabled}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminArrIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, s.arrIntegrationStatus(r))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SonarrEnabled bool `json:"sonarrEnabled"`
|
||||
RadarrEnabled bool `json:"radarrEnabled"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetArrIntegrationPolicy(r.Context(), store.ArrIntegrationPolicy{SonarrEnabled: req.SonarrEnabled, RadarrEnabled: req.RadarrEnabled}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not save integration settings")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("arr integrations changed", "sonarr_enabled", req.SonarrEnabled, "radarr_enabled", req.RadarrEnabled)
|
||||
writeJSON(w, http.StatusOK, s.arrIntegrationStatus(r))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
type radarrProfileOption struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Recommended bool `json:"recommended"`
|
||||
}
|
||||
|
||||
type radarrRequestAdminPolicy struct {
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
SearchImmediately bool `json:"searchImmediately"`
|
||||
Profiles []radarrProfileOption `json:"profiles"`
|
||||
RecommendedID int `json:"recommendedId"`
|
||||
Configured bool `json:"configured"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) radarrRequestAdminPolicy(ctx context.Context) radarrRequestAdminPolicy {
|
||||
policy, err := s.store.RadarrRequestPolicy(ctx)
|
||||
if err != nil {
|
||||
return radarrRequestAdminPolicy{Profiles: []radarrProfileOption{}, Error: "Could not read the Radarr request policy."}
|
||||
}
|
||||
result := radarrRequestAdminPolicy{QualityProfileID: policy.QualityProfileID, SearchImmediately: policy.SearchImmediately, Profiles: []radarrProfileOption{}}
|
||||
if s.radarr == nil {
|
||||
result.Error = "Radarr is not configured."
|
||||
return result
|
||||
}
|
||||
profiles, err := s.radarr.QualityProfiles(ctx)
|
||||
if err != nil {
|
||||
result.Error = "Could not read Radarr quality profiles."
|
||||
return result
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
recommended := is720pProfile(profile.Name)
|
||||
if recommended && result.RecommendedID == 0 {
|
||||
result.RecommendedID = profile.ID
|
||||
}
|
||||
if profile.ID == policy.QualityProfileID {
|
||||
result.Configured = true
|
||||
}
|
||||
result.Profiles = append(result.Profiles, radarrProfileOption{ID: profile.ID, Name: profile.Name, Recommended: recommended})
|
||||
}
|
||||
if policy.QualityProfileID == 0 && result.RecommendedID != 0 {
|
||||
result.QualityProfileID, result.Configured = result.RecommendedID, true
|
||||
}
|
||||
if !result.Configured && result.Error == "" {
|
||||
result.Error = "Choose an existing Radarr quality profile before accepting movie requests."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type radarrRequestPolicyRequest struct {
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
SearchImmediately bool `json:"searchImmediately"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRadarrRequestPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, s.radarrRequestAdminPolicy(r.Context()))
|
||||
return
|
||||
}
|
||||
if s.radarr == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "Radarr is not configured")
|
||||
return
|
||||
}
|
||||
var req radarrRequestPolicyRequest
|
||||
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.QualityProfileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "choose a Radarr quality profile")
|
||||
return
|
||||
}
|
||||
profiles, err := s.radarr.QualityProfiles(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "could not validate Radarr quality profiles")
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, profile := range profiles {
|
||||
if profile.ID == req.QualityProfileID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusBadRequest, "the selected Radarr quality profile no longer exists")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetRadarrRequestPolicy(r.Context(), store.RadarrRequestPolicy{QualityProfileID: req.QualityProfileID, SearchImmediately: req.SearchImmediately}); err != nil {
|
||||
s.loggerFor(r.Context()).Error("Radarr request policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save Radarr request policy")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("Radarr request policy changed", "quality_profile_id", req.QualityProfileID, "search_immediately", req.SearchImmediately)
|
||||
writeJSON(w, http.StatusOK, s.radarrRequestAdminPolicy(r.Context()))
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func liveAlerts(stored []storedAlert, now time.Time) []clientAlert {
|
||||
// sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row
|
||||
// uses, so polling clients never cost a Sonarr request of their own.
|
||||
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
|
||||
if s.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 {
|
||||
if !s.sonarrEnabled(ctx) || s.cfg.SonarrAlertWindow <= 0 {
|
||||
return nil
|
||||
}
|
||||
row, err := s.sonarrAiringTodayRow(ctx)
|
||||
|
||||
@@ -153,6 +153,30 @@ func (s *Server) publishAdmin(ctx context.Context, event adminevents.Event) {
|
||||
s.adminEvents.Publish(ctx, event)
|
||||
}
|
||||
|
||||
func (s *Server) sonarrEnabled(ctx context.Context) bool {
|
||||
if s.sonarr == nil || s.store == nil {
|
||||
return false
|
||||
}
|
||||
policy, err := s.store.ArrIntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err)
|
||||
return false
|
||||
}
|
||||
return policy.SonarrEnabled
|
||||
}
|
||||
|
||||
func (s *Server) radarrEnabled(ctx context.Context) bool {
|
||||
if s.radarr == nil || s.store == nil {
|
||||
return false
|
||||
}
|
||||
policy, err := s.store.ArrIntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err)
|
||||
return false
|
||||
}
|
||||
return policy.RadarrEnabled
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
// The client API lives on its own mux so maintenance mode can gate all of it at
|
||||
// once, without the gate ever touching health checks or the admin page.
|
||||
@@ -404,6 +428,9 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
r, identity := withRequestIdentity(r)
|
||||
w.Header().Set("X-Memby-Correlation", identity.correlation)
|
||||
s.loggerFor(r.Context()).Log(r.Context(), serverlogging.LevelTrace, "request started",
|
||||
"method", r.Method, "path", r.URL.Path, "query_keys", queryKeys(r))
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
// Polling the live-log endpoint must not create another live-log record and
|
||||
@@ -417,7 +444,7 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
//
|
||||
// Path only: query strings can carry image tokens.
|
||||
level := requestLogLevel(r.URL.Path, rec.status)
|
||||
fields := []any{"component", identity.component}
|
||||
fields := []any{"component", identity.component, "correlation", identity.correlation}
|
||||
fields = append(fields, identity.viewerAttrs()...)
|
||||
// The app build keeps its placeholder where the viewer does not, because "which
|
||||
// build made this call" always has an answer worth seeing, including "it did
|
||||
@@ -439,6 +466,17 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// queryKeys is diagnostic context without values: query values can contain title searches,
|
||||
// tokens or other private values, while their keys are enough to explain the route shape.
|
||||
func queryKeys(r *http.Request) string {
|
||||
keys := make([]string, 0, len(r.URL.Query()))
|
||||
for key := range r.URL.Query() {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return strings.Join(keys, ",")
|
||||
}
|
||||
|
||||
func clientLogValue(value string) string {
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
|
||||
@@ -62,7 +62,7 @@ type calendarDay struct {
|
||||
|
||||
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
ctx := r.Context()
|
||||
if s.sonarr == nil || !s.featureEnabled(ctx, featureTVCalendar) {
|
||||
if !s.sonarrEnabled(ctx) || !s.featureEnabled(ctx, featureTVCalendar) {
|
||||
writeJSON(w, http.StatusOK, emptyCalendar())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ type homeResponse struct {
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
limit := queryInt(r, "limit", 24, 100)
|
||||
sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
|
||||
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
|
||||
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
|
||||
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
||||
hero := supportsHomeHero(r)
|
||||
key := cache.UserKey(
|
||||
sess.EmbyUserID,
|
||||
|
||||
@@ -84,7 +84,7 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
|
||||
}
|
||||
|
||||
func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
|
||||
if s.radarr == nil {
|
||||
if !s.radarrEnabled(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemI
|
||||
}
|
||||
|
||||
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -19,11 +21,12 @@ import (
|
||||
// still read what an inner layer filled in. A request is served on one goroutine and the
|
||||
// handler has returned by the time the middleware reads this, so no lock is needed.
|
||||
type requestIdentity struct {
|
||||
component string
|
||||
user string
|
||||
device string
|
||||
client string
|
||||
protocol string
|
||||
component string
|
||||
user string
|
||||
device string
|
||||
client string
|
||||
protocol string
|
||||
correlation string
|
||||
}
|
||||
|
||||
type identityKey struct{}
|
||||
@@ -31,9 +34,10 @@ type identityKey struct{}
|
||||
// withRequestIdentity installs an empty identity for this request and returns it.
|
||||
func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) {
|
||||
identity := &requestIdentity{
|
||||
component: componentFor(r.URL.Path),
|
||||
client: clientVersion(r),
|
||||
protocol: clientProtocol(r),
|
||||
component: componentFor(r.URL.Path),
|
||||
client: clientVersion(r),
|
||||
protocol: clientProtocol(r),
|
||||
correlation: requestCorrelation(r),
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity)), identity
|
||||
}
|
||||
@@ -79,9 +83,35 @@ func (i *requestIdentity) attrs() []any {
|
||||
if i.client != "" {
|
||||
attrs = append(attrs, "client", i.client)
|
||||
}
|
||||
if i.correlation != "" {
|
||||
attrs = append(attrs, "correlation", i.correlation)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// requestCorrelation accepts a client-supplied safe identifier or creates one at the
|
||||
// gateway boundary. The same value is attached to every log line within the exchange and
|
||||
// returned to the client, making a playback launch traceable through gateway and Emby work.
|
||||
func requestCorrelation(r *http.Request) string {
|
||||
if value := strings.TrimSpace(r.Header.Get("X-Memby-Correlation")); len(value) >= 6 && len(value) <= 64 {
|
||||
for _, rune := range value {
|
||||
if !(rune >= 'a' && rune <= 'z' || rune >= 'A' && rune <= 'Z' || rune >= '0' && rune <= '9' || rune == '-' || rune == '_') {
|
||||
return newCorrelation()
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
return newCorrelation()
|
||||
}
|
||||
|
||||
func newCorrelation() string {
|
||||
var raw [4]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "request-unknown"
|
||||
}
|
||||
return "req-" + strings.ToUpper(hex.EncodeToString(raw[:]))
|
||||
}
|
||||
|
||||
// viewerAttrs names the person and the television, and only when they are known: an
|
||||
// unauthenticated probe has neither, and "user=unknown" on every health check is noise.
|
||||
func (i *requestIdentity) viewerAttrs() []any {
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.
|
||||
return
|
||||
}
|
||||
sonarrSeries := []sonarr.Series{}
|
||||
if s.sonarr != nil {
|
||||
if s.sonarrEnabled(r.Context()) {
|
||||
if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil {
|
||||
sonarrSeries = value
|
||||
} else {
|
||||
@@ -186,7 +186,7 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
|
||||
func (s *Server) syncReturnNotifications(
|
||||
r *http.Request, sess store.Session, prefs store.NotificationPreferences,
|
||||
) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(r.Context()) {
|
||||
return
|
||||
}
|
||||
shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID)
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -161,9 +163,11 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
subtitleIndex = &parsed
|
||||
}
|
||||
}
|
||||
negotiationStarted := time.Now()
|
||||
forceTranscode := queryBool(r, "forceTranscode")
|
||||
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
|
||||
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
|
||||
queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess),
|
||||
forceTranscode, s.effectivePlaybackCapabilities(ctx, sess),
|
||||
)
|
||||
streamURL := s.emby.StreamURL(cred, target.ID)
|
||||
if negotiatedURL != "" {
|
||||
@@ -198,6 +202,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
"subtitles", len(subtitles),
|
||||
"subtitle_track", clientLogValue(selectedSubtitleID),
|
||||
"subtitle_language", clientLogValue(subtitleLanguage),
|
||||
"media_source_id", mediaSourceID,
|
||||
"play_session_id", clientLogValue(playSessionID),
|
||||
"force_transcode", forceTranscode,
|
||||
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, playbackResponse{
|
||||
@@ -392,15 +400,20 @@ func (s *Server) playbackSubtitles(
|
||||
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
|
||||
capabilities emby.PlaybackCapabilities,
|
||||
) ([]playableSubtitle, string, string, string, string) {
|
||||
started := time.Now()
|
||||
log := s.loggerFor(ctx).With("item", itemID, "force_transcode", forceTranscode,
|
||||
"subtitle_index", subtitleIndex != nil, "resume", millisecondDuration(startTicks/ticksPerMillisecond))
|
||||
log.Log(ctx, serverlogging.LevelTrace, "Emby playback negotiation started")
|
||||
info, err := s.emby.PlaybackInfo(
|
||||
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
|
||||
capabilities,
|
||||
)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
|
||||
log.Warn("Emby playback negotiation failed", "duration", time.Since(started).Round(time.Millisecond), "error", err)
|
||||
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
|
||||
}
|
||||
if len(info.MediaSources) == 0 {
|
||||
log.Warn("Emby playback negotiation returned no media sources", "duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID)
|
||||
return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay"
|
||||
}
|
||||
out := make([]playableSubtitle, 0)
|
||||
@@ -457,12 +470,37 @@ func (s *Server) playbackSubtitles(
|
||||
// download that produced it.
|
||||
out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID))
|
||||
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
|
||||
log.Debug("Emby playback source selected",
|
||||
"duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID,
|
||||
"media_source_id", source.ID, "media_sources", len(info.MediaSources), "subtitles", len(out),
|
||||
"play_method", playMethod, "selection_reason", playbackSelectionReason(source, forceTranscode || subtitleIndex != nil),
|
||||
"supports_direct_play", source.SupportsDirectPlay, "supports_direct_stream", source.SupportsDirectStream,
|
||||
"supports_transcoding", source.SupportsTranscoding)
|
||||
if delivery != "" {
|
||||
delivery = s.emby.DeliveryURL(cred, delivery)
|
||||
}
|
||||
return out, source.ID, info.PlaySessionID, delivery, playMethod
|
||||
}
|
||||
|
||||
func playbackSelectionReason(source emby.MediaSourceInfo, forceTranscode bool) string {
|
||||
switch {
|
||||
case forceTranscode && source.TranscodingURL != "":
|
||||
return "forced by viewer or burned-in subtitle"
|
||||
case source.SupportsDirectPlay:
|
||||
return "source supports direct play"
|
||||
case source.SupportsDirectStream && source.DirectStreamURL != "":
|
||||
return "direct play unavailable; source supports direct stream"
|
||||
case source.SupportsTranscoding && source.TranscodingURL != "":
|
||||
return "source requires transcoding"
|
||||
case source.DirectStreamURL != "":
|
||||
return "fallback direct-stream URL available"
|
||||
case source.TranscodingURL != "":
|
||||
return "fallback transcode URL available"
|
||||
default:
|
||||
return "Emby supplied no alternate delivery URL"
|
||||
}
|
||||
}
|
||||
|
||||
func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities {
|
||||
capabilities := emby.PlaybackCapabilities{}
|
||||
for _, value := range sess.ClientCapabilities {
|
||||
@@ -729,6 +767,8 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
log := s.loggerFor(r.Context()).With(
|
||||
"title", s.playbackTitles.name(report.ItemID),
|
||||
"item", report.ItemID,
|
||||
"media_source_id", clientLogValue(report.MediaSourceID),
|
||||
"play_session_id", clientLogValue(report.PlaySessionID),
|
||||
)
|
||||
|
||||
err := s.emby.ReportPlayback(
|
||||
@@ -756,6 +796,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
log.Info("playback started",
|
||||
"position", millisecondDuration(report.PositionMs),
|
||||
"play_method", clientLogValue(report.PlayMethod),
|
||||
"event_name", clientLogValue(report.EventName),
|
||||
)
|
||||
case "stopped":
|
||||
log.Info("playback stopped",
|
||||
@@ -767,6 +808,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
log.Debug("playback progress",
|
||||
"position", millisecondDuration(report.PositionMs),
|
||||
"paused", report.IsPaused,
|
||||
"event_name", clientLogValue(report.EventName),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ type radarrScheduleItem struct {
|
||||
}
|
||||
|
||||
func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) {
|
||||
if s.radarr == nil {
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
location := s.cfg.RadarrLocation
|
||||
@@ -243,7 +243,7 @@ const radarrMovieCacheKey = "radarr:movies:v1"
|
||||
// Failure degrades to asking Radarr directly — a cache that is down costs latency, never the
|
||||
// answer.
|
||||
func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, error) {
|
||||
if s.radarr == nil {
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return nil, fmt.Errorf("radarr: not configured")
|
||||
}
|
||||
if movies := s.cachedRadarrMovies(ctx); movies != nil {
|
||||
|
||||
@@ -48,7 +48,7 @@ type requestLookupResponse struct {
|
||||
}
|
||||
|
||||
func (s *Server) requestAllowed(r *http.Request, sess store.Session) bool {
|
||||
if s.store == nil || (s.sonarr == nil && s.radarr == nil) {
|
||||
if s.store == nil || (!s.sonarrEnabled(r.Context()) && !s.radarrEnabled(r.Context())) {
|
||||
return false
|
||||
}
|
||||
policy, err := s.store.RequestPolicy(r.Context())
|
||||
@@ -73,7 +73,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
|
||||
var movieCandidates []requestCandidate
|
||||
var seriesCandidates []requestCandidate
|
||||
var wg sync.WaitGroup
|
||||
if s.radarr != nil {
|
||||
if s.radarrEnabled(r.Context()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -98,7 +98,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s.sonarr != nil {
|
||||
if s.sonarrEnabled(r.Context()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -235,7 +235,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
|
||||
switch req.MediaType {
|
||||
case "movie":
|
||||
if s.radarr == nil {
|
||||
if !s.radarrEnabled(r.Context()) {
|
||||
s.logMediaRequest(r.Context(), req, "failed", errors.New("movie requests are not configured"))
|
||||
writeError(w, http.StatusServiceUnavailable, "movie requests are not configured")
|
||||
return
|
||||
@@ -265,13 +265,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
return
|
||||
}
|
||||
req.Title = movie.Title
|
||||
added, err := s.radarr.AddRequested(r.Context(), movie)
|
||||
requestOptions, rootFolder, profileName, err := s.radarrRequestOptions(r.Context())
|
||||
if err != nil {
|
||||
s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", "", 0, "", false, err)
|
||||
s.publishRadarrRequestConfigurationProblem(r.Context(), err)
|
||||
writeError(w, http.StatusServiceUnavailable, "movie requests are unavailable: "+err.Error())
|
||||
return
|
||||
}
|
||||
added, err := s.radarr.AddRequested(r.Context(), movie, rootFolder, requestOptions)
|
||||
if err != nil {
|
||||
s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, err)
|
||||
s.logMediaRequest(r.Context(), req, "failed", err)
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie")
|
||||
return
|
||||
}
|
||||
req.Title = added.Title
|
||||
s.logRadarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil)
|
||||
s.recordMediaRequest(r.Context(), sess, req, added.Year,
|
||||
radarrCoverURL(added.Images, "poster"))
|
||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||
@@ -279,7 +288,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
return
|
||||
}
|
||||
case "series":
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(r.Context()) {
|
||||
s.logMediaRequest(r.Context(), req, "failed", errors.New("series requests are not configured"))
|
||||
writeError(w, http.StatusServiceUnavailable, "series requests are not configured")
|
||||
return
|
||||
@@ -392,7 +401,7 @@ func (s *Server) writeRequestUpstreamError(
|
||||
// sonarrRequestOptions validates every component before a POST can reach Sonarr. A missing
|
||||
// configured profile is an error, not permission to fall back to Sonarr's "Any" profile.
|
||||
func (s *Server) sonarrRequestOptions(ctx context.Context) (sonarr.RequestOptions, string, string, error) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(ctx) {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("Sonarr integration is unavailable")
|
||||
}
|
||||
policy, err := s.store.SonarrRequestPolicy(ctx)
|
||||
@@ -465,3 +474,74 @@ func (s *Server) publishSonarrRequestConfigurationProblem(ctx context.Context, e
|
||||
Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) radarrRequestOptions(ctx context.Context) (radarr.RequestOptions, string, string, error) {
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("Radarr integration is unavailable")
|
||||
}
|
||||
policy, err := s.store.RadarrRequestPolicy(ctx)
|
||||
if err != nil {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("could not read the Radarr request policy")
|
||||
}
|
||||
roots, err := s.radarr.RootFolders(ctx)
|
||||
if err != nil {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr root folders")
|
||||
}
|
||||
if len(roots) == 0 || strings.TrimSpace(roots[0].Path) == "" {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("Radarr has no valid root folder")
|
||||
}
|
||||
profiles, err := s.radarr.QualityProfiles(ctx)
|
||||
if err != nil {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr quality profiles")
|
||||
}
|
||||
profileID := policy.QualityProfileID
|
||||
if profileID == 0 {
|
||||
for _, profile := range profiles {
|
||||
if is720pProfile(profile.Name) {
|
||||
profileID = profile.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if profileID == 0 {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("no request quality profile is configured and Radarr has no 720p profile")
|
||||
}
|
||||
policy.QualityProfileID = profileID
|
||||
if err := s.store.SetRadarrRequestPolicy(ctx, policy); err != nil {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("could not save the default Radarr request quality profile")
|
||||
}
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if profile.ID == profileID {
|
||||
return radarr.RequestOptions{QualityProfileID: profile.ID, SearchImmediately: policy.SearchImmediately}, roots[0].Path, profile.Name, nil
|
||||
}
|
||||
}
|
||||
return radarr.RequestOptions{}, "", "", errors.New("the configured Radarr request quality profile no longer exists")
|
||||
}
|
||||
|
||||
func (s *Server) logRadarrRequest(
|
||||
ctx context.Context, sess store.Session, req requestPayload, movie radarr.Movie, movieID int,
|
||||
outcome, profileName string, profileID int, rootFolder string, searchImmediately bool, err error,
|
||||
) {
|
||||
fields := []any{
|
||||
"user", clientLogValue(sess.Username), "user_id", sess.EmbyUserID,
|
||||
"title", clientLogValue(movie.Title), "tmdb_id", movie.TMDBID,
|
||||
"radarr_movie_id", movieID, "quality_profile", profileName,
|
||||
"quality_profile_id", profileID, "monitoring_strategy", "movie",
|
||||
"root_folder", rootFolder, "search_immediately", searchImmediately, "outcome", outcome,
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, "radarr_result", err.Error())
|
||||
s.loggerFor(ctx).Warn("Radarr movie request", fields...)
|
||||
return
|
||||
}
|
||||
fields = append(fields, "radarr_result", "created")
|
||||
s.loggerFor(ctx).Info("Radarr movie request", fields...)
|
||||
}
|
||||
|
||||
func (s *Server) publishRadarrRequestConfigurationProblem(ctx context.Context, err error) {
|
||||
s.publishAdmin(ctx, adminevents.Event{
|
||||
Type: "radarr.request_configuration", Severity: adminevents.SeverityError,
|
||||
Title: "Radarr movie requests need attention", Summary: err.Error(), Actor: "memby-server",
|
||||
Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func (s *Server) decorateRequests(
|
||||
)
|
||||
now := time.Now()
|
||||
|
||||
if len(movieIDs) > 0 && s.radarr != nil {
|
||||
if len(movieIDs) > 0 && s.radarrEnabled(ctx) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -111,7 +111,7 @@ func (s *Server) decorateRequests(
|
||||
}
|
||||
}()
|
||||
}
|
||||
if len(seriesIDs) > 0 && s.sonarr != nil {
|
||||
if len(seriesIDs) > 0 && s.sonarrEnabled(ctx) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
@@ -38,7 +38,7 @@ const sonarrScheduleDays = 5
|
||||
// Every failure degrades to asking Sonarr directly: a cache that is down must cost latency,
|
||||
// never the answer.
|
||||
func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(ctx) {
|
||||
return nil, fmt.Errorf("sonarr: not configured")
|
||||
}
|
||||
if series := s.cachedSonarrSeries(ctx); series != nil {
|
||||
@@ -95,7 +95,7 @@ type prerollScheduleEntry struct {
|
||||
}
|
||||
|
||||
func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(r.Context()) {
|
||||
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
|
||||
return
|
||||
}
|
||||
@@ -340,7 +340,7 @@ func (index seriesIndex) logo(title string, year int) (string, string) {
|
||||
}
|
||||
|
||||
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// daily. History stores changes rather than identical daily snapshots: it still records
|
||||
// the complete lifecycle while making an active-to-cancelled transition unambiguous.
|
||||
func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
|
||||
if s.sonarr == nil || interval <= 0 {
|
||||
if !s.sonarrEnabled(ctx) || interval <= 0 {
|
||||
return
|
||||
}
|
||||
scan := func() {
|
||||
|
||||
Reference in New Issue
Block a user