diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py
index 052b9733..05462d0f 100644
--- a/apps/m3u/serializers.py
+++ b/apps/m3u/serializers.py
@@ -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):
diff --git a/frontend/src/components/forms/AccountInfoModal.jsx b/frontend/src/components/forms/AccountInfoModal.jsx
index fa44df90..b8522638 100644
--- a/frontend/src/components/forms/AccountInfoModal.jsx
+++ b/frontend/src/components/forms/AccountInfoModal.jsx
@@ -284,6 +284,30 @@ const AccountInfoModal = ({ isOpen, onClose, profile, onRefresh }) => {
+ {/* Profile Notes */}
+ {currentProfile?.custom_properties?.notes && (
+ <>
+
+
+
+ Profile Notes
+
+
+
+ {currentProfile.custom_properties.notes}
+
+
+
+ >
+ )}
+
{/* Detailed Information Table */}
diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx
index ac6adca2..353e48d1 100644
--- a/frontend/src/components/forms/M3UProfile.jsx
+++ b/frontend/src/components/forms/M3UProfile.jsx
@@ -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) => `${match}`
);
- } 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 (
-
+
-
- Live Regex Demonstration
-
+ {/* Only show regex demonstration for non-default profiles */}
+ {!isDefaultProfile && (
+ <>
+
+ Live Regex Demonstration
+
-
-
- Sample Text
-
-
-
-
-
-
-
+
- Matched Text{' '}
-
- highlighted
-
+ Sample Text
-
-
-
-
-
- Result After Replace
-
-
- {getLocalReplaceResult()}
-
-
-
-
+
+
+
+
+ Matched Text{' '}
+
+ highlighted
+
+
+
+
+
+
+
+
+
+ Result After Replace
+
+
+ {getLocalReplaceResult()}
+
+
+
+
+ >
+ )}
);
};
diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx
index db7ceb39..7dc40029 100644
--- a/frontend/src/components/forms/M3UProfiles.jsx
+++ b/frontend/src/components/forms/M3UProfiles.jsx
@@ -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 */}
- {item.name}
+
+ {item.name}
+ {/* Show notes if they exist */}
+ {item.custom_properties?.notes && (
+
+ {item.custom_properties.notes}
+
+ )}
+
{playlist?.account_type === 'XC' &&
item.custom_properties && (
@@ -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 */}
+ editProfile(item)}
+ title={
+ item.is_default
+ ? 'Edit profile name and notes'
+ : 'Edit profile'
+ }
+ >
+
+
+
{!item.is_default && (
<>
- editProfile(item)}
- title="Edit profile"
- >
-
-
-
deleteProfile(item.id)}