mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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
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:
parent
f991338376
commit
cfe58db222
5 changed files with 176 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
139
apps/m3u/tests/test_xc_live_url.py
Normal file
139
apps/m3u/tests/test_xc_live_url.py
Normal 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",
|
||||
)
|
||||
|
|
@ -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'}"
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue