0.2.59 - Settings save fixes
This commit is contained in:
@@ -414,6 +414,10 @@ func clientLogValue(value string) string {
|
||||
// visible regardless of path.
|
||||
func requestLogLevel(path string, status int) slog.Level {
|
||||
switch {
|
||||
// A request nobody is waiting for any more is not a failure of anything. It is only
|
||||
// ever answered this way deliberately, so it never hides a fault.
|
||||
case status == statusClientClosedRequest:
|
||||
return slog.LevelDebug
|
||||
case status >= http.StatusInternalServerError:
|
||||
return slog.LevelError
|
||||
case status >= http.StatusBadRequest:
|
||||
@@ -557,6 +561,11 @@ func writeRaw(w http.ResponseWriter, status int, body []byte) {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// statusClientClosedRequest is nginx's 499. Go has no constant for it because it is not
|
||||
// in the RFC — it exists to say "this was not answered, and that is nobody's fault",
|
||||
// which is a distinction a log is read for and a 5xx destroys.
|
||||
const statusClientClosedRequest = 499
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -566,6 +575,17 @@ func writeError(w http.ResponseWriter, status int, message string) {
|
||||
func (s *Server) writeUpstreamError(
|
||||
ctx context.Context, w http.ResponseWriter, err error, message string,
|
||||
) {
|
||||
// The television having navigated on is not a fault, and it is the ordinary case here:
|
||||
// artwork loaders abandon requests as cards leave the screen, and a detail page warmed
|
||||
// on focus is cancelled the moment the D-pad moves. Reported as 502 it filled the
|
||||
// operator's log with errors describing a launcher working exactly as designed, and
|
||||
// buried the ones that meant something. Nobody is left to read the answer, so it goes
|
||||
// out as 499 — nginx's "client closed request" — and is recorded at DEBUG.
|
||||
if clientGaveUp(ctx, err) {
|
||||
s.loggerFor(ctx).Debug("abandoned before the answer", "detail", message, "error", err)
|
||||
writeError(w, statusClientClosedRequest, "the request was abandoned")
|
||||
return
|
||||
}
|
||||
var apiErr *emby.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch {
|
||||
|
||||
@@ -452,6 +452,10 @@ type heroItemFacts struct {
|
||||
Type string
|
||||
Premiere time.Time
|
||||
Playable bool
|
||||
|
||||
// Watched is Emby's own answer for this viewer. The rows are fetched per person and
|
||||
// carry their user data, so it costs nothing to read.
|
||||
Watched bool
|
||||
}
|
||||
|
||||
func heroFactsOf(raw json.RawMessage) (heroItemFacts, bool) {
|
||||
@@ -462,6 +466,9 @@ func heroFactsOf(raw json.RawMessage) (heroItemFacts, bool) {
|
||||
PremiereDate string `json:"PremiereDate"`
|
||||
Source string `json:"MembySource"`
|
||||
Playable *bool `json:"MembyPlayable"`
|
||||
UserData struct {
|
||||
Played bool `json:"Played"`
|
||||
} `json:"UserData"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.ID) == "" {
|
||||
return heroItemFacts{}, false
|
||||
@@ -474,6 +481,7 @@ func heroFactsOf(raw json.RawMessage) (heroItemFacts, bool) {
|
||||
// Anything from Emby carries neither field, and is.
|
||||
Playable: strings.TrimSpace(payload.Source) == "" &&
|
||||
(payload.Playable == nil || *payload.Playable),
|
||||
Watched: payload.UserData.Played,
|
||||
}
|
||||
if parsed, err := parseEmbyDate(payload.PremiereDate); err == nil {
|
||||
facts.Premiere = parsed
|
||||
@@ -837,6 +845,18 @@ func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]hero
|
||||
!strings.EqualFold(fact.Type, "Movie") {
|
||||
continue
|
||||
}
|
||||
// A film somebody has already seen is not something to lead the launcher
|
||||
// with: the hero exists to be pressed, and the answer to "watch this
|
||||
// tonight" cannot be a film that finished last week. It is only ever the
|
||||
// whole title here — a film halfway through belongs to Continue Watching,
|
||||
// which is not a candidate row at all.
|
||||
//
|
||||
// Series are deliberately not filtered this way. A show marked watched is
|
||||
// one somebody is up to date with, which is exactly who a season premiere
|
||||
// is news for, and premieres come from Sonarr rather than from here.
|
||||
if fact.Watched {
|
||||
continue
|
||||
}
|
||||
seen[fact.ID] = true
|
||||
facts[fact.ID] = fact
|
||||
rating, rated := heroRatingOf(raw)
|
||||
|
||||
@@ -357,6 +357,27 @@ func TestHeroMovieCandidatesSkipUnpressableRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A film somebody has finished is not an answer to "watch this tonight", however new or
|
||||
// well reviewed it is. A series is left alone: watched there means up to date, which is
|
||||
// exactly who a returning season is news for.
|
||||
func TestHeroMovieCandidatesSkipWatchedFilms(t *testing.T) {
|
||||
watched, _ := json.Marshal(map[string]any{
|
||||
"Id": "seen", "Name": "Seen", "Type": "Movie",
|
||||
"UserData": map[string]any{"Played": true},
|
||||
})
|
||||
partway, _ := json.Marshal(map[string]any{
|
||||
"Id": "partway", "Name": "Partway", "Type": "Movie",
|
||||
"UserData": map[string]any{"Played": false, "PlaybackPositionTicks": 6_000_000_000},
|
||||
})
|
||||
rows := []recommend.Row{{ID: "latest", Kind: "latest", Items: []json.RawMessage{
|
||||
watched, partway, heroItem("unseen", "Unseen", "Movie"),
|
||||
}}}
|
||||
candidates, _ := heroMovieCandidates(rows)
|
||||
if ids := strings.Join(heroIDs(candidates), ","); ids != "partway,unseen" {
|
||||
t.Fatalf("expected the unfinished and unseen films only, got %s", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// Emby writes dates in more than one shape, and one it will not parse is unknown rather
|
||||
// than fatal.
|
||||
func TestParseEmbyDate(t *testing.T) {
|
||||
|
||||
@@ -110,6 +110,10 @@ func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemI
|
||||
writeError(w, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
if clientGaveUp(r.Context(), err) {
|
||||
writeError(w, statusClientClosedRequest, "the request was abandoned")
|
||||
return
|
||||
}
|
||||
s.log.Warn("radarr image failed", "movie_id", movieID, "type", coverType, "error", err)
|
||||
writeError(w, http.StatusBadGateway, "could not load the image")
|
||||
return
|
||||
@@ -154,6 +158,10 @@ func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemI
|
||||
writeError(w, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
if clientGaveUp(r.Context(), err) {
|
||||
writeError(w, statusClientClosedRequest, "the request was abandoned")
|
||||
return
|
||||
}
|
||||
s.log.Warn("sonarr image failed", "series_id", seriesID, "type", coverType, "error", err)
|
||||
writeError(w, http.StatusBadGateway, "could not load the image")
|
||||
return
|
||||
@@ -192,7 +200,17 @@ func copyImage(
|
||||
}
|
||||
|
||||
func expectedClientDisconnect(r *http.Request, err error) bool {
|
||||
if r.Context().Err() != nil ||
|
||||
return clientGaveUp(r.Context(), err)
|
||||
}
|
||||
|
||||
// clientGaveUp reports whether a failure is the television having walked away rather than
|
||||
// anything being wrong here.
|
||||
//
|
||||
// A cancellation is the *only* thing it treats as such. A deadline is our own patience
|
||||
// running out, which is a real failure with a real cause; conflating the two would hide
|
||||
// exactly the timeouts worth seeing.
|
||||
func clientGaveUp(ctx context.Context, err error) bool {
|
||||
if errors.Is(ctx.Err(), context.Canceled) ||
|
||||
errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, net.ErrClosed) ||
|
||||
errors.Is(err, syscall.EPIPE) ||
|
||||
|
||||
Reference in New Issue
Block a user