Dispatcharr/apps/proxy/vod_proxy/views.py
2025-08-05 21:24:41 -05:00

234 lines
8.5 KiB
Python

import time
import random
import logging
import requests
from django.http import StreamingHttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view
from apps.vod.models import Movie, Episode
from dispatcharr.utils import network_access_allowed, get_client_ip
from core.models import UserAgent, CoreSettings
from .connection_manager import get_connection_manager
logger = logging.getLogger(__name__)
@csrf_exempt
@api_view(["GET"])
def stream_movie(request, movie_uuid):
"""Stream movie content with connection tracking and range support"""
return _stream_content(request, Movie, movie_uuid, "movie")
@csrf_exempt
@api_view(["GET"])
def stream_episode(request, episode_uuid):
"""Stream episode content with connection tracking and range support"""
return _stream_content(request, Episode, episode_uuid, "episode")
def _stream_content(request, model_class, content_uuid, content_type_name):
"""Generic function to stream VOD content"""
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
# Get content object
content = get_object_or_404(model_class, uuid=content_uuid)
# Generate client ID and get client info
client_id = f"vod_client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
client_ip = get_client_ip(request)
client_user_agent = request.META.get('HTTP_USER_AGENT', '')
logger.info(f"[{client_id}] VOD stream request for: {content.name}")
try:
# Get connection manager
connection_manager = get_connection_manager()
# Get available M3U profile for connection management
m3u_account = content.m3u_account
available_profile = None
for profile in m3u_account.profiles.filter(is_active=True):
current_connections = connection_manager.get_profile_connections(profile.id)
if profile.max_streams == 0 or current_connections < profile.max_streams:
available_profile = profile
break
if not available_profile:
return JsonResponse(
{"error": "No available connections for this VOD"},
status=503
)
# Create connection tracking record in Redis
connection_created = connection_manager.create_connection(
content_type=content_type_name,
content_uuid=str(content_uuid),
content_name=content.name,
client_id=client_id,
client_ip=client_ip,
user_agent=client_user_agent,
m3u_profile=available_profile
)
if not connection_created:
return JsonResponse(
{"error": "Failed to create connection tracking"},
status=503
)
# Get user agent for upstream request
try:
user_agent_obj = m3u_account.get_user_agent()
upstream_user_agent = user_agent_obj.user_agent
except:
default_ua_id = CoreSettings.get_default_user_agent_id()
user_agent_obj = UserAgent.objects.get(id=default_ua_id)
upstream_user_agent = user_agent_obj.user_agent
# Handle range requests for seeking
range_header = request.META.get('HTTP_RANGE')
headers = {
'User-Agent': upstream_user_agent,
'Connection': 'keep-alive'
}
if range_header:
headers['Range'] = range_header
logger.debug(f"[{client_id}] Range request: {range_header}")
# Stream the VOD content
try:
response = requests.get(
content.url,
headers=headers,
stream=True,
timeout=(10, 60)
)
if response.status_code not in [200, 206]:
logger.error(f"[{client_id}] Upstream error: {response.status_code}")
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id)
return JsonResponse(
{"error": f"Upstream server error: {response.status_code}"},
status=response.status_code
)
# Determine content type
content_type_header = response.headers.get('Content-Type', 'video/mp4')
content_length = response.headers.get('Content-Length')
content_range = response.headers.get('Content-Range')
# Create streaming response
def stream_generator():
bytes_sent = 0
try:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
bytes_sent += len(chunk)
yield chunk
# Update connection activity periodically
if bytes_sent % (8192 * 10) == 0: # Every ~80KB
connection_manager.update_connection_activity(
content_type_name,
str(content_uuid),
client_id,
bytes_sent=len(chunk)
)
except Exception as e:
logger.error(f"[{client_id}] Streaming error: {e}")
finally:
# Clean up connection when streaming ends
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id)
logger.info(f"[{client_id}] Connection cleaned up")
# Build response with appropriate headers
streaming_response = StreamingHttpResponse(
stream_generator(),
content_type=content_type_header,
status=response.status_code
)
# Copy important headers
if content_length:
streaming_response['Content-Length'] = content_length
if content_range:
streaming_response['Content-Range'] = content_range
# Add CORS and caching headers
streaming_response['Accept-Ranges'] = 'bytes'
streaming_response['Access-Control-Allow-Origin'] = '*'
streaming_response['Cache-Control'] = 'no-cache'
logger.info(f"[{client_id}] Started streaming {content_type_name}: {content.name}")
return streaming_response
except requests.RequestException as e:
logger.error(f"[{client_id}] Request error: {e}")
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id)
return JsonResponse(
{"error": "Failed to connect to upstream server"},
status=502
)
except Exception as e:
logger.error(f"[{client_id}] Unexpected error: {e}")
return JsonResponse(
{"error": "Internal server error"},
status=500
)
@csrf_exempt
@api_view(["POST"])
def update_movie_position(request, movie_uuid):
"""Update playback position for a movie"""
return _update_position(request, Movie, movie_uuid, "movie")
@csrf_exempt
@api_view(["POST"])
def update_episode_position(request, episode_uuid):
"""Update playback position for an episode"""
return _update_position(request, Episode, episode_uuid, "episode")
def _update_position(request, model_class, content_uuid, content_type_name):
"""Generic function to update playback position"""
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
client_id = request.data.get('client_id')
position = request.data.get('position', 0)
if not client_id:
return JsonResponse({"error": "Client ID required"}, status=400)
try:
content = get_object_or_404(model_class, uuid=content_uuid)
connection_manager = get_connection_manager()
# Update position in Redis
success = connection_manager.update_connection_activity(
content_type_name,
str(content_uuid),
client_id,
position_seconds=position
)
if not success:
return JsonResponse({"error": "Connection not found"}, status=404)
return JsonResponse({"status": "success"})
except Exception as e:
logger.error(f"Position update error: {e}")
return JsonResponse({"error": "Internal server error"}, status=500)