mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 01:05:30 +00:00
Update DVR to not require group selection, use new channel summary endpoint.
This commit is contained in:
parent
8427ae0a2d
commit
c68d73fbdb
3 changed files with 78 additions and 133 deletions
|
|
@ -88,13 +88,11 @@ const RecordingModal = ({
|
|||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
|
||||
|
||||
// Local state: selected group and channels for that group
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(null);
|
||||
const [localChannels, setLocalChannels] = useState([]);
|
||||
// All channels loaded via lightweight summary API
|
||||
const [allChannels, setAllChannels] = useState([]);
|
||||
const [isChannelsLoading, setIsChannelsLoading] = useState(false);
|
||||
|
||||
const [mode, setMode] = useState('single');
|
||||
|
|
@ -216,36 +214,19 @@ const RecordingModal = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, recording, channel]);
|
||||
|
||||
// Initialize group selection to the first available group when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (selectedGroupId) return;
|
||||
const firstGroup = Object.values(channelGroups || {}).find(
|
||||
(g) => g?.hasChannels
|
||||
);
|
||||
if (firstGroup) {
|
||||
setSelectedGroupId(String(firstGroup.id));
|
||||
}
|
||||
}, [isOpen, selectedGroupId, channelGroups]);
|
||||
|
||||
// Fetch channels for selected group (no global load)
|
||||
// Load all channels via lightweight summary API when modal opens
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
if (!isOpen) return;
|
||||
if (!selectedGroupId) return;
|
||||
const group = channelGroups?.[Number(selectedGroupId)];
|
||||
if (!group?.name) return;
|
||||
try {
|
||||
setIsChannelsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
params.set('channel_group', group.name);
|
||||
const chans = await API.getChannelsForParams(params);
|
||||
const chans = await API.getChannelsSummary();
|
||||
if (cancelled) return;
|
||||
setLocalChannels(Array.isArray(chans) ? chans : []);
|
||||
setAllChannels(Array.isArray(chans) ? chans : []);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load channels for group', group?.name, e);
|
||||
if (!cancelled) setLocalChannels([]);
|
||||
console.warn('Failed to load channels for recording form', e);
|
||||
if (!cancelled) setAllChannels([]);
|
||||
} finally {
|
||||
if (!cancelled) setIsChannelsLoading(false);
|
||||
}
|
||||
|
|
@ -254,41 +235,10 @@ const RecordingModal = ({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, selectedGroupId, channelGroups]);
|
||||
|
||||
// When group changes, clear selected channel in both forms
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
singleForm.setFieldValue('channel_id', '');
|
||||
recurringForm.setFieldValue('channel_id', '');
|
||||
}, [isOpen, selectedGroupId]);
|
||||
|
||||
// After channels load for a group, auto-select the first channel
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
if (!selectedGroupId) {
|
||||
return;
|
||||
}
|
||||
const first = Array.isArray(localChannels) ? localChannels[0] : null;
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
if (mode === 'single') {
|
||||
if (!singleForm.values.channel_id) {
|
||||
singleForm.setFieldValue('channel_id', `${first.id}`);
|
||||
}
|
||||
} else {
|
||||
if (!recurringForm.values.channel_id) {
|
||||
recurringForm.setFieldValue('channel_id', `${first.id}`);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, selectedGroupId, localChannels, mode]);
|
||||
}, [isOpen]);
|
||||
|
||||
const channelOptions = useMemo(() => {
|
||||
const list = Array.isArray(localChannels) ? [...localChannels] : [];
|
||||
const list = Array.isArray(allChannels) ? [...allChannels] : [];
|
||||
list.sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
|
|
@ -297,18 +247,11 @@ const RecordingModal = ({
|
|||
});
|
||||
return list.map((item) => ({
|
||||
value: `${item.id}`,
|
||||
label: item.name || `Channel ${item.id}`,
|
||||
label: item.channel_number
|
||||
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
|
||||
: item.name || `Channel ${item.id}`,
|
||||
}));
|
||||
}, [localChannels]);
|
||||
|
||||
// Group options (no "All")
|
||||
const groupOptions = useMemo(() => {
|
||||
const arr = Object.values(channelGroups || {}).filter(
|
||||
(g) => g?.hasChannels
|
||||
);
|
||||
arr.sort((a, b) => (a?.name || '').localeCompare(b?.name || ''));
|
||||
return arr.map((g) => ({ value: String(g.id), label: g.name }));
|
||||
}, [channelGroups]);
|
||||
}, [allChannels]);
|
||||
|
||||
const resetForms = () => {
|
||||
singleForm.reset();
|
||||
|
|
@ -419,20 +362,10 @@ const RecordingModal = ({
|
|||
|
||||
<form onSubmit={onSubmit}>
|
||||
<Stack gap="md">
|
||||
{/* Group selection (required, no "All") */}
|
||||
<Select
|
||||
label="Group"
|
||||
placeholder="Select group"
|
||||
data={groupOptions}
|
||||
value={selectedGroupId}
|
||||
onChange={(value) => setSelectedGroupId(value)}
|
||||
searchable
|
||||
/>
|
||||
|
||||
{mode === 'single' ? (
|
||||
<Select
|
||||
{...singleForm.getInputProps('channel_id')}
|
||||
key={`${singleForm.key('channel_id')}-${selectedGroupId || 'none'}`}
|
||||
key={singleForm.key('channel_id')}
|
||||
label="Channel"
|
||||
placeholder="Select channel"
|
||||
searchable
|
||||
|
|
@ -445,7 +378,7 @@ const RecordingModal = ({
|
|||
) : (
|
||||
<Select
|
||||
{...recurringForm.getInputProps('channel_id')}
|
||||
key={`${recurringForm.key('channel_id')}-${selectedGroupId || 'none'}`}
|
||||
key={recurringForm.key('channel_id')}
|
||||
label="Channel"
|
||||
placeholder="Select channel"
|
||||
searchable
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ const DVRPage = () => {
|
|||
const recordings = useChannelsStore((s) => s.recordings);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
|
||||
const channelIds = useChannelsStore((s) => s.channelIds);
|
||||
const [channelsById, setChannelsById] = useState({});
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
|
||||
|
|
@ -95,29 +94,16 @@ const DVRPage = () => {
|
|||
fetchRecurringRules();
|
||||
}, [fetchRecordings, fetchRecurringRules]);
|
||||
|
||||
// When recordings change, ensure we have local channel details on demand
|
||||
// Load channel details for recordings via lightweight summary API
|
||||
useEffect(() => {
|
||||
const neededIds = new Set();
|
||||
for (const rec of Array.isArray(recordings) ? recordings : []) {
|
||||
if (rec?.channel) neededIds.add(rec.channel);
|
||||
}
|
||||
const missing = Array.from(neededIds).filter(
|
||||
(id) => !channelsById[id]
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const fetched = await Promise.all(
|
||||
missing.map((id) => API.getChannel(id))
|
||||
);
|
||||
const channels = await API.getChannelsSummary();
|
||||
if (cancelled) return;
|
||||
setChannelsById((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const ch of fetched) if (ch?.id) next[ch.id] = ch;
|
||||
return next;
|
||||
});
|
||||
const byId = {};
|
||||
for (const ch of channels) if (ch?.id) byId[ch.id] = ch;
|
||||
setChannelsById(byId);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch channels for DVR page', e);
|
||||
}
|
||||
|
|
@ -125,7 +111,7 @@ const DVRPage = () => {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [recordings, channelsById]);
|
||||
}, []);
|
||||
|
||||
// Re-render every second so time-based bucketing updates without a refresh
|
||||
const [now, setNow] = useState(userNow());
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from '@testing-library/react';
|
||||
import DVRPage from '../DVR';
|
||||
import dayjs from 'dayjs';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
|
@ -171,8 +177,8 @@ describe('DVRPage', () => {
|
|||
const now = new Date('2024-01-15T12:00:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Default: API.getChannel returns null (no channel data loaded by default)
|
||||
API.getChannel.mockResolvedValue(null);
|
||||
// Default: API.getChannelsSummary returns empty array
|
||||
API.getChannelsSummary.mockResolvedValue([]);
|
||||
|
||||
isAfter.mockImplementation((a, b) => new Date(a) > new Date(b));
|
||||
isBefore.mockImplementation((a, b) => new Date(a) < new Date(b));
|
||||
|
|
@ -228,21 +234,25 @@ describe('DVRPage', () => {
|
|||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('renders new recording buttons', () => {
|
||||
render(<DVRPage />);
|
||||
it('renders new recording buttons', async () => {
|
||||
await act(async () => {
|
||||
render(<DVRPage />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('New Recording')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no recordings', () => {
|
||||
render(<DVRPage />);
|
||||
it('renders empty state when no recordings', async () => {
|
||||
await act(async () => {
|
||||
render(<DVRPage />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('No upcoming recordings.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recording Display', () => {
|
||||
it('displays recordings grouped by date', () => {
|
||||
it('displays recordings grouped by date', async () => {
|
||||
const now = dayjs('2024-01-15T12:00:00Z');
|
||||
const recordings = [
|
||||
{
|
||||
|
|
@ -266,7 +276,9 @@ describe('DVRPage', () => {
|
|||
return selector ? selector(state) : state;
|
||||
});
|
||||
|
||||
render(<DVRPage />);
|
||||
await act(async () => {
|
||||
render(<DVRPage />);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recording-card-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('recording-card-2')).toBeInTheDocument();
|
||||
|
|
@ -275,24 +287,34 @@ describe('DVRPage', () => {
|
|||
|
||||
describe('New Recording', () => {
|
||||
it('opens recording form when new recording button is clicked', async () => {
|
||||
render(<DVRPage />);
|
||||
await act(async () => {
|
||||
render(<DVRPage />);
|
||||
});
|
||||
|
||||
const newButton = screen.getByText('New Recording');
|
||||
fireEvent.click(newButton);
|
||||
act(() => {
|
||||
fireEvent.click(newButton);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recording-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes recording form when close is clicked', async () => {
|
||||
render(<DVRPage />);
|
||||
await act(async () => {
|
||||
render(<DVRPage />);
|
||||
});
|
||||
|
||||
const newButton = screen.getByText('New Recording');
|
||||
fireEvent.click(newButton);
|
||||
act(() => {
|
||||
fireEvent.click(newButton);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recording-form')).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByText('Close Form');
|
||||
fireEvent.click(closeButton);
|
||||
act(() => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -395,10 +417,13 @@ describe('DVRPage', () => {
|
|||
return selector ? selector(state) : state;
|
||||
});
|
||||
|
||||
render(<DVRPage />);
|
||||
await act(async () => {
|
||||
render(<DVRPage />);
|
||||
});
|
||||
|
||||
const recurringButton = screen.getByText('Open Recurring');
|
||||
fireEvent.click(recurringButton);
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText('Open Recurring'));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rule ID: 100')).toBeInTheDocument();
|
||||
|
|
@ -426,15 +451,19 @@ describe('DVRPage', () => {
|
|||
return selector ? selector(state) : state;
|
||||
});
|
||||
|
||||
render(<DVRPage />);
|
||||
await act(async () => {
|
||||
render(<DVRPage />);
|
||||
});
|
||||
|
||||
const recurringButton = screen.getByText('Open Recurring');
|
||||
fireEvent.click(recurringButton);
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText('Open Recurring'));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByText('Close Recurring');
|
||||
fireEvent.click(closeButton);
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText('Close Recurring'));
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('recurring-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -453,14 +482,11 @@ describe('DVRPage', () => {
|
|||
custom_properties: { Title: 'Live Show' },
|
||||
};
|
||||
|
||||
// DVR.jsx now lazy-loads channel data via API.getChannel rather than
|
||||
// reading s.channels from the store. Mock the API so channelsById
|
||||
// gets populated before the Watch Live handler runs.
|
||||
API.getChannel.mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Channel 1',
|
||||
stream_url: 'http://stream.url',
|
||||
});
|
||||
// DVR.jsx loads all channel data via getChannelsSummary.
|
||||
// Mock the API so channelsById gets populated before the handler runs.
|
||||
API.getChannelsSummary.mockResolvedValue([
|
||||
{ id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
|
||||
]);
|
||||
|
||||
useChannelsStore.mockImplementation((selector) => {
|
||||
const state = {
|
||||
|
|
@ -479,7 +505,7 @@ describe('DVRPage', () => {
|
|||
|
||||
// Wait for channelsById to be populated from the async API call
|
||||
await waitFor(() => {
|
||||
expect(API.getChannel).toHaveBeenCalledWith(1);
|
||||
expect(API.getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const watchLiveButton = screen.getByText('Watch Live');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue