diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e461fcd..6ae7ba43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/frontend/src/components/modals/ChannelNumberingModal.jsx b/frontend/src/components/modals/ChannelNumberingModal.jsx
new file mode 100644
index 00000000..151dc9a8
--- /dev/null
+++ b/frontend/src/components/modals/ChannelNumberingModal.jsx
@@ -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 (
+
+
+
+ {isBulk
+ ? `Choose how to assign channel numbers to the ${streamCount} selected streams:`
+ : `Choose how to assign the channel number for "${streamName}":`}
+
+
+
+
+
+
+
+
+
+
+ {mode === customModeValue && (
+
+ )}
+
+
+ onRememberChoiceChange(event.currentTarget.checked)
+ }
+ label="Remember this choice and don't ask again"
+ />
+
+
+
+
+
+
+
+ );
+};
+
+export default ChannelNumberingModal;
diff --git a/frontend/src/components/modals/CreateChannelModal.jsx b/frontend/src/components/modals/CreateChannelModal.jsx
new file mode 100644
index 00000000..ae3d16b0
--- /dev/null
+++ b/frontend/src/components/modals/CreateChannelModal.jsx
@@ -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 (
+
+
+
+ {isBulk
+ ? `Configure options for creating ${streamCount} channels from selected streams:`
+ : `Configure options for creating a channel from "${streamName}":`}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {mode === customModeValue && (
+
+ )}
+
+
+ onRememberChoiceChange(event.currentTarget.checked)
+ }
+ label="Remember this choice and don't ask again"
+ />
+
+
+
+
+
+
+
+ );
+};
+
+export default CreateChannelModal;
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index b8ce52a4..c39e63ab 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -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 */}
- setChannelNumberingModalOpen(false)}
- title="Channel Numbering Options"
- size="md"
- centered
- >
-
-
- Choose how to assign channel numbers to the{' '}
- {selectedStreamIds.length} selected streams:
-
+ 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) : []}
+ />
-
-
-
-
-
-
-
-
- {numberingMode === 'custom' && (
-
- )}
-
- setRememberChoice(event.currentTarget.checked)}
- label="Remember this choice and don't ask again"
- />
-
-
-
-
-
-
-
-
- {/* Single Channel Numbering Modal */}
- setSingleChannelModalOpen(false)}
- title="Channel Number Assignment"
- size="md"
- centered
- >
-
-
- Choose how to assign the channel number for "
- {currentStreamForChannel?.name}":
-
-
-
-
-
-
-
-
-
-
- {singleChannelMode === 'specific' && (
-
- )}
-
-
- setRememberSingleChoice(event.currentTarget.checked)
- }
- label="Remember this choice and don't ask again"
- />
-
-
-
-
-
-
-
+ 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) : []}
+ />