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