Refactor/Enhancement: Refactored channel numbering dialogs into a unified CreateChannelModal component that now includes channel profile selection alongside channel number assignment for both single and bulk channel creation. Users can choose to add channels to all profiles, no profiles, or specific profiles with mutual exclusivity between special options ("All Profiles", "None") and specific profile selections. Profile selection defaults to the current table filter for intuitive workflow.

This commit is contained in:
SergeantPanda 2026-01-11 19:05:07 -06:00
parent 6b873be3cf
commit b8374fcc68
4 changed files with 409 additions and 151 deletions

View file

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sentinel value `[0]`: Channels are added to ALL profiles (explicit)
- Specific IDs `[1, 2, ...]`: Channels are added only to the specified profiles
This allows API consumers to control profile membership across all channel creation methods without requiring all channels to be added to every profile by default.
- Channel profile selection in creation modal: Users can now choose which profiles to add channels to when creating channels from streams (both single and bulk operations). Options include adding to all profiles, no profiles, or specific profiles with mutual exclusivity between special options ("All Profiles", "None") and specific profile selections. Profile selection defaults to the current table filter for intuitive workflow.
- Group retention policy for M3U accounts: Groups now follow the same stale retention logic as streams, using the account's `stale_stream_days` setting. Groups that temporarily disappear from an M3U source are retained for the configured retention period instead of being immediately deleted, preserving user settings and preventing data loss when providers temporarily remove/re-add groups. (Closes #809)
- Visual stale indicators for streams and groups: Added `is_stale` field to Stream and both `is_stale` and `last_seen` fields to ChannelGroupM3UAccount models to track items in their retention grace period. Stale groups display with orange buttons and a warning tooltip, while stale streams show with a red background color matching the visual treatment of empty channels.
@ -24,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase
- Form management refactored across application: Migrated Channel, Stream, M3U Profile, Stream Profile, Logo, and User Agent forms from Formik to React Hook Form (RHF) with Yup validation for improved form handling, better validation feedback, and enhanced code maintainability
- Stats and VOD pages refactored for clearer separation of concerns: extracted Stream/VOD connection cards (StreamConnectionCard, VodConnectionCard, VODCard, SeriesCard), moved page logic into dedicated utils, and lazy-loaded heavy components with ErrorBoundary fallbacks to improve readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
- Channel creation modal refactored: Extracted and unified channel numbering dialogs from StreamsTable into a dedicated CreateChannelModal component that handles both single and bulk channel creation with cleaner, more maintainable implementation and integrated profile selection controls.
### Fixed

View file

@ -0,0 +1,122 @@
import React from 'react';
import {
Modal,
Stack,
Text,
Radio,
NumberInput,
Checkbox,
Group,
Button,
} from '@mantine/core';
const ChannelNumberingModal = ({
opened,
onClose,
mode,
onModeChange,
numberValue,
onNumberValueChange,
rememberChoice,
onRememberChoiceChange,
onConfirm,
// Props for customizing the modal behavior
isBulk = false,
streamCount = 1,
streamName = '',
}) => {
const title = isBulk
? 'Channel Numbering Options'
: 'Channel Number Assignment';
const confirmLabel = isBulk ? 'Create Channels' : 'Create Channel';
const numberingLabel = isBulk ? 'Numbering Mode' : 'Number Assignment';
// For bulk: use 'custom' mode, for single: use 'specific' mode
const customModeValue = isBulk ? 'custom' : 'specific';
return (
<Modal opened={opened} onClose={onClose} title={title} size="md" centered>
<Stack spacing="md">
<Text size="sm" c="dimmed">
{isBulk
? `Choose how to assign channel numbers to the ${streamCount} selected streams:`
: `Choose how to assign the channel number for "${streamName}":`}
</Text>
<Radio.Group
value={mode}
onChange={onModeChange}
label={numberingLabel}
>
<Stack mt="xs" spacing="xs">
<Radio
value="provider"
label={isBulk ? 'Use Provider Numbers' : 'Use Provider Number'}
description={
isBulk
? 'Use tvg-chno or channel-number from stream metadata, auto-assign for conflicts'
: 'Use tvg-chno or channel-number from stream metadata, auto-assign if not available'
}
/>
<Radio
value="auto"
label={
isBulk ? 'Auto-Assign Sequential' : 'Auto-Assign Next Available'
}
description={
isBulk
? 'Start from the lowest available channel number and increment by 1'
: 'Automatically assign the next available channel number'
}
/>
<Radio
value={customModeValue}
label={
isBulk ? 'Start from Custom Number' : 'Use Specific Number'
}
description={
isBulk
? 'Start sequential numbering from a specific channel number'
: 'Use a specific channel number'
}
/>
</Stack>
</Radio.Group>
{mode === customModeValue && (
<NumberInput
label={isBulk ? 'Starting Channel Number' : 'Channel Number'}
description={
isBulk
? 'Channel numbers will be assigned starting from this number'
: 'The specific channel number to assign'
}
value={numberValue}
onChange={onNumberValueChange}
min={1}
placeholder={
isBulk ? 'Enter starting number...' : 'Enter channel number...'
}
/>
)}
<Checkbox
checked={rememberChoice}
onChange={(event) =>
onRememberChoiceChange(event.currentTarget.checked)
}
label="Remember this choice and don't ask again"
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={onConfirm}>{confirmLabel}</Button>
</Group>
</Stack>
</Modal>
);
};
export default ChannelNumberingModal;

