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');