mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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:
parent
e2ceef5217
commit
c5e5016728
3 changed files with 154 additions and 56 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue