mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 01:05:30 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/sethwv/1308
This commit is contained in:
commit
1d07b26a01
13 changed files with 283 additions and 190 deletions
|
|
@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Changed
|
||||
|
||||
- **`get_host_and_port` and `build_absolute_uri_with_port` moved from `apps/output/views.py` to `core/utils.py`.** Both helpers have no dependencies on anything in `apps/output` and are now used in `apps/channels/serializers.py` as well. Moving them to `core/utils` eliminates the need for a local import inside `LogoSerializer` and makes them available to the rest of the codebase without circular-import risk. `LogoSerializer.get_cache_url()` was also updated to use `build_absolute_uri_with_port` instead of `request.build_absolute_uri()`, so logo cache URLs now correctly include non-standard ports (fixing port-stripping for logo URLs behind reverse proxies, matching the existing fix applied to M3U and EPG URLs).
|
||||
- **`debian_install.sh` switched from Gunicorn to uWSGI with gevent workers.** The Debian/LXC bare-metal installer now deploys Dispatcharr under the same uWSGI + gevent stack used by the Docker image, eliminating a class of compatibility differences between the two deployment paths. The installer writes a `uwsgi-debian.ini` next to the app and manages uWSGI via a systemd service. The nginx site config now uses `uwsgi_pass` + `include uwsgi_params` instead of `proxy_pass`, which correctly populates `SERVER_PORT` in the WSGI environ so M3U playlist URLs include the configured port number (fixing the port-stripping bug from #1267 for bare-metal installs). Python 3.13 is now provisioned through uv's managed runtime so the install works on Debian 12 and Ubuntu 24.04 LTS regardless of the system Python version.
|
||||
- **`get_vod_streams` XC API response was missing metadata fields available from basic sync.** When a provider includes `director`, `cast`, `release_date`, `plot`, `genre`, or `year` in its `get_vod_streams` response, Dispatcharr stores those fields during the basic sync pass but was not including them in its own `get_vod_streams` XC output. The endpoint now outputs all six fields. The `trailer` key in the response also mapped to the wrong internal key (`trailer` instead of `youtube_trailer`) following the storage key rename, so the trailer field was always empty until an advanced refresh ran. Both issues are corrected. Users with existing libraries should trigger a VOD provider refresh to populate the missing fields. (Fixes #1228)
|
||||
- **Docker base image now uses a multi-stage build.** The builder stage installs compilers and dev headers (`gcc`, `g++`, `gfortran`, `build-essential`, `libopenblas-dev`, `libpcre3-dev`, `python3.13-dev`, `ninja-build`) to create the virtual environment and compile the legacy NumPy wheel (cpu-baseline=none for old hardware). The final runtime image starts from the same ffmpeg base, copies only the prebuilt venv and wheel from the builder, and installs only the runtime libraries needed at runtime - eliminating compiler binaries from production containers and reducing final image size. — Thanks [@kensac](https://github.com/kensac)
|
||||
- **`libpq-dev` removed from both build and runtime stages.** The project uses `psycopg[binary]` (psycopg3), which bundles its own statically linked copy of libpq inside the wheel. No system libpq headers or shared library are needed at build or runtime.
|
||||
|
|
@ -26,8 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **Database connections are now managed by a persistent per-worker pool.** Previously each request opened a fresh TCP connection to PostgreSQL, paid a full authentication handshake, and closed the connection at request end. `django-db-geventpool` now maintains a pool of warm connections per uWSGI worker; requests borrow a connection and return it when done, eliminating connection-setup overhead on every request. Pool size is bounded (`MAX_CONNS=8` per worker, `REUSE_CONNS=3` warm connections kept idle) to stay comfortably within PostgreSQL's default `max_connections=100` across all uWSGI workers, Celery workers, and Daphne. — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
|
||||
### Security
|
||||
|
||||
- **M3U endpoint no longer reflects POST body content in error responses.** The error message for disallowed POST requests previously echoed the raw request body back to the caller in a `text/html` response, which could be used for reflected XSS. The body is no longer included in the response. - Thanks [@sebastiondev](https://github.com/sebastiondev)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Authenticated users were not identified in VOD connection cards and stream events when streaming via the web player.** The VOD proxy now accepts a JWT via a `?token=` query parameter so browser `<video>` elements (which cannot send `Authorization` headers) can authenticate. The token is read on the initial request, the resolved user ID is stored in Redis, then retrieved on the actual streaming request after the session redirect. Previously the redirect discarded auth context and all JWT-authenticated VOD sessions were tracked as anonymous. (Fixes #1224)
|
||||
- **Deleting the last playlist (or any playlist) crashed the entire UI.** `removePlaylists()` in the playlists store filtered the `playlists` array but never removed the corresponding entries from the `profiles` map. After deletion, components reading `profiles[deletedId]` received `undefined` and crashed with `undefined is not an object (evaluating 'P[R].name')`, replacing the entire page with an error screen. The profiles map is now cleaned up atomically in the same state update as the playlists array. (Fixes #1269) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Switching providers in the VOD or Series detail modal had no effect.** The `provider-info` endpoints for movies and series always fetched data from the highest-priority provider, ignoring the `relation_id` the frontend sent when the user selected a different provider from the dropdown. The endpoints now accept an optional `relation_id` query parameter and fetch from that specific relation. The VOD modal also now shows the "Loading additional details..." indicator while the provider switch is in flight, matching the existing behaviour in the Series modal. (Fixes #1285) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Per-channel stream profile override was ignored during streaming.** `Channel.get_stream_profile()` read `self.stream_profile` directly, bypassing any `ChannelOverride.stream_profile` set by the user on auto-synced channels. The method now resolves through `effective_stream_profile_obj`, which checks the channel's override record first and falls back to the channel's own field. Channels without an override continue to behave identically to before. (Fixes #1268) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Web-player output profile was ignored for live streams started outside the Channels page.** `getShowVideoUrl` in `RecordingCardUtils.js` returned a raw proxy URL without calling `buildLiveStreamUrl`, so the output profile preference stored in `localStorage` was not applied when launching a stream from the TV Guide, Program Detail modal, DVR page, Recording Details modal, or Recording Card. Only the Channels page (StreamsTable) was calling `buildLiveStreamUrl` correctly. One additional import and one changed return value fix all affected entry points. (Fixes #1304) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from django.conf import settings
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from .models import User
|
||||
|
|
@ -84,3 +86,18 @@ class ApiKeyAuthentication(authentication.BaseAuthentication):
|
|||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
|
||||
|
||||
class QueryParamJWTAuthentication(JWTAuthentication):
|
||||
"""Reads a JWT from the `token` query parameter. Used for media endpoints
|
||||
where the browser cannot set Authorization headers (e.g. <video src>)."""
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = request.GET.get('token')
|
||||
if not raw_token:
|
||||
return None
|
||||
try:
|
||||
validated_token = self.get_validated_token(raw_token)
|
||||
return self.get_user(validated_token), validated_token
|
||||
except (InvalidToken, TokenError):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ def get_backup_dir() -> Path:
|
|||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
"""Check if we're using PostgreSQL."""
|
||||
return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
|
||||
return "postgresql" in settings.DATABASES["default"]["ENGINE"]
|
||||
|
||||
|
||||
def _get_pg_env() -> dict:
|
||||
|
|
@ -171,30 +170,25 @@ def _restore_postgresql(dump_file: Path) -> None:
|
|||
|
||||
|
||||
def _dump_sqlite(output_file: Path) -> None:
|
||||
"""Dump SQLite database using sqlite3 .backup command."""
|
||||
logger.info("Dumping SQLite database with sqlite3 .backup...")
|
||||
import sqlite3 as _sqlite3
|
||||
logger.info("Dumping SQLite database...")
|
||||
db_path = Path(settings.DATABASES["default"]["NAME"])
|
||||
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"SQLite database not found: {db_path}")
|
||||
|
||||
# Use sqlite3 .backup command via stdin for reliable execution
|
||||
result = subprocess.run(
|
||||
["sqlite3", str(db_path)],
|
||||
input=f".backup '{output_file}'\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
src = _sqlite3.connect(str(db_path))
|
||||
dst = _sqlite3.connect(str(output_file))
|
||||
try:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
src.close()
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"sqlite3 backup failed: {result.stderr}")
|
||||
raise RuntimeError(f"sqlite3 backup failed: {result.stderr}")
|
||||
|
||||
# Verify the backup file was created
|
||||
if not output_file.exists():
|
||||
raise RuntimeError("sqlite3 backup failed: output file not created")
|
||||
raise RuntimeError("SQLite backup failed: output file not created")
|
||||
|
||||
logger.info(f"sqlite3 backup completed successfully: {output_file}")
|
||||
logger.info(f"SQLite backup completed successfully: {output_file}")
|
||||
|
||||
|
||||
def _restore_sqlite(dump_file: Path) -> None:
|
||||
|
|
@ -216,23 +210,20 @@ def _restore_sqlite(dump_file: Path) -> None:
|
|||
# We can simply copy it over the existing database
|
||||
shutil.copy2(dump_file, db_path)
|
||||
|
||||
# Verify the restore worked by checking if sqlite3 can read it
|
||||
result = subprocess.run(
|
||||
["sqlite3", str(db_path)],
|
||||
input=".tables\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"sqlite3 verification failed: {result.stderr}")
|
||||
# Try to restore from backup
|
||||
# Verify the restore worked by checking if the file is a readable SQLite database
|
||||
import sqlite3 as _sqlite3
|
||||
try:
|
||||
conn = _sqlite3.connect(str(db_path))
|
||||
conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
conn.close()
|
||||
except _sqlite3.DatabaseError as exc:
|
||||
logger.error(f"SQLite verification failed: {exc}")
|
||||
if backup_current and backup_current.exists():
|
||||
shutil.copy2(backup_current, db_path)
|
||||
logger.info("Restored original database from backup")
|
||||
raise RuntimeError(f"sqlite3 restore verification failed: {result.stderr}")
|
||||
raise RuntimeError(f"SQLite restore verification failed: {exc}") from exc
|
||||
|
||||
logger.info("sqlite3 restore completed successfully")
|
||||
logger.info("SQLite restore completed successfully")
|
||||
|
||||
|
||||
def create_backup() -> Path:
|
||||
|
|
|
|||
|
|
@ -19,10 +19,9 @@ from apps.epg.serializers import EPGDataSerializer
|
|||
from core.models import StreamProfile
|
||||
from apps.epg.models import EPGData
|
||||
from django.db import connection, transaction
|
||||
from django.urls import reverse
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
from core.utils import validate_flexible_url
|
||||
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
||||
|
||||
|
||||
class LogoSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -57,13 +56,12 @@ class LogoSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
def get_cache_url(self, obj):
|
||||
# return f"/api/channels/logos/{obj.id}/cache/"
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[obj.id])
|
||||
)
|
||||
return reverse("api:channels:logo-cache", args=[obj.id])
|
||||
if not request:
|
||||
return f"/api/channels/logos/{obj.id}/cache/"
|
||||
if not hasattr(self, "_cache_url_prefix"):
|
||||
self._cache_url_prefix = build_absolute_uri_with_port(request, "")
|
||||
return f"{self._cache_url_prefix}/api/channels/logos/{obj.id}/cache/"
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
"""Get the number of channels using this logo"""
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import os
|
|||
from apps.m3u.utils import calculate_tuner_count
|
||||
from apps.proxy.utils import get_user_active_connections
|
||||
import regex
|
||||
from core.utils import log_system_event
|
||||
from core.utils import log_system_event, build_absolute_uri_with_port
|
||||
import hashlib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -126,7 +126,7 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
# Check if this is a POST request with data (which we don't want to allow)
|
||||
if request.method == "POST" and request.body:
|
||||
if request.body.decode() != '{}':
|
||||
return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode()))
|
||||
return HttpResponseForbidden("POST requests with body are not allowed.")
|
||||
|
||||
if user is not None:
|
||||
if user.user_level < 10:
|
||||
|
|
@ -3151,87 +3151,6 @@ def xc_series_stream(request, username, password, stream_id, extension):
|
|||
return HttpResponseRedirect(vod_url)
|
||||
|
||||
|
||||
def get_host_and_port(request):
|
||||
"""
|
||||
Returns (host, port) for building absolute URIs.
|
||||
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
|
||||
- Falls back to Host header.
|
||||
- Returns None for port if using standard ports (80/443) to omit from URLs.
|
||||
- In dev, uses 5656 as a guess if port cannot be determined.
|
||||
"""
|
||||
# Determine the scheme first - needed for standard port detection
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
standard_port = "443" if scheme == "https" else "80"
|
||||
|
||||
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
|
||||
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
|
||||
if xfh:
|
||||
if ":" in xfh:
|
||||
host, port = xfh.split(":", 1)
|
||||
# Omit standard ports from URLs
|
||||
if port == standard_port:
|
||||
return host, None
|
||||
# Non-standard port in X-Forwarded-Host - return it
|
||||
# This handles reverse proxies on non-standard ports (e.g., https://example.com:8443)
|
||||
return host, port
|
||||
else:
|
||||
host = xfh
|
||||
|
||||
# Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
# If X-Forwarded-Proto is set but no valid port, assume standard
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO"):
|
||||
return host, None
|
||||
|
||||
# 2. Try Host header
|
||||
raw_host = request.get_host()
|
||||
if ":" in raw_host:
|
||||
host, port = raw_host.split(":", 1)
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
else:
|
||||
host = raw_host
|
||||
|
||||
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present)
|
||||
# If so, assume standard port for the scheme (don't trust SERVER_PORT in this case)
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
|
||||
return host, None
|
||||
|
||||
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
|
||||
port = request.META.get("SERVER_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 6. Dev fallback: guess port 5656
|
||||
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
|
||||
return host, "5656"
|
||||
|
||||
# 7. Final fallback: assume standard port for scheme (omit from URL)
|
||||
return host, None
|
||||
|
||||
def build_absolute_uri_with_port(request, path):
|
||||
"""
|
||||
Build an absolute URI with optional port.
|
||||
Port is omitted from URL if None (standard port for scheme).
|
||||
"""
|
||||
host, port = get_host_and_port(request)
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
|
||||
if port:
|
||||
return f"{scheme}://{host}:{port}{path}"
|
||||
else:
|
||||
return f"{scheme}://{host}{path}"
|
||||
|
||||
def format_duration_hms(seconds):
|
||||
"""
|
||||
Format a duration in seconds as HH:MM:SS zero-padded string.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import time
|
|||
import random
|
||||
import logging
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from django.http import JsonResponse, Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
|
@ -14,10 +15,12 @@ from apps.vod.models import Movie, Series, Episode
|
|||
from apps.m3u.models import M3UAccountProfile
|
||||
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key
|
||||
from .utils import get_client_info
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.permissions import IsAdmin
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
|
||||
from apps.proxy.utils import check_user_stream_limits
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
||||
|
|
@ -293,6 +296,7 @@ def _transform_url(original_url, m3u_profile):
|
|||
return original_url
|
||||
|
||||
@api_view(["GET"])
|
||||
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
|
||||
@permission_classes([AllowAny])
|
||||
def stream_vod(request, content_type, content_id, session_id=None, profile_id=None, user=None):
|
||||
"""
|
||||
|
|
@ -306,7 +310,8 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
"""
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
|
||||
if user is None and hasattr(request, "user") and request.user.is_authenticated:
|
||||
user = request.user
|
||||
logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}")
|
||||
logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}")
|
||||
logger.info(f"[VOD-REQUEST] Request method: {request.method}")
|
||||
|
|
@ -384,39 +389,66 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||||
logger.info(f"[VOD-SESSION] Creating new session: {new_session_id}")
|
||||
|
||||
# Preserve any query parameters (except session_id)
|
||||
# Preserve any query parameters (except session_id and token)
|
||||
query_params = dict(request.GET)
|
||||
query_params.pop('session_id', None) # Remove if present
|
||||
query_params.pop('session_id', None)
|
||||
query_params.pop('token', None) # Token not needed after session is established
|
||||
|
||||
if user:
|
||||
redirect_url = f"{request.path}?session_id={new_session_id}"
|
||||
if query_params:
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{redirect_url}&{query_string}"
|
||||
else:
|
||||
# Build redirect URL with session ID in path, preserve query parameters
|
||||
# The VOD proxy URL patterns accept session_id in the path, so we redirect
|
||||
# to a path-based URL. XC endpoints (/movie/<user>/<pass>/<id>.<ext>) have
|
||||
# a fixed shape and instead read session_id from a query parameter.
|
||||
is_vod_proxy_path = request.path.startswith('/proxy/vod/')
|
||||
|
||||
if is_vod_proxy_path:
|
||||
path_parts = request.path.rstrip('/').split('/')
|
||||
|
||||
# Construct new path: /vod/movie/UUID/SESSION_ID or /vod/movie/UUID/SESSION_ID/PROFILE_ID/
|
||||
if profile_id:
|
||||
new_path = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/"
|
||||
else:
|
||||
new_path = f"{'/'.join(path_parts)}/{new_session_id}"
|
||||
|
||||
if query_params:
|
||||
from urllib.parse import urlencode
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{new_path}?{query_string}"
|
||||
else:
|
||||
redirect_url = new_path
|
||||
else:
|
||||
# XC path: keep the original path, put session_id in the query string
|
||||
query_params['session_id'] = new_session_id
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{request.path}?{query_string}"
|
||||
|
||||
logger.info(f"[VOD-SESSION] Redirecting to path-based URL: {redirect_url}")
|
||||
|
||||
# Persist the authenticated user to Redis so the streaming request
|
||||
# (which arrives without the token after the redirect) can resolve it.
|
||||
if user:
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
_r = RedisClient.get_client()
|
||||
if _r:
|
||||
_r.set(f"vod_session_user:{new_session_id}", user.id, ex=300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return HttpResponse(
|
||||
status=301,
|
||||
headers={'Location': redirect_url}
|
||||
)
|
||||
|
||||
# Resolve user from Redis session mapping when the streaming request
|
||||
# arrives without auth credentials (token was stripped from redirect URL).
|
||||
# Only needed on the first streaming request - skip if connection already exists.
|
||||
if user is None:
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
_r = RedisClient.get_client()
|
||||
if _r and not _r.exists(f"vod_persistent_connection:{session_id}"):
|
||||
stored_uid = _r.get(f"vod_session_user:{session_id}")
|
||||
if stored_uid:
|
||||
user = User.objects.filter(id=int(stored_uid)).first()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if user:
|
||||
if not check_user_stream_limits(user, session_id, media_id=content_id):
|
||||
return JsonResponse(
|
||||
|
|
@ -506,6 +538,7 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
return HttpResponse(f"Streaming error: {str(e)}", status=500)
|
||||
|
||||
@api_view(["HEAD"])
|
||||
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
|
||||
@permission_classes([AllowAny])
|
||||
def head_vod(request, content_type, content_id, session_id=None, profile_id=None):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -752,6 +752,76 @@ def send_websocket_notification(notification):
|
|||
logger.error(f"Failed to send WebSocket notification: {e}")
|
||||
|
||||
|
||||
def get_host_and_port(request):
|
||||
"""
|
||||
Returns (host, port) for building absolute URIs.
|
||||
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
|
||||
- Falls back to Host header.
|
||||
- Returns None for port if using standard ports (80/443) to omit from URLs.
|
||||
- In dev, uses 5656 as a guess if port cannot be determined.
|
||||
"""
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
standard_port = "443" if scheme == "https" else "80"
|
||||
|
||||
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
|
||||
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
|
||||
if xfh:
|
||||
if ":" in xfh:
|
||||
host, port = xfh.split(":", 1)
|
||||
if port == standard_port:
|
||||
return host, None
|
||||
return host, port
|
||||
else:
|
||||
host = xfh
|
||||
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO"):
|
||||
return host, None
|
||||
|
||||
# 2. Try Host header
|
||||
raw_host = request.get_host()
|
||||
if ":" in raw_host:
|
||||
host, port = raw_host.split(":", 1)
|
||||
return host, None if port == standard_port else port
|
||||
else:
|
||||
host = raw_host
|
||||
|
||||
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 4. Behind a reverse proxy with no port info - assume standard port
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
|
||||
return host, None
|
||||
|
||||
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
|
||||
port = request.META.get("SERVER_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 6. Dev fallback
|
||||
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
|
||||
return host, "5656"
|
||||
|
||||
# 7. Final fallback: assume standard port for scheme
|
||||
return host, None
|
||||
|
||||
|
||||
def build_absolute_uri_with_port(request, path):
|
||||
"""
|
||||
Build an absolute URI with optional port.
|
||||
Port is omitted from URL if None (standard port for scheme).
|
||||
"""
|
||||
host, port = get_host_and_port(request)
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
if port:
|
||||
return f"{scheme}://{host}:{port}{path}"
|
||||
return f"{scheme}://{host}{path}"
|
||||
|
||||
|
||||
def send_notification_dismissed(notification_key):
|
||||
"""
|
||||
Notify all connected clients that a notification was dismissed.
|
||||
|
|
|
|||
|
|
@ -64,11 +64,9 @@ configure_variables() {
|
|||
POSTGRES_PASSWORD="secret"
|
||||
NGINX_HTTP_PORT="9191"
|
||||
WEBSOCKET_PORT="8001"
|
||||
GUNICORN_RUNTIME_DIR="dispatcharr"
|
||||
GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock"
|
||||
PYTHON_BIN=$(command -v python3)
|
||||
UWSGI_RUNTIME_DIR="dispatcharr"
|
||||
UWSGI_SOCKET="/run/${UWSGI_RUNTIME_DIR}/dispatcharr.sock"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
NGINX_SITE="/etc/nginx/sites-available/dispatcharr"
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
|
|
@ -79,8 +77,8 @@ install_packages() {
|
|||
echo ">>> Installing system packages..."
|
||||
apt-get update
|
||||
declare -a packages=(
|
||||
git curl wget build-essential gcc libpq-dev
|
||||
python3-dev python3-venv python3-pip nginx redis-server
|
||||
git curl wget build-essential gcc libpq-dev libpcre3-dev
|
||||
nginx redis-server
|
||||
postgresql postgresql-contrib ffmpeg procps streamlink
|
||||
sudo
|
||||
)
|
||||
|
|
@ -113,6 +111,11 @@ create_dispatcharr_user() {
|
|||
##############################################################################
|
||||
|
||||
setup_postgresql() {
|
||||
echo ">>> Waiting for PostgreSQL to accept connections..."
|
||||
until pg_isready -h /var/run/postgresql >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ">>> Checking PostgreSQL database and user..."
|
||||
|
||||
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'")
|
||||
|
|
@ -143,7 +146,7 @@ setup_postgresql() {
|
|||
|
||||
clone_dispatcharr_repo() {
|
||||
echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..."
|
||||
|
||||
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
mkdir -p "$APP_DIR"
|
||||
chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR"
|
||||
|
|
@ -171,14 +174,8 @@ EOSU
|
|||
# 6) Setup Python Environment
|
||||
##############################################################################
|
||||
|
||||
install_uv() {
|
||||
echo ">>> Installing UV package manager..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
|
||||
setup_python_env() {
|
||||
echo ">>> Setting up Python virtual environment with UV..."
|
||||
echo ">>> Setting up Python virtual environment with UV (Python 3.13)..."
|
||||
|
||||
su - "$DISPATCH_USER" <<EOSU
|
||||
set -euo pipefail
|
||||
|
|
@ -188,13 +185,12 @@ setup_python_env() {
|
|||
command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
rm -rf env
|
||||
$PYTHON_BIN -m venv env
|
||||
env/bin/python -m ensurepip --upgrade
|
||||
# uv creates the venv with a managed Python 3.13 (auto-downloads if missing),
|
||||
# avoiding system Python version mismatches on Debian 12 / Ubuntu 24.04.
|
||||
uv venv --python 3.13 env
|
||||
|
||||
export UV_PROJECT_ENVIRONMENT="$APP_DIR/env"
|
||||
uv sync --no-dev
|
||||
|
||||
env/bin/python -m pip install -q gunicorn
|
||||
EOSU
|
||||
|
||||
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
|
||||
|
|
@ -291,17 +287,40 @@ EOSU
|
|||
configure_services() {
|
||||
echo ">>> Creating systemd service files..."
|
||||
|
||||
# Gunicorn
|
||||
# uWSGI config
|
||||
cat <<EOF >${APP_DIR}/uwsgi-debian.ini
|
||||
[uwsgi]
|
||||
chdir = ${APP_DIR}
|
||||
module = dispatcharr.wsgi:application
|
||||
virtualenv = ${APP_DIR}/env
|
||||
master = true
|
||||
workers = 4
|
||||
socket = ${UWSGI_SOCKET}
|
||||
chmod-socket = 666
|
||||
vacuum = true
|
||||
die-on-term = true
|
||||
gevent = 100
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
lazy-apps = true
|
||||
buffer-size = 65536
|
||||
socket-timeout = 600
|
||||
thunder-lock = true
|
||||
EOF
|
||||
|
||||
chown ${DISPATCH_USER}:${DISPATCH_GROUP} ${APP_DIR}/uwsgi-debian.ini
|
||||
|
||||
# uWSGI
|
||||
cat <<EOF >${SYSTEMD_DIR}/dispatcharr.service
|
||||
[Unit]
|
||||
Description=Gunicorn for Dispatcharr
|
||||
Description=uWSGI for Dispatcharr
|
||||
After=network.target postgresql.service redis-server.service
|
||||
|
||||
[Service]
|
||||
User=${DISPATCH_USER}
|
||||
Group=${DISPATCH_GROUP}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
RuntimeDirectory=${GUNICORN_RUNTIME_DIR}
|
||||
RuntimeDirectory=${UWSGI_RUNTIME_DIR}
|
||||
RuntimeDirectoryMode=0775
|
||||
EnvironmentFile=/opt/dispatcharr/.env
|
||||
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
|
||||
|
|
@ -310,12 +329,7 @@ Environment="POSTGRES_USER=${POSTGRES_USER}"
|
|||
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
Environment="POSTGRES_HOST=localhost"
|
||||
ExecStartPre=/usr/bin/bash -c 'until pg_isready -h localhost -U ${POSTGRES_USER}; do sleep 1; done'
|
||||
ExecStart=${APP_DIR}/env/bin/gunicorn \\
|
||||
--workers=4 \\
|
||||
--worker-class=gevent \\
|
||||
--timeout=300 \\
|
||||
--bind unix:${GUNICORN_SOCKET} \\
|
||||
dispatcharr.wsgi:application
|
||||
ExecStart=${APP_DIR}/env/bin/uwsgi --ini ${APP_DIR}/uwsgi-debian.ini
|
||||
Restart=always
|
||||
KillMode=mixed
|
||||
SyslogIdentifier=dispatcharr
|
||||
|
|
@ -412,9 +426,14 @@ EOF
|
|||
cat <<EOF >/etc/nginx/sites-available/dispatcharr.conf
|
||||
server {
|
||||
listen ${NGINX_HTTP_PORT};
|
||||
client_max_body_size 0;
|
||||
|
||||
location / {
|
||||
include proxy_params;
|
||||
proxy_pass http://unix:${GUNICORN_SOCKET};
|
||||
include uwsgi_params;
|
||||
uwsgi_param HTTP_X_REAL_IP \$remote_addr;
|
||||
uwsgi_read_timeout 600;
|
||||
uwsgi_send_timeout 600;
|
||||
uwsgi_pass unix:${UWSGI_SOCKET};
|
||||
}
|
||||
location /static/ {
|
||||
alias ${APP_DIR}/static/;
|
||||
|
|
@ -465,7 +484,7 @@ show_summary() {
|
|||
=================================================
|
||||
Dispatcharr installation (or update) complete!
|
||||
Nginx is listening on port ${NGINX_HTTP_PORT}.
|
||||
Gunicorn socket: ${GUNICORN_SOCKET}.
|
||||
uWSGI socket: ${UWSGI_SOCKET}.
|
||||
WebSockets on port ${WEBSOCKET_PORT} (path /ws/).
|
||||
|
||||
You can check logs via:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import useSettingsStore from '../../store/settings';
|
|||
import { copyToClipboard } from '../../utils';
|
||||
|
||||
// Mock stores
|
||||
vi.mock('../../store/auth', () => ({
|
||||
default: {
|
||||
getState: () => ({ accessToken: null }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../store/useVODStore', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -194,15 +194,25 @@ describe('usePlaylistsStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should remove playlists', () => {
|
||||
it('should remove playlists and their profiles', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.playlists = [
|
||||
{ id: 'playlist1', name: 'Playlist 1' },
|
||||
{ id: 'playlist2', name: 'Playlist 2' },
|
||||
{ id: 'playlist3', name: 'Playlist 3' },
|
||||
];
|
||||
result.current.addPlaylist({
|
||||
id: 'playlist1',
|
||||
name: 'Playlist 1',
|
||||
profiles: ['profile1'],
|
||||
});
|
||||
result.current.addPlaylist({
|
||||
id: 'playlist2',
|
||||
name: 'Playlist 2',
|
||||
profiles: ['profile2'],
|
||||
});
|
||||
result.current.addPlaylist({
|
||||
id: 'playlist3',
|
||||
name: 'Playlist 3',
|
||||
profiles: ['profile3'],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -210,8 +220,11 @@ describe('usePlaylistsStore', () => {
|
|||
});
|
||||
|
||||
expect(result.current.playlists).toEqual([
|
||||
{ id: 'playlist2', name: 'Playlist 2' },
|
||||
{ id: 'playlist2', name: 'Playlist 2', profiles: ['profile2'] },
|
||||
]);
|
||||
expect(result.current.profiles).toEqual({ playlist2: ['profile2'] });
|
||||
expect(result.current.profiles.playlist1).toBeUndefined();
|
||||
expect(result.current.profiles.playlist3).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set refresh progress with two parameters', () => {
|
||||
|
|
|
|||
|
|
@ -86,12 +86,16 @@ const usePlaylistsStore = create((set) => ({
|
|||
})),
|
||||
|
||||
removePlaylists: (playlistIds) =>
|
||||
set((state) => ({
|
||||
playlists: state.playlists.filter(
|
||||
(playlist) => !playlistIds.includes(playlist.id)
|
||||
),
|
||||
// @TODO: remove playlist profiles here
|
||||
})),
|
||||
set((state) => {
|
||||
const updatedProfiles = { ...state.profiles };
|
||||
playlistIds.forEach((id) => delete updatedProfiles[id]);
|
||||
return {
|
||||
playlists: state.playlists.filter(
|
||||
(playlist) => !playlistIds.includes(playlist.id)
|
||||
),
|
||||
profiles: updatedProfiles,
|
||||
};
|
||||
}),
|
||||
|
||||
setRefreshProgress: (accountIdOrData, data) =>
|
||||
set((state) => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import useAuthStore from '../../store/auth';
|
||||
|
||||
export const imdbUrl = (imdb_id) =>
|
||||
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
|
||||
|
||||
|
|
@ -27,7 +29,9 @@ const extractQuality = (relation) => {
|
|||
|
||||
// 2. Secondary: Custom properties detailed_info
|
||||
if (relation.custom_properties?.detailed_info) {
|
||||
const fromDetailedInfo = getQualityFromDetailedInfo(relation.custom_properties.detailed_info);
|
||||
const fromDetailedInfo = getQualityFromDetailedInfo(
|
||||
relation.custom_properties.detailed_info
|
||||
);
|
||||
if (fromDetailedInfo) return fromDetailedInfo;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +59,10 @@ const getQualityFromBackend = (qualityInfo) => {
|
|||
const getQualityFromDetailedInfo = (detailedInfo) => {
|
||||
// Check video dimensions first
|
||||
if (detailedInfo.video?.width && detailedInfo.video?.height) {
|
||||
return getQualityInfoFromDimensions(detailedInfo.video.width, detailedInfo.video.height);
|
||||
return getQualityInfoFromDimensions(
|
||||
detailedInfo.video.width,
|
||||
detailedInfo.video.height
|
||||
);
|
||||
}
|
||||
|
||||
// Check name field
|
||||
|
|
@ -91,7 +98,7 @@ const getQualityInfoFromDimensions = (width, height) => {
|
|||
} else {
|
||||
return ` - ${width}x${height}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sortEpisodesList = (episodesList) => {
|
||||
return episodesList.sort((a, b) => {
|
||||
|
|
@ -123,15 +130,18 @@ export const sortBySeasonNumber = (episodesBySeason) => {
|
|||
export const getEpisodeStreamUrl = (episode, selectedProvider, env_mode) => {
|
||||
let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
|
||||
|
||||
// Add selected provider as query parameter if available
|
||||
const params = new URLSearchParams();
|
||||
if (selectedProvider) {
|
||||
// Use stream_id for most specific selection, fallback to account_id
|
||||
if (selectedProvider.stream_id) {
|
||||
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
|
||||
params.set('stream_id', selectedProvider.stream_id);
|
||||
} else {
|
||||
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
|
||||
params.set('m3u_account_id', selectedProvider.m3u_account.id);
|
||||
}
|
||||
}
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) params.set('token', token);
|
||||
if (params.toString())
|
||||
streamUrl += `?${params.toString().replace(/\+/g, '%20')}`;
|
||||
|
||||
if (env_mode === 'dev') {
|
||||
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import useAuthStore from '../../store/auth';
|
||||
|
||||
const hasValidTechnicalDetails = (obj) => {
|
||||
return obj?.bitrate || obj?.video || obj?.audio;
|
||||
};
|
||||
|
|
@ -60,15 +62,18 @@ export const getTechnicalDetails = (selectedProvider, defaultVOD) => {
|
|||
export const getMovieStreamUrl = (vod, selectedProvider, env_mode) => {
|
||||
let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
|
||||
|
||||
// Add selected provider as query parameter if available
|
||||
const params = new URLSearchParams();
|
||||
if (selectedProvider) {
|
||||
// Use stream_id for most specific selection, fallback to account_id
|
||||
if (selectedProvider.stream_id) {
|
||||
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
|
||||
params.set('stream_id', selectedProvider.stream_id);
|
||||
} else {
|
||||
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
|
||||
params.set('m3u_account_id', selectedProvider.m3u_account.id);
|
||||
}
|
||||
}
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) params.set('token', token);
|
||||
if (params.toString())
|
||||
streamUrl += `?${params.toString().replace(/\+/g, '%20')}`;
|
||||
|
||||
if (env_mode === 'dev') {
|
||||
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue