diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58e5d90d..e046aa43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- M3U Profile form (XC accounts): added a **Simple / Advanced** mode toggle for credential-based URL rewriting. In Simple mode users enter just a new username and password; the search and replace patterns are built automatically from the account's current credentials. In Advanced mode the full regex fields are shown as before. The selected mode is saved to `custom_properties.xcMode` and auto-detected on existing profiles (a profile whose search pattern matches the account's current `username/password` is recognised as Simple automatically). The Live Regex Demonstration panel is hidden in Simple mode.
- XtreamCodes VOD endpoints (`/movie/` and `/series/`) no longer redirect clients to a UUID-based proxy URL. Requests are now handled directly in the proxy layer via `stream_xc_movie` and `stream_xc_episode`, which call `stream_vod()` internally. The original XC path is preserved for the client throughout the stream.
- `CustomTable` column layout now supports flexible (`grow`) columns alongside fixed-width ones:
- Column definitions accept a `grow` property (boolean or number) to opt into flex layout. A numeric value sets the flex-grow weight, allowing relative sizing between grow columns (e.g. `grow: 2` gives a column twice the share of spare space as `grow: 1`).
diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx
index d00996d7..bbb2823c 100644
--- a/frontend/src/components/forms/M3UProfile.jsx
+++ b/frontend/src/components/forms/M3UProfile.jsx
@@ -15,6 +15,7 @@ import {
Grid,
Textarea,
NumberInput,
+ SegmentedControl,
} from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { useWebSocket } from '../../WebSocket';
@@ -26,6 +27,10 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [replacePattern, setReplacePattern] = useState('');
const [debouncedPatterns, setDebouncedPatterns] = useState({});
const [sampleInput, setSampleInput] = useState('');
+ const [xcMode, setXcMode] = useState('simple');
+ const [newUsername, setNewUsername] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [simpleErrors, setSimpleErrors] = useState({});
const isDefaultProfile = profile?.is_default;
const isXC = m3u?.account_type === 'XC';
@@ -45,12 +50,12 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const schema = Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().when([], {
- is: () => !isDefaultProfile,
+ is: () => !isDefaultProfile && !isXC,
then: (schema) => schema.required('Search pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
replace_pattern: Yup.string().when([], {
- is: () => !isDefaultProfile,
+ is: () => !isDefaultProfile && !isXC,
then: (schema) => schema.required('Replace pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
@@ -64,6 +69,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
reset,
setValue,
watch,
+ setError,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
@@ -83,6 +89,32 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
expDateValue = null;
}
+ // For XC simple mode: validate simple inputs and build patterns from credentials
+ if (isXC && xcMode === 'simple' && !isDefaultProfile) {
+ const errs = {};
+ if (!newUsername.trim()) errs.newUsername = 'New username is required';
+ if (!newPassword.trim()) errs.newPassword = 'New password is required';
+ if (Object.keys(errs).length > 0) {
+ setSimpleErrors(errs);
+ return;
+ }
+ setSimpleErrors({});
+ values.search_pattern = `${m3u?.username || ''}/${m3u?.password || ''}`;
+ values.replace_pattern = `${newUsername.trim()}/${newPassword.trim()}`;
+ }
+
+ // For XC advanced mode: validate regex pattern fields
+ if (isXC && xcMode === 'advanced' && !isDefaultProfile) {
+ if (!searchPattern.trim()) {
+ setError('search_pattern', { message: 'Search pattern is required' });
+ return;
+ }
+ if (!replacePattern.trim()) {
+ setError('replace_pattern', { message: 'Replace pattern is required' });
+ return;
+ }
+ }
+
// For default profiles, only send name and custom_properties (notes)
let submitValues;
if (isDefaultProfile) {
@@ -105,6 +137,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
+ ...(isXC ? { xcMode } : {}),
},
};
}
@@ -200,12 +233,63 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
reset(defaultValues);
setSearchPattern(profile?.search_pattern || '');
setReplacePattern(profile?.replace_pattern || '');
- }, [defaultValues, profile, reset]);
+ if (isXC && !isDefaultProfile) {
+ const storedMode = profile?.custom_properties?.xcMode;
+ let detectedMode;
+ if (storedMode) {
+ detectedMode = storedMode;
+ } else if (
+ profile?.search_pattern &&
+ profile.search_pattern === `${m3u?.username}/${m3u?.password}`
+ ) {
+ detectedMode = 'simple';
+ } else if (profile?.search_pattern) {
+ detectedMode = 'advanced';
+ } else {
+ detectedMode = 'simple';
+ }
+ setXcMode(detectedMode);
+ if (detectedMode === 'simple') {
+ const rp = profile?.replace_pattern || '';
+ const idx = rp.indexOf('/');
+ setNewUsername(idx === -1 ? rp : rp.slice(0, idx));
+ setNewPassword(idx === -1 ? '' : rp.slice(idx + 1));
+ }
+ }
+ }, [
+ defaultValues,
+ isDefaultProfile,
+ isXC,
+ m3u?.password,
+ m3u?.username,
+ profile,
+ reset,
+ ]);
const handleSampleInputChange = (e) => {
setSampleInput(e.target.value);
};
+ const handleXcModeChange = (mode) => {
+ if (mode === 'advanced' && xcMode === 'simple') {
+ // Pre-populate regex fields from current simple values
+ const sp = `${m3u?.username || ''}/${m3u?.password || ''}`;
+ const rp = `${newUsername}/${newPassword}`;
+ setSearchPattern(sp);
+ setReplacePattern(rp);
+ setValue('search_pattern', sp);
+ setValue('replace_pattern', rp);
+ } else if (mode === 'simple' && xcMode === 'advanced') {
+ // Parse current replace pattern back into username/password
+ const idx = replacePattern.indexOf('/');
+ setNewUsername(
+ idx === -1 ? replacePattern : replacePattern.slice(0, idx)
+ );
+ setNewPassword(idx === -1 ? '' : replacePattern.slice(idx + 1));
+ }
+ setXcMode(mode);
+ };
+
// Local regex testing for immediate visual feedback
const getHighlightedSearchText = () => {
if (!searchPattern || !sampleInput) return sampleInput;
@@ -267,22 +351,65 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
{/* Only show search/replace fields for non-default profiles */}
{!isDefaultProfile && (
<>
-
-
+ {isXC && (
+
+ )}
+ {isXC && xcMode === 'simple' ? (
+ <>
+ {
+ setNewUsername(e.target.value);
+ setSimpleErrors((s) => ({ ...s, newUsername: undefined }));
+ }}
+ error={simpleErrors.newUsername}
+ />
+ {
+ setNewPassword(e.target.value);
+ setSimpleErrors((s) => ({ ...s, newPassword: undefined }));
+ }}
+ error={simpleErrors.newPassword}
+ />
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
>
)}
@@ -326,8 +453,8 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
- {/* Only show regex demonstration for non-default profiles */}
- {!isDefaultProfile && (
+ {/* Only show regex demonstration for non-default profiles in advanced mode */}
+ {!isDefaultProfile && (!isXC || xcMode === 'advanced') && (
<>
Live Regex Demonstration