From 086cc749591f5b7b76c8185df8038937837df806 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 29 Mar 2026 17:55:08 -0500 Subject: [PATCH] =?UTF-8?q?Bug=20Fix:=20M3U=20profile=20URL=20rewriting=20?= =?UTF-8?q?now=20uses=20the=20`regex`=20module=20instead=20of=20`re`=20acr?= =?UTF-8?q?oss=20all=20URL=20transform=20code=20paths=20(`url=5Futils.tran?= =?UTF-8?q?sform=5Furl`,=20`core/views.py`,=20`vod=5Fproxy/=5Ftransform=5F?= =?UTF-8?q?url`,=20`tasks.get=5Ftransformed=5Fcredentials`,=20and=20the=20?= =?UTF-8?q?WebSocket=20live-preview=20handler=20in=20`consumers.py`).=20Th?= =?UTF-8?q?e=20`regex`=20module=20natively=20accepts=20JavaScript/PCRE-sty?= =?UTF-8?q?le=20named=20capture=20groups=20(`(=3F...)`)=20without=20?= =?UTF-8?q?any=20conversion,=20eliminating=20the=20root=20cause=20of=20pat?= =?UTF-8?q?terns=20that=20matched=20in=20the=20frontend=20live=20preview?= =?UTF-8?q?=20but=20failed=20on=20the=20backend=20with=20a=20`re.error`.?= =?UTF-8?q?=20As=20a=20further=20improvement,=20`regex`=20also=20supports?= =?UTF-8?q?=20variable-length=20lookbehind=20assertions=20(e.g.=20`(=3F`=20=E2=86=92=20`\g`=20and=20`$1`/`$2`/=E2=80=A6=20?= =?UTF-8?q?=E2=86=92=20`\1`/`\2`/=E2=80=A6=20(Python=20replacement=20synta?= =?UTF-8?q?x).=20Also=20fixed=20a=20bug=20in=20the=20WebSocket=20preview?= =?UTF-8?q?=20handler=20where=20a=20pattern=20error=20was=20incorrectly=20?= =?UTF-8?q?returning=20the=20search=20pattern=20string=20as=20the=20previe?= =?UTF-8?q?w=20output=20instead=20of=20the=20original=20URL.=20(Fixes=20#1?= =?UTF-8?q?005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 9 ++++++--- apps/proxy/ts_proxy/url_utils.py | 11 ++++++----- apps/proxy/vod_proxy/views.py | 9 ++++++--- core/views.py | 9 ++++++--- dispatcharr/consumers.py | 6 +++--- frontend/src/components/forms/M3UProfile.jsx | 2 +- 7 files changed, 29 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e046aa43..249ea29b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- M3U profile URL rewriting now uses the `regex` module instead of `re` across all URL transform code paths (`url_utils.transform_url`, `core/views.py`, `vod_proxy/_transform_url`, `tasks.get_transformed_credentials`, and the WebSocket live-preview handler in `consumers.py`). The `regex` module natively accepts JavaScript/PCRE-style named capture groups (`(?...)`) without any conversion, eliminating the root cause of patterns that matched in the frontend live preview but failed on the backend with a `re.error`. As a further improvement, `regex` also supports variable-length lookbehind assertions (e.g. `(?<=a+)`), which `re` rejects with an error; patterns using these will now work correctly on the backend as well. Replace-pattern JS tokens are still normalised before calling `regex.sub`: `$` → `\g` and `$1`/`$2`/… → `\1`/`\2`/… (Python replacement syntax). Also fixed a bug in the WebSocket preview handler where a pattern error was incorrectly returning the search pattern string as the preview output instead of the original URL. (Fixes #1005) - Web UI stream preview (`FloatingVideo`) was calling `mpegts.createPlayer()` with all `Config` options (e.g. `enableWorker`, `liveSync`, `headers`) merged into the first `MediaDataSource` argument. mpegts.js only reads `Config` from the optional second argument; unrecognised fields in the first are silently ignored. As a result all player configuration was effectively the library defaults — worker offloading was disabled, latency management had no effect, and the `Authorization: Bearer` header (required for user identification) was never sent. Fixed by splitting into the correct two-argument call. Both `liveBufferLatencyChasing` and `liveSync` have been disabled, eliminating playback-rate fluctuations that caused audible stuttering on live streams. SourceBuffer cleanup thresholds were also relaxed from 10s/5s to 120s/60s to prevent frequent SourceBuffer pauses. - HTML named entities in XMLTV EPG files are now resolved to Unicode characters before lxml parsing. Some EPG providers (particularly French and other European sources) use HTML named entities like `é`, `î`, `ü` in channel names, program titles, and metadata. These are not valid XML entities — lxml 6.0.2 with `recover=True` silently drops them, causing characters to go missing (e.g., "Chaîne Télé" becomes "Chane Tl"). A preprocessing step in `fetch_xmltv()` now resolves HTML named entities while preserving the 5 XML-predefined entities, detecting encoding from the XML declaration (falling back to UTF-8), and processing line-by-line to a temp file before atomically replacing the original. (Closes #1095) — Thanks [@CodeBormen](https://github.com/CodeBormen) - Duplicate recordings created when EPG sources refresh and re-evaluate series rules (Fixes #940) — Thanks [@CodeBormen](https://github.com/CodeBormen): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 9c31fa75..354a2ef4 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1,6 +1,7 @@ # apps/m3u/tasks.py import logging import re +import regex import requests import os import gc @@ -2399,11 +2400,13 @@ def get_transformed_credentials(account, profile=None): # Apply profile-specific transformations if profile is provided if profile and profile.search_pattern and profile.replace_pattern: try: - # Handle backreferences in the replacement pattern - safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern) + # Handle backreferences: convert JS-style $ -> \g, $1 -> \1 + # regex module accepts JS-style (?...) named groups natively + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', profile.replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) # Apply transformation to the complete URL - transformed_complete_url = re.sub(profile.search_pattern, safe_replace_pattern, complete_url) + transformed_complete_url = regex.sub(profile.search_pattern, safe_replace_pattern, complete_url) logger.info(f"Transformed complete URL: {complete_url} -> {transformed_complete_url}") # Extract components from the transformed URL diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 55e44c35..5f4615a4 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -3,7 +3,7 @@ Utilities for handling stream URLs and transformations. """ import logging -import re +import regex from typing import Optional, Tuple, List from django.shortcuts import get_object_or_404 from apps.channels.models import Channel, Stream @@ -146,13 +146,14 @@ def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> logger.debug(f" base URL: {input_url}") logger.debug(f" search: {search_pattern}") - # Handle backreferences in the replacement pattern - safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern) + # Convert JS-style backreferences in replace pattern: $ -> \g, $1 -> \1 + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) logger.debug(f" replace: {replace_pattern}") logger.debug(f" safe replace: {safe_replace_pattern}") - # Apply the transformation - stream_url = re.sub(search_pattern, safe_replace_pattern, input_url) + # Apply the transformation (regex module accepts JS-style (?...) natively) + stream_url = regex.sub(search_pattern, safe_replace_pattern, input_url) logger.info(f"Generated stream url: {stream_url}") return stream_url diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 4382caaa..8db51ebd 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -270,17 +270,20 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None): def _transform_url(original_url, m3u_profile): """Transform URL based on M3U profile settings""" try: - import re + import regex if not original_url: return None search_pattern = m3u_profile.search_pattern replace_pattern = m3u_profile.replace_pattern - safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern) + # Convert JS-style backreferences in replace: $ -> \g, $1 -> \1 + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) if search_pattern and replace_pattern: - transformed_url = re.sub(search_pattern, safe_replace_pattern, original_url) + # regex module accepts JS-style (?...) named groups natively + transformed_url = regex.sub(search_pattern, safe_replace_pattern, original_url) return transformed_url return original_url diff --git a/core/views.py b/core/views.py index b3e6dfb2..7321367c 100644 --- a/core/views.py +++ b/core/views.py @@ -4,7 +4,7 @@ from shlex import split as shlex_split import sys import subprocess import logging -import re +import regex import redis from django.conf import settings @@ -132,10 +132,13 @@ def stream_view(request, channel_uuid): # Prepare the pattern replacement. logger.debug("Executing the following pattern replacement:") logger.debug(f" search: {active_profile.search_pattern}") - safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', active_profile.replace_pattern) + # Convert JS-style backreferences in replace: $ -> \g, $1 -> \1 + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', active_profile.replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) logger.debug(f" replace: {active_profile.replace_pattern}") logger.debug(f" safe replace: {safe_replace_pattern}") - stream_url = re.sub(active_profile.search_pattern, safe_replace_pattern, input_url) + # regex module accepts JS-style (?...) named groups natively + stream_url = regex.sub(active_profile.search_pattern, safe_replace_pattern, input_url) logger.debug(f"Generated stream url: {stream_url}") # Get the stream profile set on the channel. diff --git a/dispatcharr/consumers.py b/dispatcharr/consumers.py index 4e21bdae..7b62d957 100644 --- a/dispatcharr/consumers.py +++ b/dispatcharr/consumers.py @@ -1,6 +1,6 @@ import json from channels.generic.websocket import AsyncWebsocketConsumer -import re, logging +import regex, logging logger = logging.getLogger(__name__) @@ -54,9 +54,9 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer): # Apply the transformation using the replace_with_mark function try: - search_preview = re.sub(data["search"], replace_with_mark, data["url"]) + search_preview = regex.sub(data["search"], replace_with_mark, data["url"]) except Exception as e: - search_preview = data["search"] + search_preview = data["url"] logger.error(f"Failed to generate replace preview: {e}") result = transform_url(data["url"], data["search"], data["replace"]) diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index bbb2823c..65da3e17 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -290,7 +290,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setXcMode(mode); }; - // Local regex testing for immediate visual feedback + // Local regex for the live demo preview const getHighlightedSearchText = () => { if (!searchPattern || !sampleInput) return sampleInput; try {