0.2.50
This commit is contained in:
@@ -566,6 +566,14 @@ func (s *Server) writeUpstreamError(
|
||||
case apiErr.StatusCode == http.StatusNotFound:
|
||||
writeError(w, http.StatusNotFound, "not found on the emby server")
|
||||
return
|
||||
case apiErr.StatusCode == http.StatusTooManyRequests:
|
||||
retryAfter := strings.TrimSpace(apiErr.RetryAfter)
|
||||
if retryAfter == "" {
|
||||
retryAfter = "60"
|
||||
}
|
||||
w.Header().Set("Retry-After", retryAfter)
|
||||
writeError(w, http.StatusTooManyRequests, "the emby server is receiving too many requests")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.loggerFor(ctx).Error(message, "error", err)
|
||||
|
||||
@@ -38,6 +38,41 @@ func TestBearerTokenSources(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteUpstreamErrorPreservesRateLimitDelay(t *testing.T) {
|
||||
s := &Server{}
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
s.writeUpstreamError(
|
||||
context.Background(),
|
||||
rec,
|
||||
&emby.APIError{StatusCode: http.StatusTooManyRequests, RetryAfter: "37"},
|
||||
"could not reach emby",
|
||||
)
|
||||
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want 429", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Retry-After"); got != "37" {
|
||||
t.Fatalf("Retry-After = %q, want 37", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteUpstreamErrorDefaultsMissingRateLimitDelay(t *testing.T) {
|
||||
s := &Server{}
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
s.writeUpstreamError(
|
||||
context.Background(),
|
||||
rec,
|
||||
&emby.APIError{StatusCode: http.StatusTooManyRequests},
|
||||
"could not reach emby",
|
||||
)
|
||||
|
||||
if got := rec.Header().Get("Retry-After"); got != "60" {
|
||||
t.Fatalf("Retry-After = %q, want 60", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashTokenIsStable(t *testing.T) {
|
||||
a, b := hashToken("token"), hashToken("token")
|
||||
if string(a) != string(b) {
|
||||
|
||||
@@ -92,16 +92,20 @@ 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"`
|
||||
URL string `json:"url"`
|
||||
ResumePositionMs int64 `json:"resumePositionMs"`
|
||||
Subtitles []playableSubtitle `json:"subtitles"`
|
||||
SubtitlesEnabled bool `json:"subtitlesEnabled"`
|
||||
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
|
||||
MediaSourceID string `json:"mediaSourceId"`
|
||||
PlaySessionID string `json:"playSessionId"`
|
||||
PlayMethod string `json:"playMethod"`
|
||||
Item json.RawMessage `json:"item"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
ResumePositionMs int64 `json:"resumePositionMs"`
|
||||
Subtitles []playableSubtitle `json:"subtitles"`
|
||||
SubtitlesEnabled bool `json:"subtitlesEnabled"`
|
||||
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
|
||||
MediaSourceID string `json:"mediaSourceId"`
|
||||
PlaySessionID string `json:"playSessionId"`
|
||||
PlayMethod string `json:"playMethod"`
|
||||
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
|
||||
TrickplayAvailable bool `json:"trickplayAvailable"`
|
||||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
||||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||||
}
|
||||
|
||||
// handlePlayback resolves what to actually play.
|
||||
@@ -366,16 +370,20 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, nextEpisodeResponse{
|
||||
Item: raw,
|
||||
Title: title,
|
||||
URL: streamURL,
|
||||
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
||||
Subtitles: subtitles,
|
||||
SubtitlesEnabled: subtitlesEnabled,
|
||||
SelectedSubtitleID: selectedSubtitleID,
|
||||
MediaSourceID: mediaSourceID,
|
||||
PlaySessionID: playSessionID,
|
||||
PlayMethod: playMethod,
|
||||
Item: raw,
|
||||
Title: title,
|
||||
URL: streamURL,
|
||||
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
||||
Subtitles: subtitles,
|
||||
SubtitlesEnabled: subtitlesEnabled,
|
||||
SelectedSubtitleID: selectedSubtitleID,
|
||||
MediaSourceID: mediaSourceID,
|
||||
PlaySessionID: playSessionID,
|
||||
PlayMethod: playMethod,
|
||||
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -729,8 +737,15 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
|
||||
)
|
||||
if err != nil {
|
||||
// A dropped progress report is not worth failing playback over; log and accept.
|
||||
log.Warn("playback report failed", "phase", phase, "error", err)
|
||||
// Progress is advisory and another reading follows in ten seconds. A final stop is
|
||||
// different: the television persists it in WorkManager specifically so an outage
|
||||
// cannot lose the final resume position. Returning success here would consume that
|
||||
// durable work and disable its bounded backoff.
|
||||
if phase == "stopped" {
|
||||
s.writeUpstreamError(r.Context(), w, err, "could not report playback stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Start and stop are the shape of an evening's viewing and belong in the normal log.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestStoppedPlaybackReportRemainsRetryableWhenEmbyIsDown(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
s := &Server{
|
||||
emby: emby.New(upstream.URL, upstream.URL, "Memby test", time.Second),
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/v1/playback/stopped",
|
||||
strings.NewReader(`{"itemId":"episode-1","positionMs":42000}`),
|
||||
)
|
||||
req.SetPathValue("phase", "stopped")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
s.handlePlaybackReport(rec, req, store.Session{EmbyUserID: "user-1", EmbyToken: "token"})
|
||||
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502 so the TV retries the durable stop", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,10 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
continue
|
||||
}
|
||||
if movie.ID > 0 {
|
||||
writeError(w, http.StatusConflict, "this movie is already in Radarr")
|
||||
// Idempotent under a lost response: OkHttp may replay a repeatable POST after
|
||||
// a connection reset. If the first request already added it, the retry is the
|
||||
// same successful action rather than an error shown to the viewer.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
|
||||
return
|
||||
}
|
||||
added, err := s.radarr.AddUnmonitored(r.Context(), movie)
|
||||
@@ -207,7 +210,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
continue
|
||||
}
|
||||
if show.ID > 0 {
|
||||
writeError(w, http.StatusConflict, "this series is already in Sonarr")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
|
||||
return
|
||||
}
|
||||
added, err := s.sonarr.AddUnmonitored(r.Context(), show)
|
||||
|
||||
@@ -126,6 +126,7 @@ type MediaStream struct {
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
RetryAfter string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
@@ -476,7 +477,7 @@ func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, im
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
resp.Body.Close()
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -509,7 +510,7 @@ func (c *Client) TrickplayBytes(
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")}
|
||||
}
|
||||
// Cap the read at what was asked for. A 200 means the range was ignored and the whole
|
||||
// file is on its way, which must not become a multi-megabyte read on a seek.
|
||||
@@ -548,7 +549,7 @@ func (c *Client) SubtitleBytes(
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")}
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, MaxSubtitleBytes))
|
||||
}
|
||||
@@ -689,7 +690,7 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
// Emby error bodies can echo request details; cap what we keep and never log it
|
||||
// alongside a token.
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
return &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")}
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
Reference in New Issue
Block a user