Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/sethwv/1308

This commit is contained in:
SergeantPanda 2026-05-31 16:38:41 -05:00
commit 1d07b26a01
13 changed files with 283 additions and 190 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
}));

View file

@ -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', () => {

View file

@ -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) => {

View file

@ -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}`;

View file

@ -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}`;