mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 10:45:27 +00:00
Fix vod streaming.
This commit is contained in:
parent
22bc573c10
commit
345247df11
4 changed files with 176 additions and 68 deletions
|
|
@ -17,6 +17,15 @@ logger = logging.getLogger("vod_proxy")
|
|||
class VODConnectionManager:
|
||||
"""Manages VOD connections using Redis for tracking"""
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get the singleton instance of VODConnectionManager"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client = RedisClient.get_client()
|
||||
self.connection_ttl = 3600 # 1 hour TTL for connections
|
||||
|
|
@ -295,6 +304,109 @@ class VODConnectionManager:
|
|||
except Exception as e:
|
||||
logger.error(f"Error during connection cleanup: {e}")
|
||||
|
||||
def stream_content(self, content_obj, stream_url, m3u_profile, client_ip, user_agent, request):
|
||||
"""
|
||||
Stream VOD content with connection tracking
|
||||
|
||||
Args:
|
||||
content_obj: Movie or Episode object
|
||||
stream_url: Final stream URL to proxy
|
||||
m3u_profile: M3UAccountProfile instance
|
||||
client_ip: Client IP address
|
||||
user_agent: Client user agent
|
||||
request: Django request object
|
||||
|
||||
Returns:
|
||||
StreamingHttpResponse or HttpResponse with error
|
||||
"""
|
||||
import time
|
||||
import random
|
||||
import requests
|
||||
from django.http import StreamingHttpResponse, HttpResponse
|
||||
|
||||
try:
|
||||
# Generate unique client ID
|
||||
client_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||||
|
||||
# Determine content type and get content info
|
||||
if hasattr(content_obj, 'episodes'): # Series
|
||||
content_type = 'series'
|
||||
elif hasattr(content_obj, 'series'): # Episode
|
||||
content_type = 'episode'
|
||||
else: # Movie
|
||||
content_type = 'movie'
|
||||
|
||||
content_uuid = str(content_obj.uuid)
|
||||
content_name = getattr(content_obj, 'name', getattr(content_obj, 'title', 'Unknown'))
|
||||
|
||||
# Create connection tracking
|
||||
connection_created = self.create_connection(
|
||||
content_type=content_type,
|
||||
content_uuid=content_uuid,
|
||||
content_name=content_name,
|
||||
client_id=client_id,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
m3u_profile=m3u_profile
|
||||
)
|
||||
|
||||
if not connection_created:
|
||||
logger.error(f"Failed to create connection tracking for {content_type} {content_uuid}")
|
||||
return HttpResponse("Connection limit exceeded", status=503)
|
||||
|
||||
# Create streaming generator
|
||||
def stream_generator():
|
||||
try:
|
||||
logger.info(f"[{client_id}] Starting VOD stream for {content_type} {content_name}")
|
||||
|
||||
# Make request to actual stream URL
|
||||
headers = {'User-Agent': user_agent} if user_agent else {}
|
||||
|
||||
with requests.get(stream_url, headers=headers, stream=True, timeout=(10, 30)) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
bytes_sent = 0
|
||||
chunk_count = 0
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
bytes_sent += len(chunk)
|
||||
chunk_count += 1
|
||||
|
||||
# Update connection activity every 100 chunks
|
||||
if chunk_count % 100 == 0:
|
||||
self.update_connection_activity(
|
||||
content_type=content_type,
|
||||
content_uuid=content_uuid,
|
||||
client_id=client_id,
|
||||
bytes_sent=len(chunk)
|
||||
)
|
||||
|
||||
logger.info(f"[{client_id}] VOD stream completed: {bytes_sent} bytes sent")
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[{client_id}] Error streaming from source: {e}")
|
||||
yield b"Error: Unable to stream content"
|
||||
except Exception as e:
|
||||
logger.error(f"[{client_id}] Error in stream generator: {e}")
|
||||
finally:
|
||||
# Clean up connection tracking
|
||||
self.remove_connection(content_type, content_uuid, client_id)
|
||||
|
||||
# Create streaming response
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content=stream_generator(),
|
||||
content_type='video/mp4'
|
||||
)
|
||||
response['Cache-Control'] = 'no-cache'
|
||||
response['Accept-Ranges'] = 'none'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in stream_content: {e}", exc_info=True)
|
||||
return HttpResponse(f"Streaming error: {str(e)}", status=500)
|
||||
|
||||
# Global instance
|
||||
_connection_manager = None
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ app_name = 'vod_proxy'
|
|||
|
||||
urlpatterns = [
|
||||
# Generic VOD streaming (supports movies, episodes, series)
|
||||
path('<str:content_type>/<uuid:content_id>/', views.VODStreamView.as_view(), name='vod_stream'),
|
||||
path('<str:content_type>/<uuid:content_id>', views.VODStreamView.as_view(), name='vod_stream'),
|
||||
path('<str:content_type>/<uuid:content_id>/<int:profile_id>/', views.VODStreamView.as_view(), name='vod_stream_with_profile'),
|
||||
|
||||
# VOD playlist generation
|
||||
|
|
|
|||
|
|
@ -41,19 +41,27 @@ class VODStreamView(View):
|
|||
client_ip, user_agent = get_client_info(request)
|
||||
logger.info(f"[VOD-CLIENT] Client info - IP: {client_ip}, User-Agent: {user_agent[:100]}...")
|
||||
|
||||
# Get the content object
|
||||
content_obj = self._get_content_object(content_type, content_id)
|
||||
if not content_obj:
|
||||
logger.error(f"[VOD-ERROR] Content not found: {content_type} {content_id}")
|
||||
# Get the content object and its relation
|
||||
content_obj, relation = self._get_content_and_relation(content_type, content_id)
|
||||
if not content_obj or not relation:
|
||||
logger.error(f"[VOD-ERROR] Content or relation not found: {content_type} {content_id}")
|
||||
raise Http404(f"Content not found: {content_type} {content_id}")
|
||||
|
||||
logger.info(f"[VOD-CONTENT] Found content: {content_obj.title if hasattr(content_obj, 'title') else getattr(content_obj, 'name', 'Unknown')}")
|
||||
logger.info(f"[VOD-CONTENT] Content URL: {getattr(content_obj, 'url', 'No URL found')}")
|
||||
logger.info(f"[VOD-CONTENT] Found content: {getattr(content_obj, 'name', 'Unknown')}")
|
||||
|
||||
# Get M3U account and profile
|
||||
m3u_account = content_obj.m3u_account
|
||||
# Get M3U account from relation
|
||||
m3u_account = relation.m3u_account
|
||||
logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}")
|
||||
|
||||
# Get stream URL from relation
|
||||
stream_url = self._get_stream_url_from_relation(relation)
|
||||
logger.info(f"[VOD-CONTENT] Content URL: {stream_url or 'No URL found'}")
|
||||
|
||||
if not stream_url:
|
||||
logger.error(f"[VOD-ERROR] No stream URL available for {content_type} {content_id}")
|
||||
return HttpResponse("No stream URL available", status=503)
|
||||
|
||||
# Get M3U profile
|
||||
m3u_profile = self._get_m3u_profile(m3u_account, profile_id, user_agent)
|
||||
|
||||
if not m3u_profile:
|
||||
|
|
@ -73,12 +81,12 @@ class VODStreamView(View):
|
|||
logger.error(f"Error tracking connection in Redis: {e}")
|
||||
|
||||
# Transform URL based on profile
|
||||
stream_url = self._transform_url(content_obj, m3u_profile)
|
||||
logger.info(f"[VOD-URL] Final stream URL: {stream_url}")
|
||||
final_stream_url = self._transform_url(stream_url, m3u_profile)
|
||||
logger.info(f"[VOD-URL] Final stream URL: {final_stream_url}")
|
||||
|
||||
# Validate stream URL
|
||||
if not stream_url or not stream_url.startswith(('http://', 'https://')):
|
||||
logger.error(f"[VOD-ERROR] Invalid stream URL: {stream_url}")
|
||||
if not final_stream_url or not final_stream_url.startswith(('http://', 'https://')):
|
||||
logger.error(f"[VOD-ERROR] Invalid stream URL: {final_stream_url}")
|
||||
return HttpResponse("Invalid stream URL", status=500)
|
||||
|
||||
# Get connection manager
|
||||
|
|
@ -88,7 +96,7 @@ class VODStreamView(View):
|
|||
logger.info("[VOD-STREAM] Calling connection manager to stream content")
|
||||
response = connection_manager.stream_content(
|
||||
content_obj=content_obj,
|
||||
stream_url=stream_url,
|
||||
stream_url=final_stream_url,
|
||||
m3u_profile=m3u_profile,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
|
|
@ -102,18 +110,27 @@ class VODStreamView(View):
|
|||
logger.error(f"[VOD-EXCEPTION] Error streaming {content_type} {content_id}: {e}", exc_info=True)
|
||||
return HttpResponse(f"Streaming error: {str(e)}", status=500)
|
||||
|
||||
def _get_content_object(self, content_type, content_id):
|
||||
"""Get the content object based on type and UUID"""
|
||||
def _get_content_and_relation(self, content_type, content_id):
|
||||
"""Get the content object and its M3U relation"""
|
||||
try:
|
||||
logger.info(f"[CONTENT-LOOKUP] Looking up {content_type} with UUID {content_id}")
|
||||
|
||||
if content_type == 'movie':
|
||||
obj = get_object_or_404(Movie, uuid=content_id)
|
||||
logger.info(f"[CONTENT-FOUND] Movie: {obj.name} (ID: {obj.id})")
|
||||
return obj
|
||||
content_obj = get_object_or_404(Movie, uuid=content_id)
|
||||
logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})")
|
||||
|
||||
# Get the first active relation
|
||||
relation = content_obj.m3u_relations.filter(m3u_account__is_active=True).first()
|
||||
return content_obj, relation
|
||||
|
||||
elif content_type == 'episode':
|
||||
obj = get_object_or_404(Episode, uuid=content_id)
|
||||
logger.info(f"[CONTENT-FOUND] Episode: {obj.name} (ID: {obj.id}, Series: {obj.series.name})")
|
||||
return obj
|
||||
content_obj = get_object_or_404(Episode, uuid=content_id)
|
||||
logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})")
|
||||
|
||||
# Get the first active relation
|
||||
relation = content_obj.m3u_relations.filter(m3u_account__is_active=True).first()
|
||||
return content_obj, relation
|
||||
|
||||
elif content_type == 'series':
|
||||
# For series, get the first episode
|
||||
series = get_object_or_404(Series, uuid=content_id)
|
||||
|
|
@ -121,37 +138,36 @@ class VODStreamView(View):
|
|||
episode = series.episodes.first()
|
||||
if not episode:
|
||||
logger.error(f"[CONTENT-ERROR] No episodes found for series {series.name}")
|
||||
raise Http404("No episodes found for series")
|
||||
return None, None
|
||||
|
||||
logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})")
|
||||
return episode
|
||||
relation = episode.m3u_relations.filter(m3u_account__is_active=True).first()
|
||||
return episode, relation
|
||||
else:
|
||||
logger.error(f"[CONTENT-ERROR] Invalid content type: {content_type}")
|
||||
raise Http404(f"Invalid content type: {content_type}")
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting content object: {e}")
|
||||
return None, None
|
||||
|
||||
def _get_stream_url_from_relation(self, relation):
|
||||
"""Get stream URL from the M3U relation"""
|
||||
try:
|
||||
if hasattr(relation, 'url') and relation.url:
|
||||
return relation.url
|
||||
elif hasattr(relation, 'get_stream_url'):
|
||||
return relation.get_stream_url()
|
||||
else:
|
||||
logger.error("Relation has no URL or get_stream_url method")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stream URL from relation: {e}")
|
||||
return None
|
||||
|
||||
def _get_m3u_profile(self, content_obj, profile_id, user_agent):
|
||||
def _get_m3u_profile(self, m3u_account, profile_id, user_agent):
|
||||
"""Get appropriate M3U profile for streaming"""
|
||||
try:
|
||||
# Get M3U account from content object's relations
|
||||
m3u_account = None
|
||||
|
||||
if hasattr(content_obj, 'm3u_relations'):
|
||||
# This is a Movie or Episode with relations
|
||||
relation = content_obj.m3u_relations.filter(m3u_account__is_active=True).first()
|
||||
if relation:
|
||||
m3u_account = relation.m3u_account
|
||||
elif hasattr(content_obj, 'series'):
|
||||
# This is an Episode, get relation through series
|
||||
relation = content_obj.series.m3u_relations.filter(m3u_account__is_active=True).first()
|
||||
if relation:
|
||||
m3u_account = relation.m3u_account
|
||||
|
||||
if not m3u_account:
|
||||
logger.error("No M3U account found for content object")
|
||||
return None
|
||||
|
||||
# If specific profile requested, try to use it
|
||||
if profile_id:
|
||||
try:
|
||||
|
|
@ -195,33 +211,12 @@ class VODStreamView(View):
|
|||
except Exception:
|
||||
return True
|
||||
|
||||
def _transform_url(self, content_obj, m3u_profile):
|
||||
def _transform_url(self, original_url, m3u_profile):
|
||||
"""Transform URL based on M3U profile settings"""
|
||||
try:
|
||||
import re
|
||||
|
||||
# Get URL from the content object's relations
|
||||
original_url = None
|
||||
|
||||
if hasattr(content_obj, 'm3u_relations'):
|
||||
# This is a Movie or Episode with relations
|
||||
relation = content_obj.m3u_relations.filter(
|
||||
m3u_account=m3u_profile.m3u_account
|
||||
).first()
|
||||
if relation:
|
||||
original_url = relation.url if hasattr(relation, 'url') else relation.get_stream_url()
|
||||
elif hasattr(content_obj, 'series'):
|
||||
# This is an Episode, get URL from episode relation
|
||||
from apps.vod.models import M3UEpisodeRelation
|
||||
relation = M3UEpisodeRelation.objects.filter(
|
||||
episode=content_obj,
|
||||
m3u_account=m3u_profile.m3u_account
|
||||
).first()
|
||||
if relation:
|
||||
original_url = relation.get_stream_url()
|
||||
|
||||
if not original_url:
|
||||
logger.error("No URL found for content object")
|
||||
return None
|
||||
|
||||
search_pattern = m3u_profile.search_pattern
|
||||
|
|
@ -237,7 +232,7 @@ class VODStreamView(View):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error transforming URL: {e}")
|
||||
return None
|
||||
return original_url
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class VODPlaylistView(View):
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
# Build response with available data
|
||||
response_data = {
|
||||
'id': movie.id,
|
||||
'uuid': movie.uuid,
|
||||
'stream_id': relation.stream_id,
|
||||
'name': info.get('name', movie.name),
|
||||
'o_name': info.get('o_name', ''),
|
||||
|
|
@ -149,7 +150,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'backdrop_path': (movie.custom_properties or {}).get('backdrop_path') or info.get('backdrop_path', []),
|
||||
'cover': info.get('cover_big', ''),
|
||||
'cover_big': info.get('cover_big', ''),
|
||||
'movie_image': movie.logo.url or info.get('movie_image', ''),
|
||||
'movie_image': movie.logo.url if movie.logo else info.get('movie_image', ''),
|
||||
'bitrate': info.get('bitrate', 0),
|
||||
'video': info.get('video', {}),
|
||||
'audio': info.get('audio', {}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue