feat(auth): add QueryParamJWTAuthentication for token retrieval via query parameters

feat(vod): include token in stream URL for episode and movie requests (Fixes #1224)
This commit is contained in:
SergeantPanda 2026-05-30 11:14:59 -05:00
parent effa03b2a5
commit 72d1520400
5 changed files with 88 additions and 34 deletions

View file

@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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)
- **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

@ -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,56 @@ 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}"
# Always put session_id in the URL path - URL patterns only route it from there
path_parts = request.path.rstrip('/').split('/')
if profile_id:
new_path = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/"
else:
# Build redirect URL with session ID in path, preserve query parameters
path_parts = request.path.rstrip('/').split('/')
new_path = f"{'/'.join(path_parts)}/{new_session_id}"
# 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
if query_params:
query_string = urlencode(query_params, doseq=True)
redirect_url = f"{new_path}?{query_string}"
else:
redirect_url = new_path
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 +528,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

@ -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,17 @@ 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()}`;
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,17 @@ 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()}`;
if (env_mode === 'dev') {
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;