View file

@ -0,0 +1,180 @@
import React from 'react';
import {
Modal,
Stack,
Text,
Radio,
NumberInput,
Checkbox,
Group,
Button,
MultiSelect,
Divider,
} from '@mantine/core';
const CreateChannelModal = ({
opened,
onClose,
mode,
onModeChange,
numberValue,
onNumberValueChange,
rememberChoice,
onRememberChoiceChange,
onConfirm,
// Props for customizing the modal behavior
isBulk = false,
streamCount = 1,
streamName = '',
// Channel profile props
selectedProfileIds,
onProfileIdsChange,
channelProfiles = [],
}) => {
const title = isBulk ? 'Create Channels Options' : 'Create Channel';
const confirmLabel = isBulk ? 'Create Channels' : 'Create Channel';
const numberingLabel = isBulk ? 'Numbering Mode' : 'Number Assignment';
// For bulk: use 'custom' mode, for single: use 'specific' mode
const customModeValue = isBulk ? 'custom' : 'specific';
// Convert channel profiles to MultiSelect data format with groups
// Filter out the "All" profile (id '0') and add our own special options
const profileOptions = [
{
group: 'Special',
items: [
{ value: 'all', label: 'All Profiles' },
{ value: 'none', label: 'No Profiles' },
],
},
{
group: 'Profiles',
items: channelProfiles
.filter((profile) => profile.id.toString() !== '0')
.map((profile) => ({
value: profile.id.toString(),
label: profile.name,
})),
},
];
// Handle profile selection with mutual exclusivity
const handleProfileChange = (newValue) => {
const lastSelected = newValue[newValue.length - 1];
// If 'all' or 'none' was just selected, clear everything else and keep only that
if (lastSelected === 'all' || lastSelected === 'none') {
onProfileIdsChange([lastSelected]);
}
// If a specific profile was selected, remove 'all' and 'none'
else if (newValue.includes('all') || newValue.includes('none')) {
onProfileIdsChange(newValue.filter((v) => v !== 'all' && v !== 'none'));
}
// Otherwise just update normally
else {
onProfileIdsChange(newValue);
}
};
return (
<Modal opened={opened} onClose={onClose} title={title} size="md" centered>
<Stack spacing="md">
<Text size="sm" c="dimmed">
{isBulk
? `Configure options for creating ${streamCount} channels from selected streams:`
: `Configure options for creating a channel from "${streamName}":`}
</Text>
<Divider label="Channel Profiles" labelPosition="left" />
<MultiSelect
label="Channel Profiles"
description="Select 'All Profiles' to add to all profiles, 'No Profiles' to not add to any profile, or choose specific profiles"
placeholder="Select profiles..."
data={profileOptions}
value={selectedProfileIds}
onChange={handleProfileChange}
searchable
clearable
/>
<Divider label="Channel Number" labelPosition="left" />
<Radio.Group
value={mode}
onChange={onModeChange}
label={numberingLabel}
>
<Stack mt="xs" spacing="xs">
<Radio
value="provider"
label={isBulk ? 'Use Provider Numbers' : 'Use Provider Number'}
description={
isBulk
? 'Use tvg-chno or channel-number from stream metadata, auto-assign for conflicts'
: 'Use tvg-chno or channel-number from stream metadata, auto-assign if not available'
}
/>
<Radio
value="auto"
label={
isBulk ? 'Auto-Assign Sequential' : 'Auto-Assign Next Available'
}
description={
isBulk
? 'Start from the lowest available channel number and increment by 1'
: 'Automatically assign the next available channel number'
}
/>
<Radio
value={customModeValue}
label={
isBulk ? 'Start from Custom Number' : 'Use Specific Number'
}
description={
isBulk
? 'Start sequential numbering from a specific channel number'
: 'Use a specific channel number'
}
/>
</Stack>
</Radio.Group>
{mode === customModeValue && (
<NumberInput
label={isBulk ? 'Starting Channel Number' : 'Channel Number'}
description={
isBulk
? 'Channel numbers will be assigned starting from this number'
: 'The specific channel number to assign'
}
value={numberValue}
onChange={onNumberValueChange}
min={1}
placeholder={
isBulk ? 'Enter starting number...' : 'Enter channel number...'
}
/>
)}
<Checkbox
checked={rememberChoice}
onChange={(event) =>
onRememberChoiceChange(event.currentTarget.checked)
}
label="Remember this choice and don't ask again"
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={onConfirm}>{confirmLabel}</Button>
</Group>
</Stack>
</Modal>
);
};
export default CreateChannelModal;

