From c5e50167285504e03487447d0bb3b2e0f8d12144 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 24 Jun 2026 17:02:31 -0500 Subject: [PATCH] fix(dvr): Fix in-progress DVR playback to prevent jumping to live edge and update FloatingVideo component for improved error handling. Added tests to verify HLS configuration behavior. (Fixes #1329) --- CHANGELOG.md | 1 + frontend/src/components/FloatingVideo.jsx | 115 +++++++++--------- .../__tests__/FloatingVideo.test.jsx | 94 ++++++++++++++ 3 files changed, 154 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 059e0d23..5365e79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **In-progress DVR playback no longer jumps to the live edge.** The floating video player still opens in-progress recordings at the start of the seekable range, but hls.js was configured with `liveMaxLatencyDurationCount: 10`, which forced the playhead forward to "now" shortly after playback began. Live-edge sync is now disabled for recording HLS so users can watch, pause, and scrub from the beginning while the recording continues. (Fixes #1329) - **M3U refresh recovers DB connections and account status after failures.** Celery workers now call `close_old_connections()` before and after every task; `refresh_single_m3u_account` also resets its DB connection at task start and retries account loads once after a connection reset (avoids opaque `list index out of range` errors from poisoned worker connections). Accounts leave `fetching`/`parsing` for a terminal `error` or `success` state when refresh aborts. Error-path status updates use `QuerySet.update()` on a clean connection instead of `save()` on a connection that may already be INTRANS after `refresh_m3u_groups` or batch ORM failures. Failed group downloads now set `error` instead of returning silently. Refresh start replaces stale `last_message` text. `refresh_account_profiles` releases DB connections after each profile failure. (Fixes #1338) - **EPG refresh recovers DB connections and source status after failures.** `refresh_epg_data` now mirrors the M3U hardening: connection reset at task start/end, retried source load after a poisoned-worker connection reset, `QuerySet.update()` for error status on a clean connection, and a `finally` guard that moves sources stuck in `fetching`/`parsing` to `error`. Programme index builds are skipped when programme parsing fails. `parse_channels_only` now persists the error status when a missing file has no URL to refetch. - **M3U `custom_properties` JSON normalization.** Legacy rows and some API writes could store a JSON-encoded string instead of an object in `custom_properties`, causing refresh, profile sync, and auto-sync to fail with `'str' object has no attribute 'get'`. M3U account, profile, and group-relation models normalize on `save()`; group settings bulk writes and read/merge paths use shared helpers in `core.utils` without re-parsing dict values. diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 507e0caf..e49c3bb2 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -333,65 +333,68 @@ export default function FloatingVideo() { let hls = null; if (isHls && Hls.isSupported()) { - hls = new Hls({ - // Open at the very beginning of the recording rather than the live - // edge. Without this, an in-progress recording would start at "now" - // and hide everything already recorded. hls.js applies this AFTER - // MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is - // also kept as a safety net for the Safari native-HLS path and for - // edge cases where this initial-position logic loses to the user's - // first interaction. - startPosition: 0, - // Allow seeking back to the start of the recording, regardless of - // current playhead position. Recordings can be hours long and the - // user may want to scrub anywhere; we explicitly disable buffer - // eviction by setting a very large back-buffer length. - backBufferLength: 90 * 60, // 90 minutes - maxBufferLength: 60, - maxMaxBufferLength: 600, - // For an in-progress recording, hls.js refreshes the playlist on - // its target-duration cadence; let it follow the live edge but keep - // the full DVR window seekable. - liveSyncDurationCount: 3, - liveMaxLatencyDurationCount: 10, - enableWorker: true, - lowLatencyMode: false, - // Inject the JWT into every playlist + segment XHR. Read the token - // from the auth store at request time rather than capturing the - // closure value at hls.js init, so a refreshed access token mid- - // playback is picked up on the next segment fetch. - xhrSetup: (xhr) => { - const token = useAuthStore.getState().accessToken; - if (token) { - xhr.setRequestHeader('Authorization', `Bearer ${token}`); - } - }, - }); - hls.on(Hls.Events.ERROR, (_evt, data) => { - if (data.fatal) { - // eslint-disable-next-line no-console - console.error('HLS fatal error:', data.type, data.details); - if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { - try { - hls.startLoad(); - } catch { - // ignore + try { + hls = new Hls({ + // Open at the very beginning of the recording rather than the live + // edge. Without this, an in-progress recording would start at "now" + // and hide everything already recorded. hls.js applies this AFTER + // MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is + // also kept as a safety net for the Safari native-HLS path and for + // edge cases where this initial-position logic loses to the user's + // first interaction. + startPosition: 0, + // Allow seeking back to the start of the recording, regardless of + // current playhead position. Recordings can be hours long and the + // user may want to scrub anywhere; we explicitly disable buffer + // eviction by setting a very large back-buffer length. + backBufferLength: 90 * 60, // 90 minutes + maxBufferLength: 60, + maxMaxBufferLength: 600, + // Leave liveMaxLatencyDurationCount at the hls.js default (Infinity). + // A finite value forces the playhead to the live edge during playback. + enableWorker: true, + lowLatencyMode: false, + // Inject the JWT into every playlist + segment XHR. Read the token + // from the auth store at request time rather than capturing the + // closure value at hls.js init, so a refreshed access token mid- + // playback is picked up on the next segment fetch. + xhrSetup: (xhr) => { + const token = useAuthStore.getState().accessToken; + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); } - } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { - try { - hls.recoverMediaError(); - } catch { - // ignore + }, + }); + hls.on(Hls.Events.ERROR, (_evt, data) => { + if (data.fatal) { + // eslint-disable-next-line no-console + console.error('HLS fatal error:', data.type, data.details); + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + try { + hls.startLoad(); + } catch { + // ignore + } + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + try { + hls.recoverMediaError(); + } catch { + // ignore + } + } else { + setLoadError(`HLS playback error: ${data.details || data.type}`); } - } else { - setLoadError(`HLS playback error: ${data.details || data.type}`); } - } - }); - hls.attachMedia(video); - hls.on(Hls.Events.MEDIA_ATTACHED, () => { - hls.loadSource(streamUrl); - }); + }); + hls.attachMedia(video); + hls.on(Hls.Events.MEDIA_ATTACHED, () => { + hls.loadSource(streamUrl); + }); + } catch (error) { + setIsLoading(false); + setLoadError(`HLS initialization error: ${error.message}`); + return; + } } else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) { // Safari path: native HLS support, including seekable DVR windows. video.src = streamUrl; diff --git a/frontend/src/components/__tests__/FloatingVideo.test.jsx b/frontend/src/components/__tests__/FloatingVideo.test.jsx index f99a3dcc..9683fc59 100644 --- a/frontend/src/components/__tests__/FloatingVideo.test.jsx +++ b/frontend/src/components/__tests__/FloatingVideo.test.jsx @@ -20,8 +20,49 @@ vi.mock('mpegts.js', () => ({ }, })); +const mockHlsInstance = { + attachMedia: vi.fn(), + loadSource: vi.fn(), + destroy: vi.fn(), + on: vi.fn(), +}; + +let capturedHlsConfig = null; +let forceHlsInitError = false; + +vi.mock('hls.js', () => ({ + default: class MockHls { + static isSupported = vi.fn(() => true); + + static Events = { + ERROR: 'error', + MEDIA_ATTACHED: 'media_attached', + }; + + static ErrorTypes = { + NETWORK_ERROR: 'networkError', + MEDIA_ERROR: 'mediaError', + }; + + constructor(config) { + if (forceHlsInitError) { + throw new Error('Illegal hls.js config'); + } + capturedHlsConfig = config; + Object.assign(this, mockHlsInstance); + } + }, +})); + +vi.mock('../../store/auth', () => ({ + default: { + getState: vi.fn(() => ({ accessToken: 'test-token' })), + }, +})); + // Import the mocked module after mocking const mpegts = (await import('mpegts.js')).default; +const Hls = (await import('hls.js')).default; // Mock react-draggable vi.mock('react-draggable', () => ({ @@ -53,6 +94,8 @@ describe('FloatingVideo', () => { beforeEach(async () => { vi.clearAllMocks(); + capturedHlsConfig = null; + forceHlsInitError = false; // Mock HTMLVideoElement methods HTMLVideoElement.prototype.load = vi.fn(); @@ -239,6 +282,57 @@ describe('FloatingVideo', () => { expect(video.poster).toBe('http://example.com/poster.jpg'); }); + it('should disable live-edge sync for in-progress recording HLS', () => { + useVideoStore.mockImplementation((selector) => { + const state = { + isVisible: true, + streamUrl: + 'http://example.com/api/channels/recordings/1/hls/index.m3u8', + contentType: 'vod', + metadata: { name: 'News Recording' }, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }); + + Hls.isSupported.mockReturnValue(true); + + render(); + + expect(capturedHlsConfig).toEqual( + expect.objectContaining({ + startPosition: 0, + }) + ); + expect(capturedHlsConfig).not.toHaveProperty( + 'liveMaxLatencyDurationCount' + ); + expect(capturedHlsConfig).not.toHaveProperty('liveSyncDurationCount'); + }); + + it('shows an in-player error when hls.js config is invalid', () => { + useVideoStore.mockImplementation((selector) => { + const state = { + isVisible: true, + streamUrl: + 'http://example.com/api/channels/recordings/1/hls/index.m3u8', + contentType: 'vod', + metadata: { name: 'News Recording' }, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }); + + Hls.isSupported.mockReturnValue(true); + forceHlsInitError = true; + + render(); + + expect( + screen.getByText(/HLS initialization error: Illegal hls.js config/i) + ).toBeInTheDocument(); + }); + it('should show metadata overlay', () => { const { container } = render(); const video = container.querySelector('video');