From 14c54b60f159a0c5fa18d4ec877f559fb276102c Mon Sep 17 00:00:00 2001 From: nemes Date: Fri, 29 May 2026 13:20:35 +0200 Subject: [PATCH 1/4] fix(frontend): apply output_profile and output_format in all live stream player calls getShowVideoUrl was building raw proxy URLs without calling buildLiveStreamUrl, so the web-player output profile preference stored in localStorage was silently ignored when starting a live stream from the TV Guide, Program Detail modal, DVR page, Recording Details modal, and Recording Card. Fix: import buildLiveStreamUrl in RecordingCardUtils and call it at the end of getShowVideoUrl so every caller inherits the correct output_format=mpegts param and any configured output_profile automatically. Fixes #1304 --- .../src/utils/cards/RecordingCardUtils.js | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/frontend/src/utils/cards/RecordingCardUtils.js b/frontend/src/utils/cards/RecordingCardUtils.js index 4920c8ba..68b0b3ce 100644 --- a/frontend/src/utils/cards/RecordingCardUtils.js +++ b/frontend/src/utils/cards/RecordingCardUtils.js @@ -1,30 +1,24 @@ -import API from '../../api.js'; -import useChannelsStore from '../../store/channels.jsx'; -import defaultLogo from '../../images/logo.png'; -import { formatSeasonEpisode } from '../guideUtils.js'; +import API from "../../api.js"; +import useChannelsStore from "../../store/channels.jsx"; +import defaultLogo from "../../images/logo.png"; +import { formatSeasonEpisode } from "../guideUtils.js"; +import { buildLiveStreamUrl } from "../components/FloatingVideoUtils.js"; export const removeRecording = (id) => { - // Optimistically remove immediately from UI try { useChannelsStore.getState().removeRecording(id); } catch (error) { - console.error('Failed to optimistically remove recording', error); + console.error("Failed to optimistically remove recording", error); } - // Fire-and-forget server delete; websocket will keep others in sync API.deleteRecording(id).catch(() => { - // On failure, fallback to refetch to restore state try { useChannelsStore.getState().fetchRecordings(); } catch (error) { - console.error('Failed to refresh recordings after delete', error); + console.error("Failed to refresh recordings after delete", error); } }); }; -/** - * Resolve the channel logo cache URL from either a full channel object - * (has logo.cache_url) or a summary object (has logo_id integer). - */ export const getChannelLogoUrl = (channel) => { if (!channel) return null; let url = channel.logo_id @@ -32,8 +26,8 @@ export const getChannelLogoUrl = (channel) => { : channel.logo?.cache_url || null; if ( url && - url.startsWith('/') && - typeof import.meta !== 'undefined' && + url.startsWith("/") && + typeof import.meta !== "undefined" && import.meta.env && import.meta.env.DEV ) { @@ -48,10 +42,10 @@ export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => { : customProperties?.poster_url || posterUrl || null; if ( purl && - typeof import.meta !== 'undefined' && + typeof import.meta !== "undefined" && import.meta.env && import.meta.env.DEV && - purl.startsWith('/') + purl.startsWith("/") ) { purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`; } @@ -60,10 +54,10 @@ export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => { export const getShowVideoUrl = (channel, env_mode) => { let url = `/proxy/ts/stream/${channel.uuid}`; - if (env_mode === 'dev') { + if (env_mode === "dev") { url = `${window.location.protocol}//${window.location.hostname}:5656${url}`; } - return url; + return buildLiveStreamUrl(url); }; export const runComSkip = async (recording) => { @@ -86,23 +80,23 @@ export const deleteSeriesAndRule = async (seriesInfo) => { const { tvg_id, title } = seriesInfo; try { await API.bulkRemoveSeriesRecordings({ - tvg_id: tvg_id || '', + tvg_id: tvg_id || "", title, - scope: 'title', + scope: "title", }); } catch (error) { - console.error('Failed to remove series recordings', error); + console.error("Failed to remove series recordings", error); } try { await API.deleteSeriesRule(tvg_id, title); } catch (error) { - console.error('Failed to delete series rule', error); + console.error("Failed to delete series rule", error); } }; export const getRecordingUrl = (customProps, env_mode) => { let fileUrl = customProps?.file_url || customProps?.output_file_url; - if (fileUrl && env_mode === 'dev' && fileUrl.startsWith('/')) { + if (fileUrl && env_mode === "dev" && fileUrl.startsWith("/")) { fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`; } return fileUrl; From 17ce5323260345ec080182e13de4249d38d4a7e1 Mon Sep 17 00:00:00 2001 From: nemes Date: Fri, 29 May 2026 13:23:22 +0200 Subject: [PATCH 2/4] fix(frontend): update getShowVideoUrl tests to verify output_format and output_profile --- .../__tests__/RecordingCardUtils.test.js | 380 ++---------------- 1 file changed, 33 insertions(+), 347 deletions(-) diff --git a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js index 2af98a1c..d0b843ee 100644 --- a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js @@ -1,14 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { - removeRecording, - getPosterUrl, - getShowVideoUrl, - runComSkip, - deleteRecordingById, - deleteSeriesAndRule, - getRecordingUrl, - getSeasonLabel, - getSeriesInfo, + removeRecording, getPosterUrl, getShowVideoUrl, runComSkip, + deleteRecordingById, deleteSeriesAndRule, getRecordingUrl, getSeasonLabel, getSeriesInfo, } from '../RecordingCardUtils'; import API from '../../../api'; import useChannelsStore from '../../../store/channels'; @@ -17,160 +10,50 @@ vi.mock('../../../api'); vi.mock('../../../store/channels'); describe('RecordingCardUtils', () => { - beforeEach(() => { - vi.clearAllMocks(); + beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); }); + + describe('getShowVideoUrl', () => { + it('returns proxy URL with mpegts output format', () => { + expect(getShowVideoUrl({ uuid: 'channel-123' }, 'production')) + .toBe('/proxy/ts/stream/channel-123?output_format=mpegts'); + }); + it('includes output_profile when set in player prefs', () => { + localStorage.setItem('dispatcharr-player-prefs', JSON.stringify({ webPlayerOutputProfileId: 5 })); + expect(getShowVideoUrl({ uuid: 'channel-123' }, 'production')) + .toBe('/proxy/ts/stream/channel-123?output_format=mpegts&output_profile=5'); + }); + it('prepends dev server URL in dev mode with output params', () => { + expect(getShowVideoUrl({ uuid: 'channel-123' }, 'dev')).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123\?output_format=mpegts$/); + }); }); describe('removeRecording', () => { - let mockRemoveRecording; - let mockFetchRecordings; - + let mockRemoveRecording, mockFetchRecordings; beforeEach(() => { - mockRemoveRecording = vi.fn(); - mockFetchRecordings = vi.fn(); - useChannelsStore.getState = vi.fn(() => ({ - removeRecording: mockRemoveRecording, - fetchRecordings: mockFetchRecordings, - })); + mockRemoveRecording = vi.fn(); mockFetchRecordings = vi.fn(); + useChannelsStore.getState = vi.fn(() => ({ removeRecording: mockRemoveRecording, fetchRecordings: mockFetchRecordings })); }); - it('optimistically removes recording from store', () => { API.deleteRecording.mockResolvedValue(); - removeRecording('recording-1'); - expect(mockRemoveRecording).toHaveBeenCalledWith('recording-1'); }); - it('calls API to delete recording', () => { API.deleteRecording.mockResolvedValue(); - removeRecording('recording-1'); - expect(API.deleteRecording).toHaveBeenCalledWith('recording-1'); }); - - it('handles optimistic removal error', () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(); - mockRemoveRecording.mockImplementation(() => { - throw new Error('Store error'); - }); - API.deleteRecording.mockResolvedValue(); - - removeRecording('recording-1'); - - expect(consoleError).toHaveBeenCalledWith( - 'Failed to optimistically remove recording', - expect.any(Error) - ); - consoleError.mockRestore(); - }); - it('refetches recordings when API delete fails', async () => { API.deleteRecording.mockRejectedValue(new Error('Delete failed')); - removeRecording('recording-1'); - - await vi.waitFor(() => { - expect(mockFetchRecordings).toHaveBeenCalled(); - }); - }); - - it('handles fetch error after failed delete', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(); - API.deleteRecording.mockRejectedValue(new Error('Delete failed')); - mockFetchRecordings.mockImplementation(() => { - throw new Error('Fetch error'); - }); - - removeRecording('recording-1'); - - await vi.waitFor(() => { - expect(consoleError).toHaveBeenCalledWith( - 'Failed to refresh recordings after delete', - expect.any(Error) - ); - }); - consoleError.mockRestore(); - }); - }); - - describe('getPosterUrl', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('returns logo URL when posterLogoId is provided', () => { - vi.stubEnv('DEV', false); - const result = getPosterUrl('logo-123', {}, ''); - - expect(result).toBe('/api/channels/logos/logo-123/cache/'); - }); - - it('returns custom poster_url when no posterLogoId', () => { - vi.stubEnv('DEV', false); - const customProps = { poster_url: '/custom/poster.jpg' }; - const result = getPosterUrl(null, customProps, ''); - - expect(result).toBe('/custom/poster.jpg'); - }); - - it('returns posterUrl when no posterLogoId or custom poster_url', () => { - vi.stubEnv('DEV', false); - const result = getPosterUrl(null, {}, '/fallback/poster.jpg'); - - expect(result).toBe('/fallback/poster.jpg'); - }); - - it('returns default logo when no parameters provided', () => { - vi.stubEnv('DEV', false); - const result = getPosterUrl(null, {}, ''); - - // Falls back to the imported default Dispatcharr logo asset - expect(result).toBeTruthy(); - expect(result).toContain('logo'); - }); - - it('prepends dev server URL in dev mode for relative paths', () => { - vi.stubEnv('DEV', true); - const result = getPosterUrl(null, {}, '/poster.jpg'); - - expect(result).toMatch(/^https?:\/\/.*:5656\/poster\.jpg$/); - }); - - it('does not prepend dev URL for absolute URLs', () => { - vi.stubEnv('DEV', true); - const result = getPosterUrl(null, {}, 'https://example.com/poster.jpg'); - - expect(result).toBe('https://example.com/poster.jpg'); - }); - }); - - describe('getShowVideoUrl', () => { - it('returns proxy URL for channel', () => { - const channel = { uuid: 'channel-123' }; - const result = getShowVideoUrl(channel, 'production'); - - expect(result).toBe('/proxy/ts/stream/channel-123'); - }); - - it('prepends dev server URL in dev mode', () => { - const channel = { uuid: 'channel-123' }; - const result = getShowVideoUrl(channel, 'dev'); - - expect(result).toMatch( - /^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/ - ); + await vi.waitFor(() => { expect(mockFetchRecordings).toHaveBeenCalled(); }); }); }); describe('runComSkip', () => { it('calls API runComskip with recording id', async () => { API.runComskip.mockResolvedValue(); - const recording = { id: 'recording-1' }; - - await runComSkip(recording); - + await runComSkip({ id: 'recording-1' }); expect(API.runComskip).toHaveBeenCalledWith('recording-1'); }); }); @@ -178,231 +61,34 @@ describe('RecordingCardUtils', () => { describe('deleteRecordingById', () => { it('calls API deleteRecording with id', async () => { API.deleteRecording.mockResolvedValue(); - await deleteRecordingById('recording-1'); - expect(API.deleteRecording).toHaveBeenCalledWith('recording-1'); }); }); - describe('deleteSeriesAndRule', () => { - it('removes series recordings and deletes series rule', async () => { - API.bulkRemoveSeriesRecordings.mockResolvedValue(); - API.deleteSeriesRule.mockResolvedValue(); - const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' }; - - await deleteSeriesAndRule(seriesInfo); - - expect(API.bulkRemoveSeriesRecordings).toHaveBeenCalledWith({ - tvg_id: 'series-123', - title: 'Test Series', - scope: 'title', - }); - expect(API.deleteSeriesRule).toHaveBeenCalledWith( - 'series-123', - 'Test Series' - ); - }); - - it('works for title-only rules with no tvg_id', async () => { - API.bulkRemoveSeriesRecordings.mockResolvedValue(); - API.deleteSeriesRule.mockResolvedValue(); - const seriesInfo = { tvg_id: undefined, title: 'Title-Only Show' }; - - await deleteSeriesAndRule(seriesInfo); - - expect(API.bulkRemoveSeriesRecordings).toHaveBeenCalledWith({ - tvg_id: '', - title: 'Title-Only Show', - scope: 'title', - }); - expect(API.deleteSeriesRule).toHaveBeenCalledWith( - undefined, - 'Title-Only Show' - ); - }); - - it('handles bulk remove error gracefully', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(); - API.bulkRemoveSeriesRecordings.mockRejectedValue( - new Error('Bulk remove failed') - ); - API.deleteSeriesRule.mockResolvedValue(); - const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' }; - - await deleteSeriesAndRule(seriesInfo); - - expect(consoleError).toHaveBeenCalledWith( - 'Failed to remove series recordings', - expect.any(Error) - ); - expect(API.deleteSeriesRule).toHaveBeenCalled(); - consoleError.mockRestore(); - }); - - it('handles delete rule error gracefully', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(); - API.bulkRemoveSeriesRecordings.mockResolvedValue(); - API.deleteSeriesRule.mockRejectedValue(new Error('Delete rule failed')); - const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' }; - - await deleteSeriesAndRule(seriesInfo); - - expect(consoleError).toHaveBeenCalledWith( - 'Failed to delete series rule', - expect.any(Error) - ); - consoleError.mockRestore(); - }); - }); - describe('getRecordingUrl', () => { it('returns file_url when available', () => { - const customProps = { file_url: '/recordings/file.mp4' }; - const result = getRecordingUrl(customProps, 'production'); - - expect(result).toBe('/recordings/file.mp4'); + expect(getRecordingUrl({ file_url: '/recordings/file.mp4' }, 'production')).toBe('/recordings/file.mp4'); }); - - it('returns output_file_url when file_url is not available', () => { - const customProps = { output_file_url: '/output/file.mp4' }; - const result = getRecordingUrl(customProps, 'production'); - - expect(result).toBe('/output/file.mp4'); + it('returns undefined when no file URL available', () => { + expect(getRecordingUrl({}, 'production')).toBeUndefined(); }); - - it('prefers file_url over output_file_url', () => { - const customProps = { - file_url: '/recordings/file.mp4', - output_file_url: '/output/file.mp4', - }; - const result = getRecordingUrl(customProps, 'production'); - - expect(result).toBe('/recordings/file.mp4'); - }); - - it('prepends dev server URL in dev mode for relative paths', () => { - const customProps = { file_url: '/recordings/file.mp4' }; - const result = getRecordingUrl(customProps, 'dev'); - - expect(result).toMatch(/^https?:\/\/.*:5656\/recordings\/file\.mp4$/); - }); - - it('does not prepend dev URL for absolute URLs', () => { - const customProps = { file_url: 'https://example.com/file.mp4' }; - const result = getRecordingUrl(customProps, 'dev'); - - expect(result).toBe('https://example.com/file.mp4'); - }); - - it('returns undefined when no file URL is available', () => { - const result = getRecordingUrl({}, 'production'); - - expect(result).toBeUndefined(); - }); - it('handles null customProps', () => { - const result = getRecordingUrl(null, 'production'); - - expect(result).toBeUndefined(); + expect(getRecordingUrl(null, 'production')).toBeUndefined(); }); }); describe('getSeasonLabel', () => { - it('returns formatted season and episode label', () => { - const result = getSeasonLabel(1, 5, null); - - expect(result).toBe('S01E05'); - }); - - it('pads single digit season and episode numbers', () => { - const result = getSeasonLabel(2, 3, null); - - expect(result).toBe('S02E03'); - }); - - it('handles multi-digit season and episode numbers', () => { - const result = getSeasonLabel(12, 34, null); - - expect(result).toBe('S12E34'); - }); - - it('returns onscreen value when season or episode is missing', () => { - const result = getSeasonLabel(null, 5, 'Episode 5'); - - expect(result).toBe('Episode 5'); - }); - - it('returns onscreen value when only episode is missing', () => { - const result = getSeasonLabel(1, null, 'Special'); - - expect(result).toBe('Special'); - }); - - it('returns null when no season, episode, or onscreen provided', () => { - const result = getSeasonLabel(null, null, null); - - expect(result).toBeNull(); - }); - - it('returns formatted label even when onscreen is provided', () => { - const result = getSeasonLabel(1, 5, 'Episode 5'); - - expect(result).toBe('S01E05'); - }); + it('returns formatted label', () => { expect(getSeasonLabel(1, 5, null)).toBe('S01E05'); }); + it('returns onscreen when season missing', () => { expect(getSeasonLabel(null, 5, 'Episode 5')).toBe('Episode 5'); }); + it('returns null when nothing provided', () => { expect(getSeasonLabel(null, null, null)).toBeNull(); }); }); describe('getSeriesInfo', () => { - it('extracts tvg_id and title from program', () => { - const customProps = { - program: { tvg_id: 'series-123', title: 'Test Series' }, - }; - const result = getSeriesInfo(customProps); - - expect(result).toEqual({ - tvg_id: 'series-123', - title: 'Test Series', - }); - }); - - it('handles missing program object', () => { - const customProps = {}; - const result = getSeriesInfo(customProps); - - expect(result).toEqual({ - tvg_id: undefined, - title: undefined, - }); - }); - - it('handles null customProps', () => { - const result = getSeriesInfo(null); - - expect(result).toEqual({ - tvg_id: undefined, - title: undefined, - }); - }); - - it('handles undefined customProps', () => { - const result = getSeriesInfo(undefined); - - expect(result).toEqual({ - tvg_id: undefined, - title: undefined, - }); - }); - - it('handles partial program data', () => { - const customProps = { - program: { tvg_id: 'series-123' }, - }; - const result = getSeriesInfo(customProps); - - expect(result).toEqual({ - tvg_id: 'series-123', - title: undefined, - }); + it('extracts tvg_id and title', () => { + expect(getSeriesInfo({ program: { tvg_id: 'series-123', title: 'Test Series' } })) + .toEqual({ tvg_id: 'series-123', title: 'Test Series' }); }); + it('handles null', () => { expect(getSeriesInfo(null)).toEqual({ tvg_id: undefined, title: undefined }); }); }); }); From 98d7acd3c0543d9abe0cb47110b6b5bca14430e8 Mon Sep 17 00:00:00 2001 From: nemes Date: Fri, 29 May 2026 17:32:22 +0200 Subject: [PATCH 3/4] fix(frontend): apply output_profile and output_format in all live stream player calls getShowVideoUrl was returning a raw proxy URL without calling buildLiveStreamUrl, so the web-player output profile preference stored in localStorage was ignored when starting a live stream from the TV Guide, Program Detail modal, DVR page, Recording Details modal, and Recording Card. Fixes #1304 --- .../src/utils/cards/RecordingCardUtils.js | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/frontend/src/utils/cards/RecordingCardUtils.js b/frontend/src/utils/cards/RecordingCardUtils.js index 68b0b3ce..60d527bd 100644 --- a/frontend/src/utils/cards/RecordingCardUtils.js +++ b/frontend/src/utils/cards/RecordingCardUtils.js @@ -1,24 +1,31 @@ -import API from "../../api.js"; -import useChannelsStore from "../../store/channels.jsx"; -import defaultLogo from "../../images/logo.png"; -import { formatSeasonEpisode } from "../guideUtils.js"; -import { buildLiveStreamUrl } from "../components/FloatingVideoUtils.js"; +import API from '../../api.js'; +import useChannelsStore from '../../store/channels.jsx'; +import defaultLogo from '../../images/logo.png'; +import { formatSeasonEpisode } from '../guideUtils.js'; +import { buildLiveStreamUrl } from '../components/FloatingVideoUtils.js'; export const removeRecording = (id) => { + // Optimistically remove immediately from UI try { useChannelsStore.getState().removeRecording(id); } catch (error) { - console.error("Failed to optimistically remove recording", error); + console.error('Failed to optimistically remove recording', error); } + // Fire-and-forget server delete; websocket will keep others in sync API.deleteRecording(id).catch(() => { + // On failure, fallback to refetch to restore state try { useChannelsStore.getState().fetchRecordings(); } catch (error) { - console.error("Failed to refresh recordings after delete", error); + console.error('Failed to refresh recordings after delete', error); } }); }; +/** + * Resolve the channel logo cache URL from either a full channel object + * (has logo.cache_url) or a summary object (has logo_id integer). + */ export const getChannelLogoUrl = (channel) => { if (!channel) return null; let url = channel.logo_id @@ -26,8 +33,8 @@ export const getChannelLogoUrl = (channel) => { : channel.logo?.cache_url || null; if ( url && - url.startsWith("/") && - typeof import.meta !== "undefined" && + url.startsWith('/') && + typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV ) { @@ -42,10 +49,10 @@ export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => { : customProperties?.poster_url || posterUrl || null; if ( purl && - typeof import.meta !== "undefined" && + typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV && - purl.startsWith("/") + purl.startsWith('/') ) { purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`; } @@ -54,7 +61,7 @@ export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => { export const getShowVideoUrl = (channel, env_mode) => { let url = `/proxy/ts/stream/${channel.uuid}`; - if (env_mode === "dev") { + if (env_mode === 'dev') { url = `${window.location.protocol}//${window.location.hostname}:5656${url}`; } return buildLiveStreamUrl(url); @@ -80,23 +87,23 @@ export const deleteSeriesAndRule = async (seriesInfo) => { const { tvg_id, title } = seriesInfo; try { await API.bulkRemoveSeriesRecordings({ - tvg_id: tvg_id || "", + tvg_id: tvg_id || '', title, - scope: "title", + scope: 'title', }); } catch (error) { - console.error("Failed to remove series recordings", error); + console.error('Failed to remove series recordings', error); } try { await API.deleteSeriesRule(tvg_id, title); } catch (error) { - console.error("Failed to delete series rule", error); + console.error('Failed to delete series rule', error); } }; export const getRecordingUrl = (customProps, env_mode) => { let fileUrl = customProps?.file_url || customProps?.output_file_url; - if (fileUrl && env_mode === "dev" && fileUrl.startsWith("/")) { + if (fileUrl && env_mode === 'dev' && fileUrl.startsWith('/')) { fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`; } return fileUrl; From c717df50f91c92c9eb1bfdceef596b4d66403218 Mon Sep 17 00:00:00 2001 From: nemes Date: Fri, 29 May 2026 17:32:24 +0200 Subject: [PATCH 4/4] fix(frontend): update getShowVideoUrl tests to expect output_format and output_profile --- .../__tests__/RecordingCardUtils.test.js | 394 ++++++++++++++++-- 1 file changed, 361 insertions(+), 33 deletions(-) diff --git a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js index d0b843ee..2e601120 100644 --- a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js @@ -1,7 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { - removeRecording, getPosterUrl, getShowVideoUrl, runComSkip, - deleteRecordingById, deleteSeriesAndRule, getRecordingUrl, getSeasonLabel, getSeriesInfo, + removeRecording, + getPosterUrl, + getShowVideoUrl, + runComSkip, + deleteRecordingById, + deleteSeriesAndRule, + getRecordingUrl, + getSeasonLabel, + getSeriesInfo, } from '../RecordingCardUtils'; import API from '../../../api'; import useChannelsStore from '../../../store/channels'; @@ -10,50 +17,174 @@ vi.mock('../../../api'); vi.mock('../../../store/channels'); describe('RecordingCardUtils', () => { - beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); }); - - describe('getShowVideoUrl', () => { - it('returns proxy URL with mpegts output format', () => { - expect(getShowVideoUrl({ uuid: 'channel-123' }, 'production')) - .toBe('/proxy/ts/stream/channel-123?output_format=mpegts'); - }); - it('includes output_profile when set in player prefs', () => { - localStorage.setItem('dispatcharr-player-prefs', JSON.stringify({ webPlayerOutputProfileId: 5 })); - expect(getShowVideoUrl({ uuid: 'channel-123' }, 'production')) - .toBe('/proxy/ts/stream/channel-123?output_format=mpegts&output_profile=5'); - }); - it('prepends dev server URL in dev mode with output params', () => { - expect(getShowVideoUrl({ uuid: 'channel-123' }, 'dev')).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123\?output_format=mpegts$/); - }); + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); }); describe('removeRecording', () => { - let mockRemoveRecording, mockFetchRecordings; + let mockRemoveRecording; + let mockFetchRecordings; + beforeEach(() => { - mockRemoveRecording = vi.fn(); mockFetchRecordings = vi.fn(); - useChannelsStore.getState = vi.fn(() => ({ removeRecording: mockRemoveRecording, fetchRecordings: mockFetchRecordings })); + mockRemoveRecording = vi.fn(); + mockFetchRecordings = vi.fn(); + useChannelsStore.getState = vi.fn(() => ({ + removeRecording: mockRemoveRecording, + fetchRecordings: mockFetchRecordings, + })); }); + it('optimistically removes recording from store', () => { API.deleteRecording.mockResolvedValue(); + removeRecording('recording-1'); + expect(mockRemoveRecording).toHaveBeenCalledWith('recording-1'); }); + it('calls API to delete recording', () => { API.deleteRecording.mockResolvedValue(); + removeRecording('recording-1'); + expect(API.deleteRecording).toHaveBeenCalledWith('recording-1'); }); + + it('handles optimistic removal error', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(); + mockRemoveRecording.mockImplementation(() => { + throw new Error('Store error'); + }); + API.deleteRecording.mockResolvedValue(); + + removeRecording('recording-1'); + + expect(consoleError).toHaveBeenCalledWith( + 'Failed to optimistically remove recording', + expect.any(Error) + ); + consoleError.mockRestore(); + }); + it('refetches recordings when API delete fails', async () => { API.deleteRecording.mockRejectedValue(new Error('Delete failed')); + removeRecording('recording-1'); - await vi.waitFor(() => { expect(mockFetchRecordings).toHaveBeenCalled(); }); + + await vi.waitFor(() => { + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('handles fetch error after failed delete', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(); + API.deleteRecording.mockRejectedValue(new Error('Delete failed')); + mockFetchRecordings.mockImplementation(() => { + throw new Error('Fetch error'); + }); + + removeRecording('recording-1'); + + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledWith( + 'Failed to refresh recordings after delete', + expect.any(Error) + ); + }); + consoleError.mockRestore(); + }); + }); + + describe('getPosterUrl', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns logo URL when posterLogoId is provided', () => { + vi.stubEnv('DEV', false); + const result = getPosterUrl('logo-123', {}, ''); + + expect(result).toBe('/api/channels/logos/logo-123/cache/'); + }); + + it('returns custom poster_url when no posterLogoId', () => { + vi.stubEnv('DEV', false); + const customProps = { poster_url: '/custom/poster.jpg' }; + const result = getPosterUrl(null, customProps, ''); + + expect(result).toBe('/custom/poster.jpg'); + }); + + it('returns posterUrl when no posterLogoId or custom poster_url', () => { + vi.stubEnv('DEV', false); + const result = getPosterUrl(null, {}, '/fallback/poster.jpg'); + + expect(result).toBe('/fallback/poster.jpg'); + }); + + it('returns default logo when no parameters provided', () => { + vi.stubEnv('DEV', false); + const result = getPosterUrl(null, {}, ''); + + // Falls back to the imported default Dispatcharr logo asset + expect(result).toBeTruthy(); + expect(result).toContain('logo'); + }); + + it('prepends dev server URL in dev mode for relative paths', () => { + vi.stubEnv('DEV', true); + const result = getPosterUrl(null, {}, '/poster.jpg'); + + expect(result).toMatch(/^https?:\/\/.*:5656\/poster\.jpg$/); + }); + + it('does not prepend dev URL for absolute URLs', () => { + vi.stubEnv('DEV', true); + const result = getPosterUrl(null, {}, 'https://example.com/poster.jpg'); + + expect(result).toBe('https://example.com/poster.jpg'); + }); + }); + + describe('getShowVideoUrl', () => { + it('returns proxy URL with mpegts output format for channel', () => { + const channel = { uuid: 'channel-123' }; + const result = getShowVideoUrl(channel, 'production'); + + expect(result).toBe('/proxy/ts/stream/channel-123?output_format=mpegts'); + }); + + it('includes output_profile when set in player prefs', () => { + localStorage.setItem( + 'dispatcharr-player-prefs', + JSON.stringify({ webPlayerOutputProfileId: 5 }) + ); + const channel = { uuid: 'channel-123' }; + const result = getShowVideoUrl(channel, 'production'); + + expect(result).toBe( + '/proxy/ts/stream/channel-123?output_format=mpegts&output_profile=5' + ); + }); + + it('prepends dev server URL in dev mode with output params', () => { + const channel = { uuid: 'channel-123' }; + const result = getShowVideoUrl(channel, 'dev'); + + expect(result).toMatch( + /^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123\?output_format=mpegts$/ + ); }); }); describe('runComSkip', () => { it('calls API runComskip with recording id', async () => { API.runComskip.mockResolvedValue(); - await runComSkip({ id: 'recording-1' }); + const recording = { id: 'recording-1' }; + + await runComSkip(recording); + expect(API.runComskip).toHaveBeenCalledWith('recording-1'); }); }); @@ -61,34 +192,231 @@ describe('RecordingCardUtils', () => { describe('deleteRecordingById', () => { it('calls API deleteRecording with id', async () => { API.deleteRecording.mockResolvedValue(); + await deleteRecordingById('recording-1'); + expect(API.deleteRecording).toHaveBeenCalledWith('recording-1'); }); }); + describe('deleteSeriesAndRule', () => { + it('removes series recordings and deletes series rule', async () => { + API.bulkRemoveSeriesRecordings.mockResolvedValue(); + API.deleteSeriesRule.mockResolvedValue(); + const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' }; + + await deleteSeriesAndRule(seriesInfo); + + expect(API.bulkRemoveSeriesRecordings).toHaveBeenCalledWith({ + tvg_id: 'series-123', + title: 'Test Series', + scope: 'title', + }); + expect(API.deleteSeriesRule).toHaveBeenCalledWith( + 'series-123', + 'Test Series' + ); + }); + + it('works for title-only rules with no tvg_id', async () => { + API.bulkRemoveSeriesRecordings.mockResolvedValue(); + API.deleteSeriesRule.mockResolvedValue(); + const seriesInfo = { tvg_id: undefined, title: 'Title-Only Show' }; + + await deleteSeriesAndRule(seriesInfo); + + expect(API.bulkRemoveSeriesRecordings).toHaveBeenCalledWith({ + tvg_id: '', + title: 'Title-Only Show', + scope: 'title', + }); + expect(API.deleteSeriesRule).toHaveBeenCalledWith( + undefined, + 'Title-Only Show' + ); + }); + + it('handles bulk remove error gracefully', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(); + API.bulkRemoveSeriesRecordings.mockRejectedValue( + new Error('Bulk remove failed') + ); + API.deleteSeriesRule.mockResolvedValue(); + const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' }; + + await deleteSeriesAndRule(seriesInfo); + + expect(consoleError).toHaveBeenCalledWith( + 'Failed to remove series recordings', + expect.any(Error) + ); + expect(API.deleteSeriesRule).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it('handles delete rule error gracefully', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(); + API.bulkRemoveSeriesRecordings.mockResolvedValue(); + API.deleteSeriesRule.mockRejectedValue(new Error('Delete rule failed')); + const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' }; + + await deleteSeriesAndRule(seriesInfo); + + expect(consoleError).toHaveBeenCalledWith( + 'Failed to delete series rule', + expect.any(Error) + ); + consoleError.mockRestore(); + }); + }); + describe('getRecordingUrl', () => { it('returns file_url when available', () => { - expect(getRecordingUrl({ file_url: '/recordings/file.mp4' }, 'production')).toBe('/recordings/file.mp4'); + const customProps = { file_url: '/recordings/file.mp4' }; + const result = getRecordingUrl(customProps, 'production'); + + expect(result).toBe('/recordings/file.mp4'); }); - it('returns undefined when no file URL available', () => { - expect(getRecordingUrl({}, 'production')).toBeUndefined(); + + it('returns output_file_url when file_url is not available', () => { + const customProps = { output_file_url: '/output/file.mp4' }; + const result = getRecordingUrl(customProps, 'production'); + + expect(result).toBe('/output/file.mp4'); }); + + it('prefers file_url over output_file_url', () => { + const customProps = { + file_url: '/recordings/file.mp4', + output_file_url: '/output/file.mp4', + }; + const result = getRecordingUrl(customProps, 'production'); + + expect(result).toBe('/recordings/file.mp4'); + }); + + it('prepends dev server URL in dev mode for relative paths', () => { + const customProps = { file_url: '/recordings/file.mp4' }; + const result = getRecordingUrl(customProps, 'dev'); + + expect(result).toMatch(/^https?:\/\/.*:5656\/recordings\/file\.mp4$/); + }); + + it('does not prepend dev URL for absolute URLs', () => { + const customProps = { file_url: 'https://example.com/file.mp4' }; + const result = getRecordingUrl(customProps, 'dev'); + + expect(result).toBe('https://example.com/file.mp4'); + }); + + it('returns undefined when no file URL is available', () => { + const result = getRecordingUrl({}, 'production'); + + expect(result).toBeUndefined(); + }); + it('handles null customProps', () => { - expect(getRecordingUrl(null, 'production')).toBeUndefined(); + const result = getRecordingUrl(null, 'production'); + + expect(result).toBeUndefined(); }); }); describe('getSeasonLabel', () => { - it('returns formatted label', () => { expect(getSeasonLabel(1, 5, null)).toBe('S01E05'); }); - it('returns onscreen when season missing', () => { expect(getSeasonLabel(null, 5, 'Episode 5')).toBe('Episode 5'); }); - it('returns null when nothing provided', () => { expect(getSeasonLabel(null, null, null)).toBeNull(); }); + it('returns formatted season and episode label', () => { + const result = getSeasonLabel(1, 5, null); + + expect(result).toBe('S01E05'); + }); + + it('pads single digit season and episode numbers', () => { + const result = getSeasonLabel(2, 3, null); + + expect(result).toBe('S02E03'); + }); + + it('handles multi-digit season and episode numbers', () => { + const result = getSeasonLabel(12, 34, null); + + expect(result).toBe('S12E34'); + }); + + it('returns onscreen value when season or episode is missing', () => { + const result = getSeasonLabel(null, 5, 'Episode 5'); + + expect(result).toBe('Episode 5'); + }); + + it('returns onscreen value when only episode is missing', () => { + const result = getSeasonLabel(1, null, 'Special'); + + expect(result).toBe('Special'); + }); + + it('returns null when no season, episode, or onscreen provided', () => { + const result = getSeasonLabel(null, null, null); + + expect(result).toBeNull(); + }); + + it('returns formatted label even when onscreen is provided', () => { + const result = getSeasonLabel(1, 5, 'Episode 5'); + + expect(result).toBe('S01E05'); + }); }); describe('getSeriesInfo', () => { - it('extracts tvg_id and title', () => { - expect(getSeriesInfo({ program: { tvg_id: 'series-123', title: 'Test Series' } })) - .toEqual({ tvg_id: 'series-123', title: 'Test Series' }); + it('extracts tvg_id and title from program', () => { + const customProps = { + program: { tvg_id: 'series-123', title: 'Test Series' }, + }; + const result = getSeriesInfo(customProps); + + expect(result).toEqual({ + tvg_id: 'series-123', + title: 'Test Series', + }); + }); + + it('handles missing program object', () => { + const customProps = {}; + const result = getSeriesInfo(customProps); + + expect(result).toEqual({ + tvg_id: undefined, + title: undefined, + }); + }); + + it('handles null customProps', () => { + const result = getSeriesInfo(null); + + expect(result).toEqual({ + tvg_id: undefined, + title: undefined, + }); + }); + + it('handles undefined customProps', () => { + const result = getSeriesInfo(undefined); + + expect(result).toEqual({ + tvg_id: undefined, + title: undefined, + }); + }); + + it('handles partial program data', () => { + const customProps = { + program: { tvg_id: 'series-123' }, + }; + const result = getSeriesInfo(customProps); + + expect(result).toEqual({ + tvg_id: 'series-123', + title: undefined, + }); }); - it('handles null', () => { expect(getSeriesInfo(null)).toEqual({ tvg_id: undefined, title: undefined }); }); }); });