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)

This commit is contained in:
SergeantPanda 2026-06-24 17:02:31 -05:00
parent e2ceef5217
commit c5e5016728
3 changed files with 154 additions and 56 deletions

View file

@ -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.

View file

@ -333,6 +333,7 @@ export default function FloatingVideo() {
let hls = null;
if (isHls && Hls.isSupported()) {
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"
@ -349,11 +350,8 @@ export default function FloatingVideo() {
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,
// 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
@ -392,6 +390,11 @@ export default function FloatingVideo() {
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;

View file

@ -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(<FloatingVideo />);
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(<FloatingVideo />);
expect(
screen.getByText(/HLS initialization error: Illegal hls.js config/i)
).toBeInTheDocument();
});
it('should show metadata overlay', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');