diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1234c5..1712d447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **XC live playback URL building now normalizes account server URLs.** On-demand live URLs (introduced for Server Group profile rotation in 0.27.0) rebuild `/live/{user}/{pass}/{stream_id}.ts` from current credentials instead of reusing the synced `stream.url`. That path now runs `normalize_server_url()` so pasted API URLs (e.g. `/player_api.php` with query params) are stripped while sub-paths like `/server1` are preserved. `get_transformed_credentials()` normalizes the base URL at the source; VOD movie and episode relations use the same helper instead of constructing a throwaway `XCClient` (which also avoided per-request HTTP session setup). (Fixes #1363) - **Live proxy now releases geventpool DB connections on more paths.** `stream_ts()` calls `close_old_connections()` before `StreamingHttpResponse` so clients waiting on channel init do not keep a pool slot while the generator polls Redis. `generate_stream_url()`, `get_stream_info_for_switch()`, `get_alternate_streams()`, and `get_connections_left()` release in `finally` blocks after ORM work on stream-manager failover paths that run outside Django's request cycle. TS client disconnect cleanup matches fMP4, and `ChannelService` metadata helpers release after their ORM lookups. ## [0.27.0] - 2026-06-16 diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 8c69965e..2d5ac1ee 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -2816,12 +2816,13 @@ def get_transformed_credentials(account, profile=None): logger.warning(f"Could not get primary profile for account {account.name}: {e}") profile = None - base_url = account.server_url + from core.xtream_codes import normalize_server_url + + base_url = normalize_server_url(account.server_url) base_username = account.username base_password = account.password # Build a complete URL with credentials (similar to how IPTV URLs are structured) # Format: http://server.com:port/live/username/password/1234.ts if base_url and base_username and base_password: - # Remove trailing slash from server URL if present clean_server_url = base_url.rstrip('/') # Build the complete URL with embedded credentials diff --git a/apps/m3u/tests/test_xc_live_url.py b/apps/m3u/tests/test_xc_live_url.py new file mode 100644 index 00000000..8353241d --- /dev/null +++ b/apps/m3u/tests/test_xc_live_url.py @@ -0,0 +1,139 @@ +"""Tests for XC stream URL normalization and on-demand URL building.""" + +from django.test import TestCase + +from apps.channels.models import Stream +from apps.m3u.models import M3UAccount, M3UAccountProfile +from apps.m3u.tasks import get_transformed_credentials +from apps.proxy.live_proxy.url_utils import _resolve_live_stream_url +from apps.vod.models import Episode, M3UEpisodeRelation, M3UMovieRelation, Movie, Series +from core.xtream_codes import normalize_server_url + + +class NormalizeServerUrlTests(TestCase): + def test_preserves_sub_path(self): + url = "https://myserver.fun/server1" + self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1") + + def test_strips_player_api_php_and_query_params(self): + url = "https://myserver.fun/server1/player_api.php?username=foo&password=bar" + self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1") + + def test_strips_trailing_slash(self): + url = "https://myserver.fun/server1/" + self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1") + + def test_nested_sub_path_with_php_endpoint(self): + url = "http://server/Pluto/gb/player_api.php" + self.assertEqual(normalize_server_url(url), "http://server/Pluto/gb") + + +class GetTransformedCredentialsTests(TestCase): + def test_returns_normalized_server_url(self): + account = M3UAccount.objects.create( + name="Sub-path XC", + account_type="XC", + server_url="https://myserver.fun/server1/player_api.php?username=foo", + username="alice", + password="secret", + ) + profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + + server_url, username, password = get_transformed_credentials(account, profile) + + self.assertEqual(server_url, "https://myserver.fun/server1") + self.assertEqual(username, "alice") + self.assertEqual(password, "secret") + + +class ResolveLiveStreamUrlTests(TestCase): + def test_builds_url_from_normalized_base_not_raw_account_url(self): + account = M3UAccount.objects.create( + name="Live sub-path", + account_type="XC", + server_url="https://myserver.fun/server1/player_api.php?username=foo", + username="alice", + password="secret", + ) + profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + stream = Stream.objects.create( + name="Test Channel", + m3u_account=account, + stream_id="12345", + url="https://myserver.fun/server1/live/olduser/oldpass/12345.ts", + ) + + url = _resolve_live_stream_url(stream, account, profile) + + self.assertEqual( + url, + "https://myserver.fun/server1/live/alice/secret/12345.ts", + ) + + def test_std_account_uses_stored_stream_url(self): + account = M3UAccount.objects.create( + name="STD account", + account_type="STD", + server_url="https://example.com/list.m3u", + username="alice", + password="secret", + ) + profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True) + stream = Stream.objects.create( + name="STD Stream", + m3u_account=account, + url="https://provider.example/stream/abc123", + ) + + url = _resolve_live_stream_url(stream, account, profile) + + self.assertEqual(url, "https://provider.example/stream/abc123") + + +class VodStreamUrlTests(TestCase): + def setUp(self): + self.account = M3UAccount.objects.create( + name="VOD sub-path", + account_type="XC", + server_url="https://myserver.fun/server1/player_api.php?username=foo", + username="alice", + password="secret", + ) + + def test_movie_relation_builds_normalized_url(self): + movie = Movie.objects.create(name="Test Movie") + relation = M3UMovieRelation.objects.create( + m3u_account=self.account, + movie=movie, + stream_id="999", + container_extension="mkv", + ) + + url = relation.get_stream_url() + + self.assertEqual( + url, + "https://myserver.fun/server1/movie/alice/secret/999.mkv", + ) + + def test_episode_relation_builds_normalized_url(self): + series = Series.objects.create(name="Test Series") + episode = Episode.objects.create( + series=series, + name="Pilot", + season_number=1, + episode_number=1, + ) + relation = M3UEpisodeRelation.objects.create( + m3u_account=self.account, + episode=episode, + stream_id="888", + container_extension="mp4", + ) + + url = relation.get_stream_url() + + self.assertEqual( + url, + "https://myserver.fun/server1/series/alice/secret/888.mp4", + ) diff --git a/apps/vod/models.py b/apps/vod/models.py index dd7d1753..f609be81 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -243,12 +243,12 @@ class M3UMovieRelation(models.Model): def get_stream_url(self): """Get the full stream URL for this movie from this provider""" - # Build URL dynamically for XtreamCodes accounts if self.m3u_account.account_type == 'XC': - from core.xtream_codes import Client as XCClient - # Use XC client's URL normalization to handle malformed URLs - # (e.g., URLs with /player_api.php or query parameters) - normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url) + from core.xtream_codes import normalize_server_url + + normalized_url = normalize_server_url(self.m3u_account.server_url) + if not normalized_url: + return None username = self.m3u_account.username password = self.m3u_account.password return f"{normalized_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" @@ -292,13 +292,12 @@ class M3UEpisodeRelation(models.Model): def get_stream_url(self): """Get the full stream URL for this episode from this provider""" - from core.xtream_codes import Client as XtreamCodesClient - if self.m3u_account.account_type == 'XC': - # For XtreamCodes accounts, build the URL dynamically - # Use XC client's URL normalization to handle malformed URLs - # (e.g., URLs with /player_api.php or query parameters) - normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url) + from core.xtream_codes import normalize_server_url + + normalized_url = normalize_server_url(self.m3u_account.server_url) + if not normalized_url: + return None username = self.m3u_account.username password = self.m3u_account.password return f"{normalized_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" diff --git a/core/xtream_codes.py b/core/xtream_codes.py index 195d4428..bcbe22e2 100644 --- a/core/xtream_codes.py +++ b/core/xtream_codes.py @@ -5,6 +5,26 @@ import json logger = logging.getLogger(__name__) + +def normalize_server_url(url): + """Normalize server URL: strip XC API endpoints and query params, preserve base path.""" + if not url: + return url + + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(url.strip()) + path = parsed.path.rstrip('/') + + # XC API endpoints are always .php files; legitimate base paths never are. + # Stripping the trailing segment when it ends in .php handles any pasted API URL. + last_segment = path.rsplit('/', 1)[-1] + if last_segment.endswith('.php'): + path = path[:-(len(last_segment) + 1)] if '/' in path else '' + + return urlunparse((parsed.scheme, parsed.netloc, path, '', '', '')) + + class Client: """Xtream Codes API Client with robust error handling""" @@ -43,22 +63,10 @@ class Client: self.server_info = None def _normalize_url(self, url): - """Normalize server URL: strip XC API endpoints and query params, preserve base path.""" - if not url: + normalized = normalize_server_url(url) + if not normalized: raise ValueError("Server URL cannot be empty") - - from urllib.parse import urlparse, urlunparse - - parsed = urlparse(url.strip()) - path = parsed.path.rstrip('/') - - # XC API endpoints are always .php files; legitimate base paths never are. - # Stripping the trailing segment when it ends in .php handles any pasted API URL. - last_segment = path.rsplit('/', 1)[-1] - if last_segment.endswith('.php'): - path = path[:-(len(last_segment) + 1)] if '/' in path else '' - - return urlunparse((parsed.scheme, parsed.netloc, path, '', '', '')) + return normalized def _make_request(self, endpoint, params=None): """Make request with detailed error handling"""