Enhance M3U profile management: add optional notes field, improve validation for default profiles, and update UI to display notes where applicable.

Also adds the ability to modify the name of the defualt profile.
Closes #280 [Feature]: add general text field in m3u/xs
This commit is contained in:
SergeantPanda 2025-09-10 17:26:18 -05:00
parent f218eaad51
commit 74aff3fb1a
4 changed files with 253 additions and 89 deletions

View file

@ -55,6 +55,10 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
"account",
]
read_only_fields = ["id", "account"]
extra_kwargs = {
'search_pattern': {'required': False, 'allow_blank': True},
'replace_pattern': {'required': False, 'allow_blank': True},
}
def create(self, validated_data):
m3u_account = self.context.get("m3u_account")
@ -64,9 +68,39 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
return super().create(validated_data)
def validate(self, data):
"""Custom validation to handle default profiles"""
# For updates to existing instances
if self.instance and self.instance.is_default:
# For default profiles, search_pattern and replace_pattern are not required
# and we don't want to validate them since they shouldn't be changed
return data
# For non-default profiles or new profiles, ensure required fields are present
if not data.get('search_pattern'):
raise serializers.ValidationError({
'search_pattern': ['This field is required for non-default profiles.']
})
if not data.get('replace_pattern'):
raise serializers.ValidationError({
'replace_pattern': ['This field is required for non-default profiles.']
})
return data
def update(self, instance, validated_data):
if instance.is_default:
raise serializers.ValidationError("Default profiles cannot be modified.")
# For default profiles, only allow updating name and custom_properties (for notes)
allowed_fields = {'name', 'custom_properties'}
# Remove any fields that aren't allowed for default profiles
disallowed_fields = set(validated_data.keys()) - allowed_fields
if disallowed_fields:
raise serializers.ValidationError(
f"Default profiles can only modify name and notes. "
f"Cannot modify: {', '.join(disallowed_fields)}"
)
return super().update(instance, validated_data)
def destroy(self, request, *args, **kwargs):

View file

