fix(xc): normalize server URLs for live playback and stream URLs (Fixes #1363)
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run

- Implemented `normalize_server_url()` to standardize account server URLs, ensuring that on-demand live URLs are built correctly without including API endpoints or query parameters.
- Updated `get_transformed_credentials()` and stream URL generation in `M3UMovieRelation` and `M3UEpisodeRelation` to utilize the new normalization function, improving URL handling for Xtream Codes accounts.
This commit is contained in:
SergeantPanda 2026-06-18 10:12:12 -05:00
parent f991338376
commit cfe58db222
5 changed files with 176 additions and 28 deletions

View file

@ -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

View file

@ -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

View file

@ -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",
)

View file

@ -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'}"

View file

@ -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"""