This commit is contained in:
ponzischeme89
2026-08-11 15:18:26 +12:00
parent 594159c0ae
commit 5e5849f985
33 changed files with 1143 additions and 241 deletions
+8
View File
@@ -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)
+35
View File
@@ -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) {
+36 -21
View File
@@ -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)
}
}
+5 -2
View File
@@ -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)
+5 -4
View File
@@ -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)