diff --git a/web/src/screens/channels/ChannelPreviewPanel.test.tsx b/web/src/screens/channels/ChannelPreviewPanel.test.tsx index e470862bf..ce46e11e3 100644 --- a/web/src/screens/channels/ChannelPreviewPanel.test.tsx +++ b/web/src/screens/channels/ChannelPreviewPanel.test.tsx @@ -235,6 +235,29 @@ describe('ChannelPreviewPanel', () => { expect(resetIptvTokenCacheMock).toHaveBeenCalled(); }); + it('on Retry re-mints the token and never reloads the stale-token URL', async () => { + // withIptvToken returns a DIFFERENT token per call (simulating a remint after resetIptvTokenCache). + let n = 0; + withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(`${url}?access_token=t${++n}`)); + + await renderPanel( + + ); + + expect(hlsMock.loadSource).toHaveBeenCalledTimes(1); + expect(hlsMock.loadSource).toHaveBeenNthCalledWith(1, '/iptv/channel/12.1.m3u8?access_token=t1'); + + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + await flush(); + + // Exactly two loads total: the initial (t1) and the retry (t2). The stale-token URL (t1) must NOT be + // reloaded when playToken bumps — the player unmounts until the fresh URL resolves. A regression that + // kept resolvedSrc across the bump would produce a third loadSource with the stale t1 URL. + expect(hlsMock.loadSource).toHaveBeenCalledTimes(2); + expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8?access_token=t2'); + expect(hlsMock.loadSource.mock.calls.filter((c) => c[0] === '/iptv/channel/12.1.m3u8?access_token=t1')).toHaveLength(1); + }); + it('clicking Retry clears a prior error', async () => { await renderPanel( diff --git a/web/src/screens/channels/ChannelPreviewPanel.tsx b/web/src/screens/channels/ChannelPreviewPanel.tsx index f08d36279..dcc4b5fc8 100644 --- a/web/src/screens/channels/ChannelPreviewPanel.tsx +++ b/web/src/screens/channels/ChannelPreviewPanel.tsx @@ -118,6 +118,11 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan // token (key rotated) or a stale "JWT disabled" latch (backend reconfigured since load) can't wedge // playback. An actually-expired token would refresh on its own, but this also covers those edges (#552). resetIptvTokenCache(); + // Null resolvedSrc FIRST so the player unmounts and does not reload the OLD tokened URL when playToken + // bumps below — the fresh URL comes from the async effect after withIptvToken re-mints. Skipping this + // would load the stale token once (duplicate session / a stale 401 that sticks the panel as failed even + // after the fresh stream succeeds). + setResolvedSrc(null); failedRef.current = false; setError(null); setState('starting');