Enhancement: 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.

This commit is contained in:
SergeantPanda 2026-03-29 17:04:56 -05:00
parent 698f9ff4c0
commit 9579248485
2 changed files with 149 additions and 21 deletions

View file

@ -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`).

View file

@ -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 && (
<>
<TextInput
label="Search Pattern (Regex)"
description="A regular expression matching the part of the stream URL you want to replace. For most users, matching just the credentials is enough."
placeholder="e.g. username1/password1"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={errors.search_pattern?.message}
/>
<TextInput
label="Replace Pattern"
description="The value to substitute in place of the matched text. Use $1, $2, etc. to reference regex capture groups."
placeholder="e.g. username2/password2"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={errors.replace_pattern?.message}
/>
{isXC && (
<SegmentedControl
mt="xs"
mb="xs"
fullWidth
size="xs"
value={xcMode}
onChange={handleXcModeChange}
data={[
{ label: 'Simple', value: 'simple' },
{ label: 'Advanced (Regex)', value: 'advanced' },
]}
/>
)}
{isXC && xcMode === 'simple' ? (
<>
<TextInput
label="New Username"
description="Your updated XC account username. The current username in all stream URLs will be replaced with this."
placeholder="e.g. username2"
value={newUsername}
onChange={(e) => {
setNewUsername(e.target.value);
setSimpleErrors((s) => ({ ...s, newUsername: undefined }));
}}
error={simpleErrors.newUsername}
/>
<TextInput
label="New Password"
description="Your updated XC account password. The current password in all stream URLs will be replaced with this."
placeholder="e.g. password2"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setSimpleErrors((s) => ({ ...s, newPassword: undefined }));
}}
error={simpleErrors.newPassword}
/>
</>
) : (
<>
<TextInput
label="Search Pattern (Regex)"
description="A regular expression matching the part of the stream URL you want to replace. For most users, matching just the credentials is enough."
placeholder="e.g. username1/password1"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={errors.search_pattern?.message}
/>
<TextInput
label="Replace Pattern"
description="The value to substitute in place of the matched text. Use $1, $2, etc. to reference regex capture groups."
placeholder="e.g. username2/password2"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={errors.replace_pattern?.message}
/>
</>
)}
</>
)}
@ -326,8 +453,8 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
</Flex>
</form>
{/* Only show regex demonstration for non-default profiles */}
{!isDefaultProfile && (
{/* Only show regex demonstration for non-default profiles in advanced mode */}
{!isDefaultProfile && (!isXC || xcMode === 'advanced') && (
<>
<Title order={4} mt={15} mb={10}>
Live Regex Demonstration