View file

@ -58,6 +58,7 @@ import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../ConfirmationDialog';
import CreateChannelModal from '../modals/CreateChannelModal';
const StreamRowActions = ({
theme,
@ -193,19 +194,21 @@ const StreamsTable = ({ onReady }) => {
const [sorting, setSorting] = useState([{ id: 'name', desc: false }]);
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
// Channel numbering modal state
// Channel creation modal state (bulk)
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
useState(false);
const [numberingMode, setNumberingMode] = useState('provider'); // 'provider', 'auto', or 'custom'
const [customStartNumber, setCustomStartNumber] = useState(1);
const [rememberChoice, setRememberChoice] = useState(false);
const [bulkSelectedProfileIds, setBulkSelectedProfileIds] = useState([]);
// Single channel numbering modal state
// Channel creation modal state (single)
const [singleChannelModalOpen, setSingleChannelModalOpen] = useState(false);
const [singleChannelMode, setSingleChannelMode] = useState('provider'); // 'provider', 'auto', or 'specific'
const [specificChannelNumber, setSpecificChannelNumber] = useState(1);
const [rememberSingleChoice, setRememberSingleChoice] = useState(false);
const [currentStreamForChannel, setCurrentStreamForChannel] = useState(null);
const [singleSelectedProfileIds, setSingleSelectedProfileIds] = useState([]);
// Confirmation dialog state
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@ -260,6 +263,8 @@ const StreamsTable = ({ onReady }) => {
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
);
const channelProfiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
const [tableSize, _] = useLocalStorage('table-size', 'default');
@ -462,6 +467,11 @@ const StreamsTable = ({ onReady }) => {
const createChannelsFromStreams = async () => {
if (selectedStreamIds.length === 0) return;
// Set default profile selection based on current profile filter
const defaultProfileIds =
selectedProfileId === '0' ? ['all'] : [selectedProfileId];
setBulkSelectedProfileIds(defaultProfileIds);
// Check if user has suppressed the channel numbering dialog
const actionKey = 'channel-numbering-choice';
if (isWarningSuppressed(actionKey)) {
@ -478,7 +488,10 @@ const StreamsTable = ({ onReady }) => {
? 0
: Number(savedStartNumber);
await executeChannelCreation(startingChannelNumberValue);
await executeChannelCreation(
startingChannelNumberValue,
defaultProfileIds
);
} else {
// Show the modal to let user choose
setChannelNumberingModalOpen(true);
@ -486,15 +499,32 @@ const StreamsTable = ({ onReady }) => {
};
// Separate function to actually execute the channel creation
const executeChannelCreation = async (startingChannelNumberValue) => {
const executeChannelCreation = async (
startingChannelNumberValue,
profileIds = null
) => {
try {
const selectedChannelProfileId =
useChannelsStore.getState().selectedProfileId;
// Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise
let channelProfileIds;
if (profileIds) {
if (profileIds.includes('none')) {
channelProfileIds = [];
} else if (profileIds.includes('all')) {
channelProfileIds = null;
} else {
channelProfileIds = profileIds
.filter((id) => id !== 'all' && id !== 'none')
.map((id) => parseInt(id));
}
} else {
channelProfileIds =
selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null;
}
// Use the async API for all bulk operations
const response = await API.createChannelsFromStreamsAsync(
selectedStreamIds,
selectedChannelProfileId !== '0' ? [selectedChannelProfileId] : null,
channelProfileIds,
startingChannelNumberValue
);
@ -533,7 +563,10 @@ const StreamsTable = ({ onReady }) => {
: Number(customStartNumber);
setChannelNumberingModalOpen(false);
await executeChannelCreation(startingChannelNumberValue);
await executeChannelCreation(
startingChannelNumberValue,
bulkSelectedProfileIds
);
};
const editStream = async (stream = null) => {
@ -595,6 +628,11 @@ const StreamsTable = ({ onReady }) => {
// Single channel creation functions
const createChannelFromStream = async (stream) => {
// Set default profile selection based on current profile filter
const defaultProfileIds =
selectedProfileId === '0' ? ['all'] : [selectedProfileId];
setSingleSelectedProfileIds(defaultProfileIds);
// Check if user has suppressed the single channel numbering dialog
const actionKey = 'single-channel-numbering-choice';
if (isWarningSuppressed(actionKey)) {
@ -611,7 +649,11 @@ const StreamsTable = ({ onReady }) => {
? 0
: Number(savedChannelNumber);
await executeSingleChannelCreation(stream, channelNumberValue);
await executeSingleChannelCreation(
stream,
channelNumberValue,
defaultProfileIds
);
} else {
// Show the modal to let user choose
setCurrentStreamForChannel(stream);
@ -620,18 +662,33 @@ const StreamsTable = ({ onReady }) => {
};
// Separate function to actually execute single channel creation
const executeSingleChannelCreation = async (stream, channelNumber = null) => {
const selectedChannelProfileId =
useChannelsStore.getState().selectedProfileId;
const executeSingleChannelCreation = async (
stream,
channelNumber = null,
profileIds = null
) => {
// Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise
let channelProfileIds;
if (profileIds) {
if (profileIds.includes('none')) {
channelProfileIds = [];
} else if (profileIds.includes('all')) {
channelProfileIds = null;
} else {
channelProfileIds = profileIds
.filter((id) => id !== 'all' && id !== 'none')
.map((id) => parseInt(id));
}
} else {
channelProfileIds =
selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null;
}
await API.createChannelFromStream({
name: stream.name,
channel_number: channelNumber,
stream_id: stream.id,
// Only pass channel_profile_ids if a specific profile is selected (not "All")
...(selectedChannelProfileId !== '0' && {
channel_profile_ids: selectedChannelProfileId,
}),
channel_profile_ids: channelProfileIds,
});
await API.requeryChannels();
const fetchLogos = useChannelsStore.getState().fetchLogos;
@ -663,7 +720,8 @@ const StreamsTable = ({ onReady }) => {
setSingleChannelModalOpen(false);
await executeSingleChannelCreation(
currentStreamForChannel,
channelNumberValue
channelNumberValue,
singleSelectedProfileIds
);
};
@ -1134,145 +1192,41 @@ const StreamsTable = ({ onReady }) => {
onClose={closeStreamForm}
/>
{/* Channel Numbering Modal */}
<Modal
{/* Bulk Channel Creation Modal */}
<CreateChannelModal
opened={channelNumberingModalOpen}
onClose={() => setChannelNumberingModalOpen(false)}
title="Channel Numbering Options"
size="md"
centered
>
<Stack spacing="md">
<Text size="sm" c="dimmed">
Choose how to assign channel numbers to the{' '}
{selectedStreamIds.length} selected streams:
</Text>
mode={numberingMode}
onModeChange={setNumberingMode}
numberValue={customStartNumber}
onNumberValueChange={setCustomStartNumber}
rememberChoice={rememberChoice}
onRememberChoiceChange={setRememberChoice}
onConfirm={handleChannelNumberingConfirm}
isBulk={true}
streamCount={selectedStreamIds.length}
selectedProfileIds={bulkSelectedProfileIds}
onProfileIdsChange={setBulkSelectedProfileIds}
channelProfiles={channelProfiles ? Object.values(channelProfiles) : []}
/>
<Radio.Group
value={numberingMode}
onChange={setNumberingMode}
label="Numbering Mode"
>
<Stack mt="xs" spacing="xs">
<Radio
value="provider"
label="Use Provider Numbers"
description="Use tvg-chno or channel-number from stream metadata, auto-assign for conflicts"
/>
<Radio
value="auto"
label="Auto-Assign Sequential"
description="Start from the lowest available channel number and increment by 1"
/>
<Radio
value="custom"
label="Start from Custom Number"
description="Start sequential numbering from a specific channel number"
/>
</Stack>
</Radio.Group>
{numberingMode === 'custom' && (
<NumberInput
label="Starting Channel Number"
description="Channel numbers will be assigned starting from this number"
value={customStartNumber}
onChange={setCustomStartNumber}
min={1}
placeholder="Enter starting number..."
/>
)}
<Checkbox
checked={rememberChoice}
onChange={(event) => setRememberChoice(event.currentTarget.checked)}
label="Remember this choice and don't ask again"
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={() => setChannelNumberingModalOpen(false)}
>
Cancel
</Button>
<Button onClick={handleChannelNumberingConfirm}>
Create Channels
</Button>
</Group>
</Stack>
</Modal>
{/* Single Channel Numbering Modal */}
<Modal
{/* Single Channel Creation Modal */}
<CreateChannelModal
opened={singleChannelModalOpen}
onClose={() => setSingleChannelModalOpen(false)}
title="Channel Number Assignment"
size="md"
centered
>
<Stack spacing="md">
<Text size="sm" c="dimmed">
Choose how to assign the channel number for "
{currentStreamForChannel?.name}":
</Text>
<Radio.Group
value={singleChannelMode}
onChange={setSingleChannelMode}
label="Number Assignment"
>
<Stack mt="xs" spacing="xs">
<Radio
value="provider"
label="Use Provider Number"
description="Use tvg-chno or channel-number from stream metadata, auto-assign if not available"
/>
<Radio
value="auto"
label="Auto-Assign Next Available"
description="Automatically assign the next available channel number"
/>
<Radio
value="specific"
label="Use Specific Number"
description="Use a specific channel number"
/>
</Stack>
</Radio.Group>
{singleChannelMode === 'specific' && (
<NumberInput
label="Channel Number"
description="The specific channel number to assign"
value={specificChannelNumber}
onChange={setSpecificChannelNumber}
min={1}
placeholder="Enter channel number..."
/>
)}
<Checkbox
checked={rememberSingleChoice}
onChange={(event) =>
setRememberSingleChoice(event.currentTarget.checked)
}
label="Remember this choice and don't ask again"
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={() => setSingleChannelModalOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSingleChannelNumberingConfirm}>
Create Channel
</Button>
</Group>
</Stack>
</Modal>
mode={singleChannelMode}
onModeChange={setSingleChannelMode}
numberValue={specificChannelNumber}
onNumberValueChange={setSpecificChannelNumber}
rememberChoice={rememberSingleChoice}
onRememberChoiceChange={setRememberSingleChoice}
onConfirm={handleSingleChannelNumberingConfirm}
isBulk={false}
streamName={currentStreamForChannel?.name}
selectedProfileIds={singleSelectedProfileIds}
onProfileIdsChange={setSingleSelectedProfileIds}
channelProfiles={channelProfiles ? Object.values(channelProfiles) : []}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}