@ -284,6 +284,30 @@ const AccountInfoModal = ({ isOpen, onClose, profile, onRefresh }) => {
</Box>
</Group>
{/* Profile Notes */}
{currentProfile?.custom_properties?.notes && (
<>
<Divider />
<Box>
<Text fw={600} mb="sm">
Profile Notes
</Text>
<Box
p="sm"
style={{
backgroundColor: 'rgba(134, 142, 150, 0.08)',
border: '1px solid rgba(134, 142, 150, 0.2)',
borderRadius: 6,
}}
>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
{currentProfile.custom_properties.notes}
</Text>
</Box>
</Box>
</>
)}
<Divider />
{/* Detailed Information Table */}

View file

@ -12,6 +12,8 @@ import {
Paper,
Badge,
Grid,
Textarea,
NumberInput,
} from '@mantine/core';
import { useWebSocket } from '../../WebSocket';
import usePlaylistsStore from '../../store/playlists';
@ -27,6 +29,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [replacePattern, setReplacePattern] = useState('');
const [debouncedPatterns, setDebouncedPatterns] = useState({});
const [sampleInput, setSampleInput] = useState('');
const isDefaultProfile = profile?.is_default;
useEffect(() => {
async function fetchStreamUrl() {
@ -91,21 +94,58 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
max_streams: 0,
search_pattern: '',
replace_pattern: '',
notes: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().required('Search pattern is required'),
replace_pattern: Yup.string().required('Replace pattern is required'),
search_pattern: Yup.string().when([], {
is: () => !isDefaultProfile,
then: (schema) => schema.required('Search pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
replace_pattern: Yup.string().when([], {
is: () => !isDefaultProfile,
then: (schema) => schema.required('Replace pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
notes: Yup.string(), // Optional field
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log('submiting');
// For default profiles, only send name and custom_properties (notes)
let submitValues;
if (isDefaultProfile) {
submitValues = {
name: values.name,
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
},
};
} else {
// For regular profiles, send all fields
submitValues = {
name: values.name,
max_streams: values.max_streams,
search_pattern: values.search_pattern,
replace_pattern: values.replace_pattern,
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
},
};
}
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...values,
...submitValues,
});
} else {
await API.addM3UProfile(m3u.id, values);
await API.addM3UProfile(m3u.id, submitValues);
}
resetForm();
@ -123,11 +163,12 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
max_streams: profile.max_streams,
search_pattern: profile.search_pattern,
replace_pattern: profile.replace_pattern,
notes: profile.custom_properties?.notes || '',
});
} else {
formik.resetForm();
}
}, [profile]);
}, [profile]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSampleInputChange = (e) => {
setSampleInput(e.target.value);
@ -142,7 +183,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
regex,
(match) => `<mark style="background-color: #ffee58;">${match}</mark>`
);
} catch (e) {
} catch {
return sampleInput;
}
};
@ -152,13 +193,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
try {
const regex = new RegExp(searchPattern, 'g');
return sampleInput.replace(regex, replacePattern);
} catch (e) {
} catch {
return sampleInput;
}
};
return (
<Modal opened={isOpen} onClose={onClose} title="M3U Profile" size="lg">
<Modal
opened={isOpen}
onClose={onClose}
title={
isDefaultProfile
? 'Edit Default Profile (Name & Notes Only)'
: 'M3U Profile'
}
size="lg"
>
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
@ -168,33 +218,64 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
onChange={formik.handleChange}
error={formik.errors.name ? formik.touched.name : ''}
/>
<TextInput
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
{/* Only show max streams field for non-default profiles */}
{!isDefaultProfile && (
<NumberInput
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={(value) =>
formik.setFieldValue('max_streams', value || 0)
}
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
min={0}
placeholder="0 = unlimited"
/>
)}
{/* Only show search/replace fields for non-default profiles */}
{!isDefaultProfile && (
<>
<TextInput
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={
formik.errors.search_pattern
? formik.touched.search_pattern
: ''
}
/>
<TextInput
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={
formik.errors.replace_pattern
? formik.touched.replace_pattern
: ''
}
/>
</>
)}
<Textarea
id="notes"
name="notes"
label="Notes"
placeholder="Add any notes or comments about this profile..."
value={formik.values.notes}
onChange={formik.handleChange}
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
/>
<TextInput
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={
formik.errors.search_pattern ? formik.touched.search_pattern : ''
}
/>
<TextInput
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={
formik.errors.replace_pattern ? formik.touched.replace_pattern : ''
}
error={formik.errors.notes ? formik.touched.notes : ''}
minRows={2}
maxRows={4}
autosize
/>
<Flex
@ -215,55 +296,60 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
</Flex>
</form>
<Title order={4} mt={15} mb={10}>
Live Regex Demonstration
</Title>
{/* Only show regex demonstration for non-default profiles */}
{!isDefaultProfile && (
<>
<Title order={4} mt={15} mb={10}>
Live Regex Demonstration
</Title>
<Paper shadow="sm" p="xs" radius="md" withBorder mb={8}>
<Text size="sm" weight={500} mb={3}>
Sample Text
</Text>
<TextInput
value={sampleInput}
onChange={handleSampleInputChange}
placeholder="Enter a sample URL to test with"
size="sm"
/>
</Paper>
<Grid gutter="xs">
<Grid.Col span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Paper shadow="sm" p="xs" radius="md" withBorder mb={8}>
<Text size="sm" weight={500} mb={3}>
Matched Text{' '}
<Badge size="xs" color="yellow">
highlighted
</Badge>
Sample Text
</Text>
<Text
<TextInput
value={sampleInput}
onChange={handleSampleInputChange}
placeholder="Enter a sample URL to test with"
size="sm"
dangerouslySetInnerHTML={{
__html: getHighlightedSearchText(),
}}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
</Paper>
</Grid.Col>
<Grid.Col span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Text size="sm" weight={500} mb={3}>
Result After Replace
</Text>
<Text
size="sm"
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
>
{getLocalReplaceResult()}
</Text>
</Paper>
</Grid.Col>
</Grid>
<Grid gutter="xs">
<Grid.Col span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Text size="sm" weight={500} mb={3}>
Matched Text{' '}
<Badge size="xs" color="yellow">
highlighted
</Badge>
</Text>
<Text
size="sm"
dangerouslySetInnerHTML={{
__html: getHighlightedSearchText(),
}}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
</Paper>
</Grid.Col>
<Grid.Col span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Text size="sm" weight={500} mb={3}>
Result After Replace
</Text>
<Text
size="sm"
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
>
{getLocalReplaceResult()}
</Text>
</Paper>
</Grid.Col>
</Grid>
</>
)}
</Modal>
);
};

View file

@ -122,8 +122,11 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
};
const closeEditor = () => {
setProfile(null);
setProfileEditorOpen(false);
// Delay clearing the profile until after the modal animation completes
setTimeout(() => {
setProfile(null);
}, 300); // Mantine modal animation typically takes ~200-300ms
};
const showAccountInfo = (profile) => {
@ -209,7 +212,19 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
{/* Header with name and status badges */}
<Group justify="space-between" align="center">
<Group spacing="sm" align="center">
<Text fw={600}>{item.name}</Text>
<Stack spacing={2}>
<Text fw={600}>{item.name}</Text>
{/* Show notes if they exist */}
{item.custom_properties?.notes && (
<Text
size="xs"
c="dimmed"
style={{ fontStyle: 'italic' }}
>
{item.custom_properties.notes}
</Text>
)}
</Stack>
{playlist?.account_type === 'XC' &&
item.custom_properties && (
<Group spacing="xs">
@ -279,18 +294,23 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
size="sm"
/>
{/* Always show edit button, but limit what can be edited for default profiles */}
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editProfile(item)}
title={
item.is_default
? 'Edit profile name and notes'
: 'Edit profile'
}
>
<SquarePen size="20" />
</ActionIcon>
{!item.is_default && (
<>
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editProfile(item)}
title="Edit profile"
>
<SquarePen size="20" />
</ActionIcon>
<ActionIcon
color={theme.tailwind.red[6]}
onClick={() => deleteProfile(item.id)}