From eb071d79c297fb5c0bb25b8f020668ce98c30cc8 Mon Sep 17 00:00:00 2001 From: blurbery <+blurbery@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:24:35 +1000 Subject: [PATCH 1/3] fix playback auth for tvOS episode transitions --- internal/api/handlers/playback_v3.go | 26 ++++++++- internal/api/handlers/playback_v3_test.go | 70 ++++++++++++++++++++++- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 5bcd326b1..57547e206 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -183,6 +183,26 @@ func headerAuthenticatedMediaV3(clientFeatures []string) mediaAuthModeV3 { } } +// mediaAuthModeForStartV3 keeps tvOS build 31 media requests independent of the +// short-lived access token. Its API client refreshes normally, but AetherEngine +// snapshots HTTP headers when an item loads and reuses them for range reads and +// internal reloads. An automatic episode transition can therefore retain the +// old bearer and begin receiving 401s while buffered playback continues. A +// session-bound transport capability preserves the selected playback route and +// is accepted before any stale Authorization header. +func mediaAuthModeForStartV3(req playback.StartRequestV3) mediaAuthModeV3 { + mode := headerAuthenticatedMediaV3(req.ClientFeatures) + ctx := req.ClientPlaybackContext + if mode.headerAuth && + playback.HasFeatureV3(req.ClientFeatures, playback.FeatureDeviceQuirksV3) && + strings.EqualFold(ctx.Device.Platform, "tvos") && + strings.EqualFold(ctx.FormFactor, "tv") && + strings.TrimSpace(ctx.AppBuild) == "31" { + return mediaAuthModeV3{} + } + return mode +} + // recipeCardStoreV3 is the shared control-plane store central hands a recipe // card to another Silo process through (*noderecipe.Store). One instance owns // one key space; the handler holds two of them, and what a stored card @@ -1174,7 +1194,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R } // A refused progressive remux is escalated before the decision is logged or // a session is opened, so the logged route is the one that will actually run. - escalated, escalateErr := h.escalateRefusedProgressiveRemuxV3(r.Context(), headerAuthenticatedMediaV3(req.ClientFeatures), + escalated, escalateErr := h.escalateRefusedProgressiveRemuxV3(r.Context(), mediaAuthModeForStartV3(req), func() playback.PlannerInputV3 { return h.plannerInputV3(r.Context(), req, requestedFile, effectiveFile, audioIndex, nil) }, result) @@ -1341,7 +1361,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr if result.Plan == nil { return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "The server produced no playback plan."} } - mode := headerAuthenticatedMediaV3(req.ClientFeatures) + mode := mediaAuthModeForStartV3(req) if checker, ok := h.sessionMgr.(transcodePermissionChecker); ok && (result.PlayMethod == playback.PlayTranscode || result.TranscodeAudio) { if err := checker.CheckTranscodingAllowed(r.Context(), userID, result.PlayMethod == playback.PlayTranscode); err != nil { reason := "transcoding_disabled" @@ -3763,7 +3783,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte // Media authentication is attempt-sticky (pinned in HandleReplanPlaybackV3), // so this mode always equals the one the attempt started under: a reused // transport cannot change the session's media security contract. - mode := headerAuthenticatedMediaV3(start.ClientFeatures) + mode := mediaAuthModeForStartV3(start) if !seekReanchor { // A freshly planned replan can land on the same refused progressive // remux a start would have; escalate it identically. A seek reanchor diff --git a/internal/api/handlers/playback_v3_test.go b/internal/api/handlers/playback_v3_test.go index 243515f38..e8005470c 100644 --- a/internal/api/handlers/playback_v3_test.go +++ b/internal/api/handlers/playback_v3_test.go @@ -903,11 +903,13 @@ func TestHandleStartPlaybackV3ReturnsExecutableDirectPlan(t *testing.T) { func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs(t *testing.T) { for _, test := range []struct { - name string - optIn bool - wantStream bool + name string + optIn bool + appleBuild31 bool + wantStream bool }{ {name: "opted-in URLs carry no playback credential", optIn: true}, + {name: "tvOS build 31 uses a session-bound playback credential", optIn: true, appleBuild31: true, wantStream: true}, {name: "legacy URL keeps restart token", wantStream: true}, } { t.Run(test.name, func(t *testing.T) { @@ -923,6 +925,12 @@ func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs if test.optIn { start.ClientFeatures = append(start.ClientFeatures, playback.FeatureHeaderAuthenticatedMediaV3) } + if test.appleBuild31 { + start.ClientFeatures = append(start.ClientFeatures, playback.FeatureDeviceQuirksV3) + start.ClientPlaybackContext.FormFactor = "tv" + start.ClientPlaybackContext.AppBuild = "31" + start.ClientPlaybackContext.Device.Platform = "tvos" + } subtitleIndex := 0 start.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", subtitleIndex) start.SubtitleTrackIndex = &subtitleIndex @@ -965,6 +973,62 @@ func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs } } +func TestMediaAuthModeForStartV3AppliesOnlyToAffectedTVOSClient(t *testing.T) { + base := playback.StartRequestV3{ + ClientFeatures: []string{ + playback.FeatureHeaderAuthenticatedMediaV3, + playback.FeatureDeviceQuirksV3, + }, + ClientPlaybackContext: playback.ClientPlaybackContextV3{ + FormFactor: "tv", + AppBuild: "31", + Device: playback.DeviceContextV3{ + Platform: "tvos", + }, + }, + } + + for _, test := range []struct { + name string + mutate func(*playback.StartRequestV3) + wantHeaderAuth bool + }{ + {name: "tvOS build 31 uses session capability"}, + { + name: "later tvOS build keeps header authentication", + mutate: func(req *playback.StartRequestV3) { + req.ClientPlaybackContext.AppBuild = "32" + }, + wantHeaderAuth: true, + }, + { + name: "client without device quirks keeps header authentication", + mutate: func(req *playback.StartRequestV3) { + req.ClientFeatures = []string{playback.FeatureHeaderAuthenticatedMediaV3} + }, + wantHeaderAuth: true, + }, + { + name: "non tvOS client keeps header authentication", + mutate: func(req *playback.StartRequestV3) { + req.ClientPlaybackContext.Device.Platform = "android" + }, + wantHeaderAuth: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + req := base + req.ClientFeatures = append([]string(nil), base.ClientFeatures...) + if test.mutate != nil { + test.mutate(&req) + } + if got := mediaAuthModeForStartV3(req); got.headerAuth != test.wantHeaderAuth { + t.Fatalf("headerAuth = %t, want %t", got.headerAuth, test.wantHeaderAuth) + } + }) + } +} + func TestHandleReplanPlaybackV3CannotDowngradeHeaderAuthenticatedAttempt(t *testing.T) { file := v3HandlerFixtureFile(t) manager := playback.NewSessionManager(0, 0) From aa3fdd9411c2c07681ce2c537e03e3b43d85339c Mon Sep 17 00:00:00 2001 From: blurbery <+blurbery@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:39:01 +1000 Subject: [PATCH 2/3] preserve Apple media auth across token refresh --- internal/api/handlers/playback_v3.go | 75 +++++++++++++++++------ internal/api/handlers/playback_v3_test.go | 61 +++++++++++++----- internal/api/middleware/auth.go | 3 + internal/api/middleware/auth_test.go | 35 ++++++++--- internal/streamtoken/token.go | 4 ++ 5 files changed, 136 insertions(+), 42 deletions(-) diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 57547e206..ba4f21268 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -89,6 +89,7 @@ type v3NodeCapabilityCache struct { type preparedTransportV3 struct { url string + headers map[string]string nodeURL string transportID string hwAccel string @@ -156,10 +157,17 @@ type playbackStartSideEffectsStateV3 struct { // it. Both bits are resolved once from the attempt's (pinned) feature list and // threaded down every branch rather than re-derived per URL builder. type mediaAuthModeV3 struct { - // headerAuth is header_authenticated_media_v1: no client-visible URL - // carries a signed playback credential, and the client authenticates every - // media request with its own access token instead. + // headerAuth is header_authenticated_media_v1: no client-visible URL carries + // a signed playback credential. Ordinarily the client authenticates media + // requests with its own access token; sessionHeaderCapability is the bounded + // compatibility exception described below. headerAuth bool + // sessionHeaderCapability keeps the signed, session-bound credential in a + // plan-supplied header. It is used only for clients whose media engine freezes + // request headers at load time, so a stale bearer cannot interrupt bytes while + // the API client independently refreshes. The URL remains credential-free and + // the response continues to honor header_authenticated_media_v1. + sessionHeaderCapability bool // proxyEgress is authorized_media_origins_v1 negotiated on top of // headerAuth: the client also honors credential-free absolute URLs on // server-designated proxy origins, so media bytes need not all egress from @@ -183,26 +191,52 @@ func headerAuthenticatedMediaV3(clientFeatures []string) mediaAuthModeV3 { } } -// mediaAuthModeForStartV3 keeps tvOS build 31 media requests independent of the -// short-lived access token. Its API client refreshes normally, but AetherEngine -// snapshots HTTP headers when an item loads and reuses them for range reads and -// internal reloads. An automatic episode transition can therefore retain the -// old bearer and begin receiving 401s while buffered playback continues. A -// session-bound transport capability preserves the selected playback route and -// is accepted before any stale Authorization header. +// mediaAuthModeForStartV3 keeps Apple build 31 media requests independent of +// the short-lived access token. The shared iOS/tvOS/macOS AetherEngine snapshots +// HTTP headers when an item loads and reuses them for range reads and internal +// reloads. An automatic episode transition can therefore retain the old bearer +// and begin receiving 401s while buffered playback continues. A session-bound +// header capability preserves the selected playback route and is accepted +// before any stale Authorization header. func mediaAuthModeForStartV3(req playback.StartRequestV3) mediaAuthModeV3 { mode := headerAuthenticatedMediaV3(req.ClientFeatures) ctx := req.ClientPlaybackContext + platform := strings.ToLower(strings.TrimSpace(ctx.Device.Platform)) + isApplePlatform := platform == "ios" || platform == "tvos" || platform == "macos" if mode.headerAuth && playback.HasFeatureV3(req.ClientFeatures, playback.FeatureDeviceQuirksV3) && - strings.EqualFold(ctx.Device.Platform, "tvos") && - strings.EqualFold(ctx.FormFactor, "tv") && + isApplePlatform && strings.TrimSpace(ctx.AppBuild) == "31" { - return mediaAuthModeV3{} + mode.proxyEgress = false + mode.sessionHeaderCapability = true } return mode } +func (h *PlaybackHandler) sessionCapabilityHeadersV3(mode mediaAuthModeV3, card playback.RecipeCard) map[string]string { + if !mode.sessionHeaderCapability { + return nil + } + token := h.signStreamClaims(card.ToClaims()) + if token == "" { + return nil + } + return map[string]string{streamtoken.Header: token} +} + +func applyPreparedTransportToPlanV3(plan *playback.PlanV3, transport preparedTransportV3) { + if plan == nil { + return + } + plan.Stream.URL = transport.url + if plan.Stream.Headers == nil { + plan.Stream.Headers = map[string]string{} + } + for name, value := range transport.headers { + plan.Stream.Headers[name] = value + } +} + // recipeCardStoreV3 is the shared control-plane store central hands a recipe // card to another Silo process through (*noderecipe.Store). One instance owns // one key space; the handler holds two of them, and what a stored card @@ -1417,7 +1451,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr abort() return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr) } - result.Plan.Stream.URL = transport.url + applyPreparedTransportToPlanV3(result.Plan, transport) if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &frozenRecipe); err != nil { transport.rollback() abort() @@ -1929,8 +1963,10 @@ func (h *PlaybackHandler) prepareIdentityTransportV3(r *http.Request, session *p streamURL = appendPlaybackQueryV3(streamURL, "seek", strconv.FormatFloat(seek, 'f', -1, 64)) } } + capabilityHeaders := h.sessionCapabilityHeadersV3(mode, identityRecipeCard(&routeSession)) return preparedTransportV3{ - url: streamURL, + url: streamURL, + headers: capabilityHeaders, commit: func() { if committed { return @@ -2678,10 +2714,10 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play } } } + card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, "", ts.Opts()) + card.OriginalStartedAt = session.StartedAt url := fmt.Sprintf("/playback/transcode/%s/master.m3u8", session.ID) if !mode.headerAuth { - card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, "", ts.Opts()) - card.OriginalStartedAt = session.StartedAt url = appendStreamToken(url, h.signSessionToken(card, mode.headerAuth)) } committed := false @@ -2689,6 +2725,7 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play previousTransportID := remoteTransportID(session) return preparedTransportV3{ url: url, + headers: h.sessionCapabilityHeadersV3(mode, card), hwAccel: ts.Opts().HWAccel, toneMapMode: ts.Opts().ToneMapMode, commit: func() { @@ -2881,7 +2918,7 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla previousNodeURL := session.TranscodeNodeURL previousTransportID := remoteTransportID(session) unlock := h.tm.LockSessionLifecycle(session.ID) - return preparedTransportV3{url: url, nodeURL: node.URL, transportID: transportID, hwAccel: confirmedHWAccel, toneMapMode: confirmedToneMapMode, commit: func() { + return preparedTransportV3{url: url, headers: h.sessionCapabilityHeadersV3(mode, card), nodeURL: node.URL, transportID: transportID, hwAccel: confirmedHWAccel, toneMapMode: confirmedToneMapMode, commit: func() { if committed { return } @@ -3876,7 +3913,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte artifactRecipe = frozenRecipe } } - result.Plan.Stream.URL = transport.url + applyPreparedTransportToPlanV3(result.Plan, transport) if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &artifactRecipe); err != nil { transport.rollback() return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err) diff --git a/internal/api/handlers/playback_v3_test.go b/internal/api/handlers/playback_v3_test.go index e8005470c..9d5e30956 100644 --- a/internal/api/handlers/playback_v3_test.go +++ b/internal/api/handlers/playback_v3_test.go @@ -903,13 +903,14 @@ func TestHandleStartPlaybackV3ReturnsExecutableDirectPlan(t *testing.T) { func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs(t *testing.T) { for _, test := range []struct { - name string - optIn bool - appleBuild31 bool - wantStream bool + name string + optIn bool + appleBuild31 bool + wantStream bool + wantCapabilityHeader bool }{ {name: "opted-in URLs carry no playback credential", optIn: true}, - {name: "tvOS build 31 uses a session-bound playback credential", optIn: true, appleBuild31: true, wantStream: true}, + {name: "Apple build 31 uses a session-bound header credential", optIn: true, appleBuild31: true, wantCapabilityHeader: true}, {name: "legacy URL keeps restart token", wantStream: true}, } { t.Run(test.name, func(t *testing.T) { @@ -951,9 +952,19 @@ func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs if got := streamURL.Query().Get(streamTokenParam); (got != "") != test.wantStream { t.Fatalf("stream token present = %v for URL %q, want %v", got != "", response.PlaybackPlan.Stream.URL, test.wantStream) } - if len(response.PlaybackPlan.Stream.Headers) != 0 { + capability := response.PlaybackPlan.Stream.Headers[streamtoken.Header] + if (capability != "") != test.wantCapabilityHeader { + t.Fatalf("session capability header present = %v, want %v", capability != "", test.wantCapabilityHeader) + } + if _, ok := response.PlaybackPlan.Stream.Headers["Authorization"]; ok { t.Fatalf("plan persisted bearer material in headers: %#v", response.PlaybackPlan.Stream.Headers) } + if capability != "" { + claims, verifyErr := streamtoken.Verify(capability, handler.JWTSecret) + if verifyErr != nil || claims.SessionID != response.SessionID || claims.MediaFileID != file.ID { + t.Fatalf("session capability claims = %#v, err = %v", claims, verifyErr) + } + } artifact := response.PlaybackPlan.Subtitle.Artifact if artifact == nil || len(response.PlaybackPlan.Subtitle.Inventory) != 1 { @@ -973,7 +984,7 @@ func TestHandleStartPlaybackV3NegotiatesHeaderAuthenticatedDirectAndSubtitleURLs } } -func TestMediaAuthModeForStartV3AppliesOnlyToAffectedTVOSClient(t *testing.T) { +func TestMediaAuthModeForStartV3AppliesOnlyToAffectedAppleClient(t *testing.T) { base := playback.StartRequestV3{ ClientFeatures: []string{ playback.FeatureHeaderAuthenticatedMediaV3, @@ -991,29 +1002,42 @@ func TestMediaAuthModeForStartV3AppliesOnlyToAffectedTVOSClient(t *testing.T) { for _, test := range []struct { name string mutate func(*playback.StartRequestV3) - wantHeaderAuth bool + wantCapability bool }{ - {name: "tvOS build 31 uses session capability"}, + {name: "tvOS build 31 uses session capability", wantCapability: true}, + { + name: "iOS build 31 uses session capability", + mutate: func(req *playback.StartRequestV3) { + req.ClientPlaybackContext.Device.Platform = "ios" + req.ClientPlaybackContext.FormFactor = "mobile" + }, + wantCapability: true, + }, + { + name: "macOS build 31 uses session capability", + mutate: func(req *playback.StartRequestV3) { + req.ClientPlaybackContext.Device.Platform = "macos" + req.ClientPlaybackContext.FormFactor = "desktop" + }, + wantCapability: true, + }, { name: "later tvOS build keeps header authentication", mutate: func(req *playback.StartRequestV3) { req.ClientPlaybackContext.AppBuild = "32" }, - wantHeaderAuth: true, }, { name: "client without device quirks keeps header authentication", mutate: func(req *playback.StartRequestV3) { req.ClientFeatures = []string{playback.FeatureHeaderAuthenticatedMediaV3} }, - wantHeaderAuth: true, }, { - name: "non tvOS client keeps header authentication", + name: "non Apple client keeps header authentication", mutate: func(req *playback.StartRequestV3) { req.ClientPlaybackContext.Device.Platform = "android" }, - wantHeaderAuth: true, }, } { t.Run(test.name, func(t *testing.T) { @@ -1022,8 +1046,15 @@ func TestMediaAuthModeForStartV3AppliesOnlyToAffectedTVOSClient(t *testing.T) { if test.mutate != nil { test.mutate(&req) } - if got := mediaAuthModeForStartV3(req); got.headerAuth != test.wantHeaderAuth { - t.Fatalf("headerAuth = %t, want %t", got.headerAuth, test.wantHeaderAuth) + got := mediaAuthModeForStartV3(req) + if !got.headerAuth { + t.Fatal("header-authenticated media contract was disabled") + } + if got.sessionHeaderCapability != test.wantCapability { + t.Fatalf("sessionHeaderCapability = %t, want %t", got.sessionHeaderCapability, test.wantCapability) + } + if got.sessionHeaderCapability && got.proxyEgress { + t.Fatal("session header capability must keep media on the API origin") } }) } diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index ad05d648e..0fb8fd17a 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -172,6 +172,9 @@ func (am *AuthMiddleware) RequireTransportAuth(secret string) func(http.Handler) regularAuth := am.RequireAuth(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get(streamtoken.QueryParameter) + if token == "" { + token = r.Header.Get(streamtoken.Header) + } sessionID := chi.URLParam(r, "session_id") if secret != "" && token != "" { claims, err := streamtoken.Verify(token, secret) diff --git a/internal/api/middleware/auth_test.go b/internal/api/middleware/auth_test.go index 5579fefa4..be6725012 100644 --- a/internal/api/middleware/auth_test.go +++ b/internal/api/middleware/auth_test.go @@ -55,14 +55,33 @@ func TestRequireTransportAuthUsesSessionBoundCapabilityWhenAccessTokenExpired(t router.With(middleware.RequireTransportAuth(secret)).Get("/stream/{session_id}", handler) router.With(middleware.RequireTransportAuth(secret)).Head("/stream/{session_id}", handler) - for _, method := range []string{http.MethodGet, http.MethodHead} { - req := httptest.NewRequest(method, "/stream/playback-1?"+streamtoken.QueryParameter+"="+url.QueryEscape(token), nil) - req.Header.Set("Authorization", "Bearer expired-access-token") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("%s status = %d, body = %s", method, rec.Code, rec.Body.String()) + for _, carrier := range []struct { + name string + apply func(*http.Request) + }{ + { + name: "query", + apply: func(req *http.Request) { + req.URL.RawQuery = streamtoken.QueryParameter + "=" + url.QueryEscape(token) + }, + }, + { + name: "header", + apply: func(req *http.Request) { + req.Header.Set(streamtoken.Header, token) + }, + }, + } { + for _, method := range []string{http.MethodGet, http.MethodHead} { + req := httptest.NewRequest(method, "/stream/playback-1", nil) + carrier.apply(req) + req.Header.Set("Authorization", "Bearer expired-access-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("%s %s status = %d, body = %s", carrier.name, method, rec.Code, rec.Body.String()) + } } } if validator.calls != 0 { diff --git a/internal/streamtoken/token.go b/internal/streamtoken/token.go index 78d2daba9..8c11430ef 100644 --- a/internal/streamtoken/token.go +++ b/internal/streamtoken/token.go @@ -12,6 +12,10 @@ const ( // the name here lets the API auth middleware and serve handlers agree without // either package depending on the other. QueryParameter = "st" + // Header carries the same session-bound capability when a client must keep + // credentials out of media URLs but cannot refresh a bearer captured by its + // media engine. It is checked before ordinary Authorization fallback. + Header = "X-Silo-Stream-Token" // PlayMethodDownload identifies a token minted only after the API has // authorized a file download. Proxy download routes reject playback tokens. PlayMethodDownload = "download" From 74a8f746c7030679dec237b88d907fb7fe26d949 Mon Sep 17 00:00:00 2001 From: blurbery <+blurbery@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:46:50 +1000 Subject: [PATCH 3/3] satisfy Apple platform lint boundary --- internal/api/handlers/playback_v3.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index ba4f21268..35696e51a 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -51,6 +51,9 @@ const ( transcodeStartFailedReasonV3 = "transcode_start_failed" seekRestorationPlayerV3 = "player_position" outputRouteChangedReasonV3 = "output_route_changed" + applePlatformIOSV3 = "ios" + applePlatformTVOSV3 = "tvos" + applePlatformMacOSV3 = "macos" // Failed capability fetches are memoized briefly so an unreachable node // costs one timeout per window instead of one per planning request. v3NodeCapabilityErrorTTL = 15 * time.Second @@ -202,7 +205,7 @@ func mediaAuthModeForStartV3(req playback.StartRequestV3) mediaAuthModeV3 { mode := headerAuthenticatedMediaV3(req.ClientFeatures) ctx := req.ClientPlaybackContext platform := strings.ToLower(strings.TrimSpace(ctx.Device.Platform)) - isApplePlatform := platform == "ios" || platform == "tvos" || platform == "macos" + isApplePlatform := platform == applePlatformIOSV3 || platform == applePlatformTVOSV3 || platform == applePlatformMacOSV3 if mode.headerAuth && playback.HasFeatureV3(req.ClientFeatures, playback.FeatureDeviceQuirksV3) && isApplePlatform &&