mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
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:
parent
f218eaad51
commit
74aff3fb1a
4 changed files with 253 additions and 89 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue