From bda3c58a98d62bd408e25200ce4c2caf2d7e8774 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Thu, 18 Jun 2026 20:50:44 +0200 Subject: [PATCH] fix: reduce hls playback churn --- internal/server/observability.go | 3 ++ internal/server/server_test.go | 54 ++++++++++++++++++++++++++++++++ static/player/main.ts | 34 ++++++++++---------- static/player/video.ts | 18 +++++++++-- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/internal/server/observability.go b/internal/server/observability.go index 862f819..09e2803 100644 --- a/internal/server/observability.go +++ b/internal/server/observability.go @@ -44,6 +44,9 @@ func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc { if len(privateErrors) > 0 { logErr = privateErrors.Last().Err } + if route == "/watch/proxy/stream" && status < 400 && len(privateErrors) == 0 { + return + } if route != path { fields["route"] = route } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6380029..ecb97c8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -95,6 +95,60 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) { } } +func TestRequestLoggerSkipsSuccessfulStreamProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + + var logs bytes.Buffer + previousOutput := log.Writer() + log.SetOutput(&logs) + defer log.SetOutput(previousOutput) + + router := gin.New() + router.Use(RequestContextMiddleware()) + router.Use(RequestLogger(observability.NewMetrics())) + router.GET("/watch/proxy/stream", func(c *gin.Context) { + c.String(http.StatusOK, "segment") + }) + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/stream?token=abc", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if got := logs.String(); got != "" { + t.Fatalf("expected no successful stream proxy log, got %s", got) + } +} + +func TestRequestLoggerLogsFailedStreamProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + + var logs bytes.Buffer + previousOutput := log.Writer() + log.SetOutput(&logs) + defer log.SetOutput(previousOutput) + + router := gin.New() + router.Use(RequestContextMiddleware()) + router.Use(RequestLogger(observability.NewMetrics())) + router.GET("/watch/proxy/stream", func(c *gin.Context) { + c.Status(http.StatusBadGateway) + }) + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/stream?token=abc", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadGateway) + } + if got := logs.String(); !strings.Contains(got, " ERROR http 502 GET /watch/proxy/stream") { + t.Fatalf("expected failed stream proxy log, got %s", got) + } +} + func TestRespondErrorIncludesRequestContext(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/static/player/main.ts b/static/player/main.ts index 09a161a..f3c9a6a 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -156,13 +156,27 @@ const initPlayer = async (): Promise => { await ensurePreferredModeSource(signal); + const resumeAfterModeSwitch = (() => { + try { + const raw = sessionStorage.getItem("mal:resume-after-mode-switch"); + if (raw === null) return null; + sessionStorage.removeItem("mal:resume-after-mode-switch"); + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; + } catch (error) { + console.error("failed to parse resume state:", error); + return null; + } + })(); + const initialStartTime = resumeAfterModeSwitch ?? state.playback.startTimeSeconds; + // build video src from mode, token, and saved quality preference const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best"; const streamToken = state.playback.modeSources[state.playback.currentMode]?.token; if (streamToken) { const source = state.playback.modeSources[state.playback.currentMode]; const url = `${state.playback.streamURL}?mode=${encodeURIComponent(state.playback.currentMode)}&token=${encodeURIComponent(streamToken)}${source?.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`; - loadVideoSource(url, source?.type); + loadVideoSource(url, source?.type, initialStartTime); } updateSubtitleOptions(); @@ -190,20 +204,6 @@ const initPlayer = async (): Promise => { const resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0; const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2; - // Resume after a mode-switch page reload (best effort, session-scoped). - const resumeAfterModeSwitch = (() => { - try { - const raw = sessionStorage.getItem("mal:resume-after-mode-switch"); - if (raw === null) return null; - sessionStorage.removeItem("mal:resume-after-mode-switch"); - const parsed = Number(raw); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; - } catch (error) { - console.error("failed to parse resume state:", error); - return null; - } - })(); - if (resumeAfterModeSwitch !== null) { const clamped = bounds.duration > 0 ? Math.min(resumeAfterModeSwitch, bounds.duration) : 0; if (clamped > 0) { @@ -425,7 +425,9 @@ const initPlayer = async (): Promise => { } setupThumbnails(); - void hydrateAlternateMode(signal); + window.setTimeout(() => { + if (!signal.aborted) void hydrateAlternateMode(signal); + }, 3000); document.body.addEventListener( "htmx:beforeSwap", diff --git a/static/player/video.ts b/static/player/video.ts index e20fa7d..98c6e82 100644 --- a/static/player/video.ts +++ b/static/player/video.ts @@ -38,7 +38,7 @@ const shouldUseHLS = (type: string | undefined, url: string): boolean => { * Some browsers can be flaky when switching between HLS URLs while playing. * Clearing `src` first ensures the media element fully resets before the new URL is set. */ -export const loadVideoSource = (url: string, type?: string): void => { +export const loadVideoSource = (url: string, type?: string, startTimeSeconds?: number): void => { if (!url) return; const wasPlaying = !state.elements.video.paused; @@ -49,10 +49,24 @@ export const loadVideoSource = (url: string, type?: string): void => { destroyVideoSource(); if (shouldUseHLS(type, url) && Hls.isSupported()) { - hls = new Hls(); + hls = new Hls({ + autoStartLoad: false, + backBufferLength: 30, + maxBufferLength: 18, + maxMaxBufferLength: 30, + maxBufferSize: 20 * 1000 * 1000, + startFragPrefetch: false, + }); stopHLSProfile = attachHLSProfile(hls, state.elements.video); hls.loadSource(url); hls.attachMedia(state.elements.video); + hls.once(Hls.Events.MEDIA_ATTACHED, () => { + const startPosition = + startTimeSeconds !== undefined && Number.isFinite(startTimeSeconds) && startTimeSeconds > 0 + ? startTimeSeconds + : -1; + hls?.startLoad(startPosition); + }); } else { state.elements.video.src = url; state.elements.video.load();