Convert to relation tables to support multiple providers for each vod.

This commit is contained in:
SergeantPanda 2025-08-07 12:31:05 -05:00
parent 44a2cf518c
commit 0e388968c4
14 changed files with 1429 additions and 955 deletions

View file

@ -1003,7 +1003,7 @@ def xc_get_epg(request, user, short=False):
def xc_get_vod_categories(user):
"""Get VOD categories for XtreamCodes API"""
from apps.vod.models import VODCategory
from apps.vod.models import VODCategory, M3UMovieRelation
response = []
@ -1021,13 +1021,16 @@ def xc_get_vod_categories(user):
else:
m3u_accounts = []
# Get categories that have movie relations with these accounts
categories = VODCategory.objects.filter(
m3u_account__in=m3u_accounts
category_type='movie',
m3umovierelation__m3u_account__in=m3u_accounts
).distinct()
else:
# Admins can see all categories
# Admins can see all categories that have active movie relations
categories = VODCategory.objects.filter(
m3u_account__is_active=True
category_type='movie',
m3umovierelation__m3u_account__is_active=True
).distinct()
for category in categories:
@ -1042,7 +1045,7 @@ def xc_get_vod_categories(user):
def xc_get_vod_streams(request, user, category_id=None):
"""Get VOD streams (movies) for XtreamCodes API"""
from apps.vod.models import Movie
from apps.vod.models import M3UMovieRelation
streams = []
@ -1065,14 +1068,18 @@ def xc_get_vod_streams(request, user, category_id=None):
if category_id:
filters["category_id"] = category_id
movies = Movie.objects.filter(**filters).select_related('category', 'logo', 'm3u_account')
# Get movie relations instead of movies directly
movie_relations = M3UMovieRelation.objects.filter(**filters).select_related(
'movie', 'movie__logo', 'category', 'm3u_account'
)
for movie in movies:
for relation in movie_relations:
movie = relation.movie
streams.append({
"num": movie.id,
"num": relation.id, # Use relation ID as num
"name": movie.name,
"stream_type": "movie",
"stream_id": movie.id,
"stream_id": relation.id, # Use relation ID
"stream_icon": (
None if not movie.logo
else request.build_absolute_uri(
@ -1081,12 +1088,12 @@ def xc_get_vod_streams(request, user, category_id=None):
),
"rating": movie.rating or "0",
"rating_5based": float(movie.rating or 0) / 2 if movie.rating else 0,
"added": int(time.time()), # TODO: use actual created date
"added": int(relation.created_at.timestamp()),
"is_adult": 0,
"category_id": str(movie.category.id) if movie.category else "0",
"container_extension": movie.container_extension or "mp4",
"category_id": str(relation.category.id) if relation.category else "0",
"container_extension": relation.container_extension or "mp4",
"custom_sid": None,
"direct_source": movie.url,
"direct_source": relation.url,
})
return streams
@ -1094,7 +1101,7 @@ def xc_get_vod_streams(request, user, category_id=None):
def xc_get_series_categories(user):
"""Get series categories for XtreamCodes API"""
from apps.vod.models import VODCategory
from apps.vod.models import VODCategory, M3USeriesRelation
response = []
@ -1110,14 +1117,15 @@ def xc_get_series_categories(user):
else:
m3u_accounts = []
# Get categories that have series relations with these accounts
categories = VODCategory.objects.filter(
m3u_account__in=m3u_accounts,
series__isnull=False # Only categories that have series
category_type='series',
m3useriesrelation__m3u_account__in=m3u_accounts
).distinct()
else:
categories = VODCategory.objects.filter(
m3u_account__is_active=True,
series__isnull=False
category_type='series',
m3useriesrelation__m3u_account__is_active=True
).distinct()
for category in categories:
@ -1132,7 +1140,7 @@ def xc_get_series_categories(user):
def xc_get_series(request, user, category_id=None):
"""Get series list for XtreamCodes API"""
from apps.vod.models import Series
from apps.vod.models import M3USeriesRelation
series_list = []
@ -1154,31 +1162,35 @@ def xc_get_series(request, user, category_id=None):
if category_id:
filters["category_id"] = category_id
series = Series.objects.filter(**filters).select_related('category', 'logo', 'm3u_account')
# Get series relations instead of series directly
series_relations = M3USeriesRelation.objects.filter(**filters).select_related(
'series', 'series__logo', 'category', 'm3u_account'
)
for serie in series:
for relation in series_relations:
series = relation.series
series_list.append({
"num": serie.id,
"name": serie.name,
"series_id": serie.id,
"num": relation.id, # Use relation ID
"name": series.name,
"series_id": relation.id, # Use relation ID
"cover": (
None if not serie.logo
None if not series.logo
else request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[serie.logo.id])
reverse("api:channels:logo-cache", args=[series.logo.id])
)
),
"plot": serie.description or "",
"plot": series.description or "",
"cast": "",
"director": "",
"genre": serie.genre or "",
"release_date": str(serie.year) if serie.year else "",
"last_modified": int(time.time()),
"rating": serie.rating or "0",
"rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0,
"genre": series.genre or "",
"release_date": str(series.year) if series.year else "",
"last_modified": int(relation.updated_at.timestamp()),
"rating": series.rating or "0",
"rating_5based": float(series.rating or 0) / 2 if series.rating else 0,
"backdrop_path": [],
"youtube_trailer": "",
"episode_run_time": "",
"category_id": str(serie.category.id) if serie.category else "0",
"category_id": str(relation.category.id) if relation.category else "0",
})
return series_list
@ -1186,12 +1198,12 @@ def xc_get_series(request, user, category_id=None):
def xc_get_series_info(request, user, series_id):
"""Get detailed series information including episodes"""
from apps.vod.models import Series, Episode
from apps.vod.models import M3USeriesRelation, M3UEpisodeRelation
if not series_id:
raise Http404()
# Get series with user access filtering
# Get series relation with user access filtering
filters = {"id": series_id, "m3u_account__is_active": True}
if user.user_level == 0:
@ -1207,33 +1219,36 @@ def xc_get_series_info(request, user, series_id):
raise Http404()
try:
serie = Series.objects.get(**filters)
except Series.DoesNotExist:
series_relation = M3USeriesRelation.objects.select_related('series', 'series__logo').get(**filters)
series = series_relation.series
except M3USeriesRelation.DoesNotExist:
raise Http404()
# Get episodes grouped by season
episodes = Episode.objects.filter(
series=serie
).order_by('season_number', 'episode_number')
# Get episodes for this series from the same M3U account
episode_relations = M3UEpisodeRelation.objects.filter(
episode__series=series,
m3u_account=series_relation.m3u_account
).select_related('episode').order_by('episode__season_number', 'episode__episode_number')
# Group episodes by season
seasons = {}
for episode in episodes:
for relation in episode_relations:
episode = relation.episode
season_num = episode.season_number or 1
if season_num not in seasons:
seasons[season_num] = []
seasons[season_num].append({
"id": episode.stream_id,
"id": relation.stream_id,
"episode_num": episode.episode_number or 0,
"title": episode.name,
"container_extension": episode.container_extension or "mp4",
"container_extension": relation.container_extension or "mp4",
"info": {
"air_date": f"{episode.year}-01-01" if episode.year else "",
"air_date": f"{episode.release_date}" if episode.release_date else "",
"crew": "",
"directed_by": "",
"episode_num": episode.episode_number or 0,
"id": episode.stream_id,
"id": relation.stream_id,
"imdb_id": episode.imdb_id or "",
"name": episode.name,
"overview": episode.description or "",
@ -1243,7 +1258,7 @@ def xc_get_series_info(request, user, series_id):
"vote_average": float(episode.rating or 0),
"vote_count": 0,
"writer": "",
"release_date": f"{episode.year}-01-01" if episode.year else "",
"release_date": f"{episode.release_date}" if episode.release_date else "",
"duration_secs": (episode.duration or 0) * 60,
"duration": f"{episode.duration or 0} min",
"video": {},
@ -1256,25 +1271,25 @@ def xc_get_series_info(request, user, series_id):
info = {
"seasons": list(seasons.keys()),
"info": {
"name": serie.name,
"name": series.name,
"cover": (
None if not serie.logo
None if not series.logo
else request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[serie.logo.id])
reverse("api:channels:logo-cache", args=[series.logo.id])
)
),
"plot": serie.description or "",
"plot": series.description or "",
"cast": "",
"director": "",
"genre": serie.genre or "",
"release_date": str(serie.year) if serie.year else "",
"last_modified": int(time.time()),
"rating": serie.rating or "0",
"rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0,
"genre": series.genre or "",
"release_date": str(series.year) if series.year else "",
"last_modified": int(series_relation.updated_at.timestamp()),
"rating": series.rating or "0",
"rating_5based": float(series.rating or 0) / 2 if series.rating else 0,
"backdrop_path": [],
"youtube_trailer": "",
"episode_run_time": "",
"category_id": str(serie.category.id) if serie.category else "0",
"category_id": str(series_relation.category.id) if series_relation.category else "0",
},
"episodes": dict(seasons)
}
@ -1284,12 +1299,12 @@ def xc_get_series_info(request, user, series_id):
def xc_get_vod_info(request, user, vod_id):
"""Get detailed VOD (movie) information"""
from apps.vod.models import Movie
from apps.vod.models import M3UMovieRelation
if not vod_id:
raise Http404()
# Get Movie with user access filtering
# Get movie relation with user access filtering
filters = {"id": vod_id, "m3u_account__is_active": True}
if user.user_level == 0:
@ -1305,8 +1320,9 @@ def xc_get_vod_info(request, user, vod_id):
raise Http404()
try:
movie = Movie.objects.get(**filters)
except Movie.DoesNotExist:
movie_relation = M3UMovieRelation.objects.select_related('movie', 'movie__logo').get(**filters)
movie = movie_relation.movie
except M3UMovieRelation.DoesNotExist:
raise Http404()
info = {
@ -1346,15 +1362,14 @@ def xc_get_vod_info(request, user, vod_id):
"rating": float(movie.rating or 0),
},
"movie_data": {
"stream_id": movie.id,
"stream_id": movie_relation.id, # Use relation ID
"name": movie.name,
"added": int(time.time()),
"category_id": str(movie.category.id) if movie.category else "0",
"container_extension": movie.container_extension or "mp4",
"added": int(movie_relation.created_at.timestamp()),
"category_id": str(movie_relation.category.id) if movie_relation.category else "0",
"container_extension": movie_relation.container_extension or "mp4",
"custom_sid": "",
"direct_source": movie.url,
"direct_source": movie_relation.url,
}
}
return info
return info

View file

@ -4,11 +4,14 @@ from . import views
app_name = 'vod_proxy'
urlpatterns = [
# Movie streaming
path('movie/<uuid:movie_uuid>', views.stream_movie, name='stream_movie'),
path('movie/<uuid:movie_uuid>/position', views.update_movie_position, name='update_movie_position'),
# 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>/<int:profile_id>/', views.VODStreamView.as_view(), name='vod_stream_with_profile'),
# Episode streaming
path('episode/<uuid:episode_uuid>', views.stream_episode, name='stream_episode'),
path('episode/<uuid:episode_uuid>/position', views.update_episode_position, name='update_episode_position'),
# VOD playlist generation
path('playlist/', views.VODPlaylistView.as_view(), name='vod_playlist'),
path('playlist/<int:profile_id>/', views.VODPlaylistView.as_view(), name='vod_playlist_with_profile'),
# Position tracking
path('position/<uuid:content_id>/', views.VODPositionView.as_view(), name='vod_position'),
]

View file

@ -0,0 +1,58 @@
"""
Utility functions for VOD proxy operations.
"""
import logging
from django.http import HttpResponse
logger = logging.getLogger(__name__)
def get_client_info(request):
"""
Extract client IP and User-Agent from request.
Args:
request: Django HttpRequest object
Returns:
tuple: (client_ip, user_agent)
"""
# Get client IP, checking for proxy headers
client_ip = request.META.get('HTTP_X_FORWARDED_FOR')
if client_ip:
# Take the first IP if there are multiple (comma-separated)
client_ip = client_ip.split(',')[0].strip()
else:
client_ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR', 'unknown')
# Get User-Agent
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
return client_ip, user_agent
def create_vod_response(content, content_type='video/mp4', filename=None):
"""
Create a streaming HTTP response for VOD content.
Args:
content: Content to stream (file-like object or bytes)
content_type: MIME type of the content
filename: Optional filename for Content-Disposition header
Returns:
HttpResponse: Configured HTTP response for streaming
"""
response = HttpResponse(content, content_type=content_type)
if filename:
response['Content-Disposition'] = f'attachment; filename="{filename}"'
# Add headers for streaming
response['Accept-Ranges'] = 'bytes'
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
return response

View file

@ -1,235 +1,334 @@
"""
VOD (Video on Demand) proxy views for handling movie and series streaming.
Supports M3U profiles for authentication and URL transformation.
"""
import time
import random
import logging
import requests
from django.http import StreamingHttpResponse, JsonResponse
from django.http import StreamingHttpResponse, JsonResponse, Http404, HttpResponse
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
from django.utils.decorators import method_decorator
from django.views import View
from apps.vod.models import Movie, Series, Episode
from apps.m3u.models import M3UAccount, M3UAccountProfile
from apps.proxy.vod_proxy.connection_manager import VODConnectionManager
from .utils import get_client_info, create_vod_response
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")
@method_decorator(csrf_exempt, name='dispatch')
class VODStreamView(View):
"""Handle VOD streaming requests with M3U profile support"""
def get(self, request, content_type, content_id, profile_id=None):
"""
Stream VOD content (movies or series episodes)
@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")
Args:
content_type: 'movie', 'series', or 'episode'
content_id: ID of the content
profile_id: Optional M3U profile ID for authentication
"""
logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_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}")
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):
# Use standardized connection counting method
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
client_ip, user_agent = get_client_info(request)
logger.info(f"[VOD-CLIENT] Client info - IP: {client_ip}, User-Agent: {user_agent[:100]}...")
# Handle range requests for seeking
range_header = request.META.get('HTTP_RANGE')
headers = {
'User-Agent': upstream_user_agent,
'Connection': 'keep-alive'
}
# 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}")
raise Http404(f"Content not found: {content_type} {content_id}")
if range_header:
headers['Range'] = range_header
logger.debug(f"[{client_id}] Range request: {range_header}")
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')}")
# Stream the VOD content
try:
response = requests.get(
content.url,
headers=headers,
stream=True,
timeout=(10, 60)
# Get M3U account and profile
m3u_account = content_obj.m3u_account
logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}")
m3u_profile = self._get_m3u_profile(m3u_account, profile_id, user_agent)
if not m3u_profile:
logger.error(f"[VOD-ERROR] No suitable M3U profile found for {content_type} {content_id}")
return HttpResponse("No available stream", status=503)
logger.info(f"[VOD-PROFILE] Using M3U profile: {m3u_profile.id} (max_streams: {m3u_profile.max_streams}, current: {m3u_profile.current_viewers})")
# Track connection start in Redis
try:
from core.utils import RedisClient
redis_client = RedisClient.get_client()
profile_connections_key = f"profile_connections:{m3u_profile.id}"
current_count = redis_client.incr(profile_connections_key)
logger.debug(f"Incremented VOD profile {m3u_profile.id} connections to {current_count}")
except Exception as e:
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}")
# Validate stream URL
if not stream_url or not stream_url.startswith(('http://', 'https://')):
logger.error(f"[VOD-ERROR] Invalid stream URL: {stream_url}")
return HttpResponse("Invalid stream URL", status=500)
# Get connection manager
connection_manager = VODConnectionManager.get_instance()
# Stream the content
logger.info("[VOD-STREAM] Calling connection manager to stream content")
response = connection_manager.stream_content(
content_obj=content_obj,
stream_url=stream_url,
m3u_profile=m3u_profile,
client_ip=client_ip,
user_agent=user_agent,
request=request
)
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
)
logger.info(f"[VOD-SUCCESS] Stream response created successfully, type: {type(response)}")
return response
# 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')
except Exception as e:
logger.error(f"[VOD-EXCEPTION] Error streaming {content_type} {content_id}: {e}", exc_info=True)
return HttpResponse(f"Streaming error: {str(e)}", status=500)
# Create streaming response
def stream_generator():
bytes_sent = 0
def _get_content_object(self, content_type, content_id):
"""Get the content object based on type and UUID"""
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
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
elif content_type == 'series':
# For series, get the first episode
series = get_object_or_404(Series, uuid=content_id)
logger.info(f"[CONTENT-FOUND] Series: {series.name} (ID: {series.id})")
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")
logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})")
return episode
else:
logger.error(f"[CONTENT-ERROR] Invalid content type: {content_type}")
raise Http404(f"Invalid content type: {content_type}")
except Exception as e:
logger.error(f"Error getting content object: {e}")
return None
def _get_m3u_profile(self, content_obj, 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:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
bytes_sent += len(chunk)
yield chunk
profile = M3UAccountProfile.objects.get(
id=profile_id,
m3u_account=m3u_account,
is_active=True
)
if profile.current_viewers < profile.max_streams or profile.max_streams == 0:
return profile
except M3UAccountProfile.DoesNotExist:
pass
# 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)
)
# Find available profile based on user agent matching
profiles = M3UAccountProfile.objects.filter(
m3u_account=m3u_account,
is_active=True
).order_by('current_viewers')
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")
for profile in profiles:
# Check if profile matches user agent pattern
if self._matches_user_agent_pattern(profile, user_agent):
if profile.current_viewers < profile.max_streams or profile.max_streams == 0:
return profile
# Build response with appropriate headers
streaming_response = StreamingHttpResponse(
stream_generator(),
content_type=content_type_header,
status=response.status_code
)
# Fallback to default profile
return profiles.filter(is_default=True).first()
# Copy important headers
if content_length:
streaming_response['Content-Length'] = content_length
if content_range:
streaming_response['Content-Range'] = content_range
except Exception as e:
logger.error(f"Error getting M3U profile: {e}")
return None
# Add CORS and caching headers
streaming_response['Accept-Ranges'] = 'bytes'
streaming_response['Access-Control-Allow-Origin'] = '*'
streaming_response['Cache-Control'] = 'no-cache'
def _matches_user_agent_pattern(self, profile, user_agent):
"""Check if user agent matches profile pattern"""
try:
import re
pattern = profile.search_pattern
if pattern and user_agent:
return bool(re.search(pattern, user_agent, re.IGNORECASE))
return True # If no pattern, match all
except Exception:
return True
logger.info(f"[{client_id}] Started streaming {content_type_name}: {content.name}")
return streaming_response
def _transform_url(self, content_obj, m3u_profile):
"""Transform URL based on M3U profile settings"""
try:
import re
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
)
# Get URL from the content object's relations
original_url = None
except Exception as e:
logger.error(f"[{client_id}] Unexpected error: {e}")
return JsonResponse(
{"error": "Internal server error"},
status=500
)
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
elif hasattr(content_obj, 'series'):
# This is an Episode, get URL from episode relation
from .models import M3UEpisodeRelation
relation = M3UEpisodeRelation.objects.filter(
episode=content_obj,
m3u_account=m3u_profile.m3u_account
).first()
if relation:
original_url = relation.url
if not original_url:
logger.error("No URL found for content object")
return None
search_pattern = m3u_profile.search_pattern
replace_pattern = m3u_profile.replace_pattern
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern)
if search_pattern and replace_pattern:
transformed_url = re.sub(search_pattern, safe_replace_pattern, original_url)
logger.debug(f"URL transformed from {original_url} to {transformed_url}")
return transformed_url
return original_url
except Exception as e:
logger.error(f"Error transforming URL: {e}")
return None
@method_decorator(csrf_exempt, name='dispatch')
class VODPlaylistView(View):
"""Generate M3U playlists for VOD content"""
def get(self, request, profile_id=None):
"""Generate VOD playlist"""
try:
# Get profile if specified
m3u_profile = None
if profile_id:
try:
m3u_profile = M3UAccountProfile.objects.get(
id=profile_id,
is_active=True
)
except M3UAccountProfile.DoesNotExist:
return HttpResponse("Profile not found", status=404)
# Generate playlist content
playlist_content = self._generate_playlist(m3u_profile)
response = HttpResponse(playlist_content, content_type='application/vnd.apple.mpegurl')
response['Content-Disposition'] = 'attachment; filename="vod_playlist.m3u8"'
return response
except Exception as e:
logger.error(f"Error generating VOD playlist: {e}")
return HttpResponse("Playlist generation error", status=500)
def _generate_playlist(self, m3u_profile=None):
"""Generate M3U playlist content for VOD"""
lines = ["#EXTM3U"]
# Add movies
movies = Movie.objects.filter(is_active=True)
if m3u_profile:
movies = movies.filter(m3u_account=m3u_profile.m3u_account)
for movie in movies:
profile_param = f"?profile={m3u_profile.id}" if m3u_profile else ""
lines.append(f'#EXTINF:-1 tvg-id="{movie.tmdb_id}" group-title="Movies",{movie.title}')
lines.append(f'/proxy/vod/movie/{movie.uuid}/{profile_param}')
# Add series
series_list = Series.objects.filter(is_active=True)
if m3u_profile:
series_list = series_list.filter(m3u_account=m3u_profile.m3u_account)
for series in series_list:
for episode in series.episodes.all():
profile_param = f"?profile={m3u_profile.id}" if m3u_profile else ""
episode_title = f"{series.title} - S{episode.season_number:02d}E{episode.episode_number:02d}"
lines.append(f'#EXTINF:-1 tvg-id="{series.tmdb_id}" group-title="Series",{episode_title}')
lines.append(f'/proxy/vod/episode/{episode.uuid}/{profile_param}')
return '\n'.join(lines)
@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")
@method_decorator(csrf_exempt, name='dispatch')
class VODPositionView(View):
"""Handle VOD position updates"""
def post(self, request, content_id):
"""Update playback position for VOD content"""
try:
import json
data = json.loads(request.body)
client_id = data.get('client_id')
position = data.get('position', 0)
@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")
# Find the content object
content_obj = None
try:
content_obj = Movie.objects.get(uuid=content_id)
except Movie.DoesNotExist:
try:
content_obj = Episode.objects.get(uuid=content_id)
except Episode.DoesNotExist:
return JsonResponse({'error': 'Content not found'}, status=404)
# Here you could store the position in a model or cache
# For now, just return success
logger.info(f"Position update for {content_obj.__class__.__name__} {content_id}: {position}s")
def _update_position(request, model_class, content_uuid, content_type_name):
"""Generic function to update playback position"""
return JsonResponse({
'success': True,
'content_id': str(content_id),
'position': 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)
except Exception as e:
logger.error(f"Error updating VOD position: {e}")
return JsonResponse({'error': str(e)}, status=500)

View file

@ -1,58 +1,67 @@
from django.contrib import admin
from .models import Series, VODCategory, VODConnection, Movie, Episode
from .models import (
Series, VODCategory, Movie, Episode,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
)
@admin.register(VODCategory)
class VODCategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'category_type', 'm3u_account', 'created_at']
list_filter = ['category_type', 'm3u_account', 'created_at']
list_display = ['name', 'category_type', 'created_at']
list_filter = ['category_type', 'created_at']
search_fields = ['name']
@admin.register(Series)
class SeriesAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'genre', 'm3u_account', 'created_at']
list_filter = ['m3u_account', 'category', 'year', 'created_at']
search_fields = ['name', 'description', 'series_id']
list_display = ['name', 'year', 'genre', 'created_at']
list_filter = ['year', 'created_at']
search_fields = ['name', 'description', 'tmdb_id', 'imdb_id']
readonly_fields = ['uuid', 'created_at', 'updated_at']
@admin.register(Movie)
class MovieAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'genre', 'duration', 'm3u_account', 'created_at']
list_filter = ['m3u_account', 'category', 'year', 'created_at']
search_fields = ['name', 'description', 'stream_id']
list_display = ['name', 'year', 'genre', 'duration', 'created_at']
list_filter = ['year', 'created_at']
search_fields = ['name', 'description', 'tmdb_id', 'imdb_id']
readonly_fields = ['uuid', 'created_at', 'updated_at']
def get_queryset(self, request):
return super().get_queryset(request).select_related('category', 'logo', 'm3u_account')
return super().get_queryset(request).select_related('logo')
@admin.register(Episode)
class EpisodeAdmin(admin.ModelAdmin):
list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'm3u_account', 'created_at']
list_filter = ['m3u_account', 'series', 'season_number', 'created_at']
search_fields = ['name', 'description', 'stream_id', 'series__name']
list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'created_at']
list_filter = ['series', 'season_number', 'created_at']
search_fields = ['name', 'description', 'series__name']
readonly_fields = ['uuid', 'created_at', 'updated_at']
def get_queryset(self, request):
return super().get_queryset(request).select_related('series', 'm3u_account')
return super().get_queryset(request).select_related('series')
@admin.register(VODConnection)
class VODConnectionAdmin(admin.ModelAdmin):
list_display = ['get_content_name', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds']
list_filter = ['connected_at', 'last_activity']
search_fields = ['client_ip', 'client_id']
readonly_fields = ['connected_at']
@admin.register(M3UMovieRelation)
class M3UMovieRelationAdmin(admin.ModelAdmin):
list_display = ['movie', 'm3u_account', 'category', 'stream_id', 'created_at']
list_filter = ['m3u_account', 'category', 'created_at']
search_fields = ['movie__name', 'm3u_account__name', 'stream_id']
readonly_fields = ['created_at', 'updated_at']
def get_content_name(self, obj):
if obj.content_object:
return obj.content_object.name
elif obj.vod:
return obj.vod.name
return "Unknown"
get_content_name.short_description = "Content"
def get_queryset(self, request):
return super().get_queryset(request).select_related('content_object', 'm3u_profile')
@admin.register(M3USeriesRelation)
class M3USeriesRelationAdmin(admin.ModelAdmin):
list_display = ['series', 'm3u_account', 'category', 'external_series_id', 'created_at']
list_filter = ['m3u_account', 'category', 'created_at']
search_fields = ['series__name', 'm3u_account__name', 'external_series_id']
readonly_fields = ['created_at', 'updated_at']
@admin.register(M3UEpisodeRelation)
class M3UEpisodeRelationAdmin(admin.ModelAdmin):
list_display = ['episode', 'm3u_account', 'stream_id', 'created_at']
list_filter = ['m3u_account', 'created_at']
search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id']
readonly_fields = ['created_at', 'updated_at']

View file

@ -5,7 +5,6 @@ from .api_views import (
EpisodeViewSet,
SeriesViewSet,
VODCategoryViewSet,
VODConnectionViewSet,
)
app_name = 'vod'
@ -15,6 +14,5 @@ router.register(r'movies', MovieViewSet, basename='movie')
router.register(r'episodes', EpisodeViewSet, basename='episode')
router.register(r'series', SeriesViewSet, basename='series')
router.register(r'categories', VODCategoryViewSet, basename='vodcategory')
router.register(r'connections', VODConnectionViewSet, basename='vodconnection')
urlpatterns = router.urls

View file

@ -10,15 +10,19 @@ from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
)
from .models import Series, VODCategory, VODConnection, Movie, Episode
from .models import (
Series, VODCategory, Movie, Episode,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
)
from .serializers import (
MovieSerializer,
EpisodeSerializer,
SeriesSerializer,
VODCategorySerializer,
VODConnectionSerializer
M3UMovieRelationSerializer,
M3USeriesRelationSerializer,
M3UEpisodeRelationSerializer
)
from core.xtream_codes import Client as XtreamCodesClient
from .tasks import refresh_series_episodes
from django.utils import timezone
from datetime import timedelta
@ -28,15 +32,14 @@ logger = logging.getLogger(__name__)
class MovieFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr="icontains")
category = django_filters.CharFilter(field_name="category__name", lookup_expr="icontains")
m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id")
year = django_filters.NumberFilter()
year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte")
year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte")
class Meta:
model = Movie
fields = ['name', 'category', 'm3u_account', 'year']
fields = ['name', 'm3u_account', 'year']
class MovieViewSet(viewsets.ReadOnlyModelViewSet):
@ -57,84 +60,133 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
return [Authenticated()]
def get_queryset(self):
return Movie.objects.select_related(
'category', 'logo', 'm3u_account'
).filter(m3u_account__is_active=True)
# Only return movies that have active M3U relations
return Movie.objects.filter(
m3u_relations__m3u_account__is_active=True
).distinct().select_related('logo').prefetch_related('m3u_relations__m3u_account')
def _extract_year(self, date_string):
"""Extract year from date string"""
if not date_string:
return None
try:
return int(date_string.split('-')[0])
except (ValueError, IndexError):
return None
@action(detail=True, methods=['get'], url_path='providers')
def get_providers(self, request, pk=None):
"""Get all providers (M3U accounts) that have this movie"""
movie = self.get_object()
relations = M3UMovieRelation.objects.filter(
movie=movie,
m3u_account__is_active=True
).select_related('m3u_account', 'category')
def _convert_duration_to_minutes(self, duration_secs):
"""Convert duration from seconds to minutes"""
if not duration_secs:
return 0
try:
return int(duration_secs) // 60
except (ValueError, TypeError):
return 0
serializer = M3UMovieRelationSerializer(relations, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='provider-info')
def provider_info(self, request, pk=None):
"""Get detailed movie information from the original provider"""
logger.debug(f"MovieViewSet.provider_info called for movie ID: {pk}")
movie = self.get_object()
logger.debug(f"Retrieved movie: {movie.name} (ID: {movie.id})")
if not movie.m3u_account:
# Get the first active relation
relation = M3UMovieRelation.objects.filter(
movie=movie,
m3u_account__is_active=True
).select_related('m3u_account').first()
if not relation:
return Response(
{'error': 'No M3U account associated with this movie'},
{'error': 'No active M3U account associated with this movie'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if detailed data has been fetched
custom_props = relation.custom_properties or {}
detailed_fetched = custom_props.get('detailed_fetched', False)
# If detailed data hasn't been fetched, fetch it now
if not detailed_fetched:
try:
from core.xtream_codes import Client as XtreamCodesClient
with XtreamCodesClient(
server_url=relation.m3u_account.server_url,
username=relation.m3u_account.username,
password=relation.m3u_account.password,
user_agent=relation.m3u_account.get_user_agent().user_agent
) as client:
# Get detailed VOD info from provider
vod_info = client.get_vod_info(relation.stream_id)
if vod_info and 'info' in vod_info:
# Update movie with detailed info
info = vod_info.get('info', {})
movie_data = vod_info.get('movie_data', {})
movie.description = info.get('plot', movie.description)
movie.rating = info.get('rating', movie.rating)
movie.genre = info.get('genre', movie.genre)
movie.duration = self._convert_duration_to_minutes(info.get('duration_secs'))
if info.get('releasedate'):
movie.year = self._extract_year(info.get('releasedate'))
movie.save()
# Update relation with detailed data
custom_props['detailed_info'] = info
custom_props['movie_data'] = movie_data
custom_props['detailed_fetched'] = True
relation.custom_properties = custom_props
relation.save()
except Exception as e:
logger.error(f"Error fetching detailed VOD info for movie {pk}: {str(e)}")
# Continue with available data
try:
# Create XtreamCodes client
from core.xtream_codes import Client as XtreamCodesClient
# Create XtreamCodes client for final response (minimal call)
with XtreamCodesClient(
server_url=movie.m3u_account.server_url,
username=movie.m3u_account.username,
password=movie.m3u_account.password,
user_agent=movie.m3u_account.user_agent
server_url=relation.m3u_account.server_url,
username=relation.m3u_account.username,
password=relation.m3u_account.password,
user_agent=relation.m3u_account.get_user_agent().user_agent
) as client:
# Get detailed VOD info from provider
logger.debug(f"Fetching VOD info for movie {movie.id} with stream ID {movie.stream_id} from provider")
vod_info = client.get_vod_info(movie.stream_id)
if not vod_info or 'info' not in vod_info:
return Response(
{'error': 'No information available from provider'},
status=status.HTTP_404_NOT_FOUND
)
# Use cached detailed data if available
custom_props = relation.custom_properties or {}
info = custom_props.get('detailed_info', {})
movie_data = custom_props.get('movie_data', {})
# Extract and format the info
info = vod_info.get('info', {})
movie_data = vod_info.get('movie_data', {})
# If no cached data, use basic data
if not info:
basic_data = custom_props.get('basic_data', {})
info = {
'name': movie.name,
'plot': movie.description,
'rating': movie.rating,
'genre': movie.genre,
}
movie_data = {
'container_extension': basic_data.get('container_extension', 'mp4'),
'added': basic_data.get('added', ''),
}
# Build response with all available fields
# Build response with available data
response_data = {
'id': movie.id,
'stream_id': movie.stream_id,
'stream_id': relation.stream_id,
'name': info.get('name', movie.name),
'o_name': info.get('o_name', ''),
'description': info.get('description', info.get('plot', '')),
'plot': info.get('plot', info.get('description', '')),
'year': self._extract_year(info.get('releasedate', '')),
'description': info.get('description', info.get('plot', movie.description)),
'plot': info.get('plot', info.get('description', movie.description)),
'year': movie.year or self._extract_year(info.get('releasedate', '')),
'release_date': info.get('release_date', ''),
'releasedate': info.get('releasedate', ''),
'genre': info.get('genre', ''),
'genre': info.get('genre', movie.genre),
'director': info.get('director', ''),
'actors': info.get('actors', info.get('cast', '')),
'cast': info.get('cast', info.get('actors', '')),
'country': info.get('country', ''),
'rating': info.get('rating', 0),
'tmdb_id': info.get('tmdb_id', ''),
'rating': info.get('rating', movie.rating or 0),
'tmdb_id': info.get('tmdb_id', movie.tmdb_id or ''),
'youtube_trailer': info.get('youtube_trailer', ''),
'duration': self._convert_duration_to_minutes(info.get('duration_secs', 0)),
'duration_secs': info.get('duration_secs', 0),
'duration': movie.duration or self._convert_duration_to_minutes(info.get('duration_secs', 0)),
'duration_secs': info.get('duration_secs', (movie.duration or 0) * 60),
'episode_run_time': info.get('episode_run_time', 0),
'age': info.get('age', ''),
'backdrop_path': info.get('backdrop_path', []),
@ -149,12 +201,18 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
'direct_source': movie_data.get('direct_source', ''),
'category_id': movie_data.get('category_id', ''),
'added': movie_data.get('added', ''),
# Include M3U account info
'm3u_account': {
'id': relation.m3u_account.id,
'name': relation.m3u_account.name,
'account_type': relation.m3u_account.account_type
}
}
return Response(response_data)
except Exception as e:
logger.error(f"Error fetching VOD info from provider for movie {pk}: {str(e)}")
logger.error(f"Error in provider info for movie {pk}: {str(e)}")
return Response(
{'error': f'Failed to fetch information from provider: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
@ -213,9 +271,46 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
return [Authenticated()]
def get_queryset(self):
return Series.objects.select_related(
'category', 'logo', 'm3u_account'
).prefetch_related('episodes').filter(m3u_account__is_active=True)
# Only return series that have active M3U relations
return Series.objects.filter(
m3u_relations__m3u_account__is_active=True
).distinct().select_related('logo').prefetch_related('episodes', 'm3u_relations__m3u_account')
@action(detail=True, methods=['get'], url_path='providers')
def get_providers(self, request, pk=None):
"""Get all providers (M3U accounts) that have this series"""
series = self.get_object()
relations = M3USeriesRelation.objects.filter(
series=series,
m3u_account__is_active=True
).select_related('m3u_account', 'category')
serializer = M3USeriesRelationSerializer(relations, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='episodes')
def get_episodes(self, request, pk=None):
"""Get episodes for this series with provider information"""
series = self.get_object()
episodes = Episode.objects.filter(series=series).prefetch_related(
'm3u_relations__m3u_account'
).order_by('season_number', 'episode_number')
episodes_data = []
for episode in episodes:
episode_serializer = EpisodeSerializer(episode)
episode_data = episode_serializer.data
# Add provider information
relations = M3UEpisodeRelation.objects.filter(
episode=episode,
m3u_account__is_active=True
).select_related('m3u_account')
episode_data['providers'] = M3UEpisodeRelationSerializer(relations, many=True).data
episodes_data.append(episode_data)
return Response(episodes_data)
@action(detail=True, methods=['get'], url_path='provider-info')
def series_info(self, request, pk=None):
@ -224,9 +319,15 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
series = self.get_object()
logger.debug(f"Retrieved series: {series.name} (ID: {series.id})")
if not series.m3u_account:
# Get the first active relation
relation = M3USeriesRelation.objects.filter(
series=series,
m3u_account__is_active=True
).select_related('m3u_account').first()
if not relation:
return Response(
{'error': 'No M3U account associated with this series'},
{'error': 'No active M3U account associated with this series'},
status=status.HTTP_400_BAD_REQUEST
)
@ -236,28 +337,36 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
refresh_interval_hours = int(request.query_params.get("refresh_interval", 24)) # Default to 24 hours
now = timezone.now()
last_refreshed = series.last_episode_refresh
last_refreshed = relation.last_episode_refresh
# Force refresh if episodes have never been populated (last_episode_refresh is null)
if last_refreshed is None:
# Check if detailed data has been fetched
custom_props = relation.custom_properties or {}
episodes_fetched = custom_props.get('episodes_fetched', False)
detailed_fetched = custom_props.get('detailed_fetched', False)
# Force refresh if episodes have never been fetched or if forced
if not episodes_fetched or not detailed_fetched or force_refresh:
force_refresh = True
logger.debug(f"Series {series.id} has never been refreshed, forcing refresh")
else:
logger.debug(f"Series {series.id} last refreshed at {last_refreshed}, now is {now}")
logger.debug(f"Series {series.id} needs detailed/episode refresh, forcing refresh")
elif last_refreshed and (now - last_refreshed) > timedelta(hours=refresh_interval_hours):
force_refresh = True
logger.debug(f"Series {series.id} refresh interval exceeded, forcing refresh")
if force_refresh or (last_refreshed and (now - last_refreshed) > timedelta(hours=refresh_interval_hours)):
if force_refresh:
logger.debug(f"Refreshing series {series.id} data from provider")
# Use existing refresh logic
# Use existing refresh logic with external_series_id
from .tasks import refresh_series_episodes
account = series.m3u_account
account = relation.m3u_account
if account and account.is_active:
refresh_series_episodes(account, series, series.series_id)
refresh_series_episodes(account, series, relation.external_series_id)
series.refresh_from_db() # Reload from database after refresh
relation.refresh_from_db() # Reload relation too
# Return the database data (which should now be fresh)
custom_props = relation.custom_properties or {}
response_data = {
'id': series.id,
'series_id': series.series_id,
'series_id': relation.external_series_id,
'name': series.name,
'description': series.description,
'year': series.year,
@ -265,25 +374,27 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
'rating': series.rating,
'tmdb_id': series.tmdb_id,
'imdb_id': series.imdb_id,
'category_id': series.category.id if series.category else None,
'category_name': series.category.name if series.category else None,
'category_id': relation.category.id if relation.category else None,
'category_name': relation.category.name if relation.category else None,
'cover': {
'id': series.logo.id,
'url': series.logo.url,
'name': series.logo.name,
} if series.logo else None,
'last_refreshed': series.updated_at,
'custom_properties': series.custom_properties or {},
'custom_properties': custom_props,
'm3u_account': {
'id': series.m3u_account.id,
'name': series.m3u_account.name,
'account_type': series.m3u_account.account_type
} if series.m3u_account else None,
'id': relation.m3u_account.id,
'name': relation.m3u_account.name,
'account_type': relation.m3u_account.account_type
},
'episodes_fetched': custom_props.get('episodes_fetched', False),
'detailed_fetched': custom_props.get('detailed_fetched', False)
}
# Always include episodes for series info
# Always include episodes for series info if they've been fetched
include_episodes = request.query_params.get('include_episodes', 'true').lower() == 'true'
if include_episodes:
if include_episodes and custom_props.get('episodes_fetched', False):
logger.debug(f"Including episodes for series {series.id}")
episodes_by_season = {}
for episode in series.episodes.all().order_by('season_number', 'episode_number'):
@ -291,6 +402,12 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
if season_key not in episodes_by_season:
episodes_by_season[season_key] = []
# Get episode relation for additional data
episode_relation = M3UEpisodeRelation.objects.filter(
episode=episode,
m3u_account=relation.m3u_account
).first()
episode_data = {
'id': episode.id,
'uuid': episode.uuid,
@ -303,8 +420,8 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
'plot': episode.description,
'duration': episode.duration,
'rating': episode.rating,
'movie_image': episode.custom_properties.get('info', {}).get('movie_image') if episode.custom_properties else None,
'container_extension': episode.container_extension,
'movie_image': episode_relation.custom_properties.get('info', {}).get('movie_image') if episode_relation and episode_relation.custom_properties else None,
'container_extension': episode_relation.container_extension if episode_relation else 'mp4',
'type': 'episode',
'series': {
'id': series.id,
@ -315,6 +432,9 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
response_data['episodes'] = episodes_by_season
logger.debug(f"Added {len(episodes_by_season)} seasons of episodes to response")
elif include_episodes:
# Episodes not yet fetched, include empty episodes list
response_data['episodes'] = {}
logger.debug(f"Returning series info response for series {series.id}")
return Response(response_data)
@ -352,21 +472,3 @@ class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet):
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
class VODConnectionViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for monitoring VOD connections"""
queryset = VODConnection.objects.all()
serializer_class = VODConnectionSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
ordering = ['-connected_at']
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def get_queryset(self):
return VODConnection.objects.select_related('m3u_profile')

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-05 20:40
# Generated by Django 5.2.4 on 2025-08-07 17:23
import django.db.models.deletion
import uuid
@ -10,27 +10,33 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'),
('dispatcharr_channels', '0024_channelgroupm3uaccount_enable_vod_sync'),
('m3u', '0012_alter_m3uaccount_refresh_interval'),
]
operations = [
migrations.CreateModel(
name='VODCategory',
name='Movie',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=255)),
('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)),
('description', models.TextField(blank=True, null=True)),
('year', models.IntegerField(blank=True, null=True)),
('rating', models.CharField(blank=True, max_length=10, null=True)),
('genre', models.CharField(blank=True, max_length=255, null=True)),
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)),
('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vod_categories', to='m3u.m3uaccount')),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')),
],
options={
'verbose_name': 'VOD Category',
'verbose_name_plural': 'VOD Categories',
'verbose_name': 'Movie',
'verbose_name_plural': 'Movies',
'ordering': ['name'],
'unique_together': {('name', 'm3u_account', 'category_type')},
'unique_together': {('name', 'year', 'imdb_id'), ('name', 'year', 'tmdb_id')},
},
),
migrations.CreateModel(
@ -43,42 +49,17 @@ class Migration(migrations.Migration):
('year', models.IntegerField(blank=True, null=True)),
('rating', models.CharField(blank=True, max_length=10, null=True)),
('genre', models.CharField(blank=True, max_length=255, null=True)),
('series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)),
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)),
('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_episode_refresh', models.DateTimeField(blank=True, help_text='Last time episodes were refreshed', null=True)),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series', to='m3u.m3uaccount')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
],
options={
'verbose_name': 'Series',
'verbose_name_plural': 'Series',
'ordering': ['name'],
'unique_together': {('series_id', 'm3u_account')},
},
),
migrations.CreateModel(
name='VODConnection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('client_id', models.CharField(max_length=255)),
('client_ip', models.GenericIPAddressField()),
('user_agent', models.TextField(blank=True, null=True)),
('connected_at', models.DateTimeField(auto_now_add=True)),
('last_activity', models.DateTimeField(auto_now=True)),
('bytes_sent', models.BigIntegerField(default=0)),
('position_seconds', models.IntegerField(default=0, help_text='Current playback position')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('m3u_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vod_connections', to='m3u.m3uaccountprofile')),
],
options={
'verbose_name': 'VOD Connection',
'verbose_name_plural': 'VOD Connections',
'unique_together': {('name', 'year', 'imdb_id'), ('name', 'year', 'tmdb_id')},
},
),
migrations.CreateModel(
@ -93,52 +74,91 @@ class Migration(migrations.Migration):
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)),
('season_number', models.IntegerField(blank=True, null=True)),
('episode_number', models.IntegerField(blank=True, null=True)),
('url', models.URLField(max_length=2048)),
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)),
('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='m3u.m3uaccount')),
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')),
],
options={
'verbose_name': 'Episode',
'verbose_name_plural': 'Episodes',
'ordering': ['series__name', 'season_number', 'episode_number'],
'unique_together': {('stream_id', 'm3u_account')},
'unique_together': {('series', 'season_number', 'episode_number')},
},
),
migrations.CreateModel(
name='Movie',
name='VODCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True, null=True)),
('year', models.IntegerField(blank=True, null=True)),
('rating', models.CharField(blank=True, max_length=10, null=True)),
('genre', models.CharField(blank=True, max_length=255, null=True)),
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)),
('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'VOD Category',
'verbose_name_plural': 'VOD Categories',
'ordering': ['name'],
'unique_together': {('name', 'category_type')},
},
),
migrations.CreateModel(
name='M3UEpisodeRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(max_length=2048)),
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)),
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movies', to='m3u.m3uaccount')),
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.episode')),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='m3u.m3uaccount')),
],
options={
'verbose_name': 'M3U Episode Relation',
'verbose_name_plural': 'M3U Episode Relations',
'unique_together': {('m3u_account', 'stream_id')},
},
),
migrations.CreateModel(
name='M3USeriesRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)),
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_episode_refresh', models.DateTimeField(blank=True, help_text='Last time episodes were refreshed', null=True)),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series_relations', to='m3u.m3uaccount')),
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.series')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
],
options={
'verbose_name': 'Movie',
'verbose_name_plural': 'Movies',
'ordering': ['name'],
'unique_together': {('stream_id', 'm3u_account')},
'verbose_name': 'M3U Series Relation',
'verbose_name_plural': 'M3U Series Relations',
'unique_together': {('m3u_account', 'external_series_id')},
},
),
migrations.CreateModel(
name='M3UMovieRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(max_length=2048)),
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movie_relations', to='m3u.m3uaccount')),
('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.movie')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
],
options={
'verbose_name': 'M3U Movie Relation',
'verbose_name_plural': 'M3U Movie Relations',
'unique_together': {('m3u_account', 'stream_id')},
},
),
]

View file

@ -22,21 +22,14 @@ class VODCategory(models.Model):
default='movie',
help_text="Type of content this category contains"
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='vod_categories',
null=True,
blank=True
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "VOD Category"
verbose_name_plural = "VOD Categories"
verbose_name = 'VOD Category'
verbose_name_plural = 'VOD Categories'
ordering = ['name']
unique_together = ['name', 'm3u_account', 'category_type']
unique_together = [('name', 'category_type')]
def __str__(self):
return f"{self.name} ({self.get_category_type_display()})"
@ -51,28 +44,27 @@ class Series(models.Model):
rating = models.CharField(max_length=10, blank=True, null=True)
genre = models.CharField(max_length=255, blank=True, null=True)
logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True)
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='series'
)
series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider")
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata")
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata")
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
# Metadata IDs for deduplication
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed")
class Meta:
verbose_name = "Series"
verbose_name_plural = "Series"
verbose_name = 'Series'
verbose_name_plural = 'Series'
ordering = ['name']
unique_together = ['series_id', 'm3u_account']
# Create unique constraint for deduplication
unique_together = [
('name', 'year', 'tmdb_id'),
('name', 'year', 'imdb_id'),
]
def __str__(self):
return f"{self.name} ({self.year or 'Unknown'})"
year_str = f" ({self.year})" if self.year else ""
return f"{self.name}{year_str}"
class Movie(models.Model):
@ -84,43 +76,28 @@ class Movie(models.Model):
rating = models.CharField(max_length=10, blank=True, null=True)
genre = models.CharField(max_length=255, blank=True, null=True)
duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes")
# Streaming information
url = models.URLField(max_length=2048)
logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True)
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
# M3U relationship
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='movies'
)
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
container_extension = models.CharField(max_length=10, blank=True, null=True)
# Metadata IDs
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata")
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata")
# Additional properties
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
# Metadata IDs for deduplication
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Movie"
verbose_name_plural = "Movies"
verbose_name = 'Movie'
verbose_name_plural = 'Movies'
ordering = ['name']
unique_together = ['stream_id', 'm3u_account']
# Create unique constraint for deduplication
unique_together = [
('name', 'year', 'tmdb_id'),
('name', 'year', 'imdb_id'),
]
def __str__(self):
return f"{self.name} ({self.year or 'Unknown'})"
def get_stream_url(self):
"""Generate the proxied stream URL for this movie"""
return f"/proxy/vod/movie/{self.uuid}"
year_str = f" ({self.year})" if self.year else ""
return f"{self.name}{year_str}"
class Episode(models.Model):
@ -137,75 +114,108 @@ class Episode(models.Model):
season_number = models.IntegerField(blank=True, null=True)
episode_number = models.IntegerField(blank=True, null=True)
# Streaming information
url = models.URLField(max_length=2048)
# M3U relationship
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='episodes'
)
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
container_extension = models.CharField(max_length=10, blank=True, null=True)
# Metadata IDs
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata")
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata")
# Additional properties
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Episode"
verbose_name_plural = "Episodes"
verbose_name = 'Episode'
verbose_name_plural = 'Episodes'
ordering = ['series__name', 'season_number', 'episode_number']
unique_together = ['stream_id', 'm3u_account']
unique_together = [
('series', 'season_number', 'episode_number'),
]
def __str__(self):
season_ep = f"S{self.season_number:02d}E{self.episode_number:02d}" if self.season_number and self.episode_number else ""
return f"{self.series.name} {season_ep} - {self.name}"
season_ep = f"S{self.season_number or 0:02d}E{self.episode_number or 0:02d}"
return f"{self.series.name} - {season_ep} - {self.name}"
def get_stream_url(self):
"""Generate the proxied stream URL for this episode"""
return f"/proxy/vod/episode/{self.uuid}"
class VODConnection(models.Model):
"""Track active VOD connections for connection limit management"""
# Use generic foreign key to support both Movie and Episode
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
# New relation models to link M3U accounts with VOD content
m3u_profile = models.ForeignKey(
'm3u.M3UAccountProfile',
on_delete=models.CASCADE,
related_name='vod_connections'
)
client_id = models.CharField(max_length=255)
client_ip = models.GenericIPAddressField()
user_agent = models.TextField(blank=True, null=True)
connected_at = models.DateTimeField(auto_now_add=True)
last_activity = models.DateTimeField(auto_now=True)
bytes_sent = models.BigIntegerField(default=0)
position_seconds = models.IntegerField(default=0, help_text="Current playback position")
class M3USeriesRelation(models.Model):
"""Links M3U accounts to Series with provider-specific information"""
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='series_relations')
series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='m3u_relations')
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
# Provider-specific fields - renamed to avoid clash with series ForeignKey
external_series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider")
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data")
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed")
class Meta:
verbose_name = "VOD Connection"
verbose_name_plural = "VOD Connections"
verbose_name = 'M3U Series Relation'
verbose_name_plural = 'M3U Series Relations'
unique_together = [('m3u_account', 'external_series_id')]
def __str__(self):
content_name = getattr(self.content_object, 'name', 'Unknown') if self.content_object else 'Unknown'
return f"{content_name} - {self.client_ip} ({self.client_id})"
return f"{self.m3u_account.name} - {self.series.name}"
def update_activity(self, bytes_sent=0, position=0):
"""Update connection activity"""
self.last_activity = timezone.now()
if bytes_sent:
self.bytes_sent += bytes_sent
if position:
self.position_seconds = position
self.save(update_fields=['last_activity', 'bytes_sent', 'position_seconds'])
class M3UMovieRelation(models.Model):
"""Links M3U accounts to Movies with provider-specific information"""
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='movie_relations')
movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='m3u_relations')
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
# Streaming information (provider-specific)
url = models.URLField(max_length=2048)
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
container_extension = models.CharField(max_length=10, blank=True, null=True)
# Provider-specific data
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.")
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'M3U Movie Relation'
verbose_name_plural = 'M3U Movie Relations'
unique_together = [('m3u_account', 'stream_id')]
def __str__(self):
return f"{self.m3u_account.name} - {self.movie.name}"
def get_stream_url(self):
"""Get the full stream URL for this movie from this provider"""
return self.url
class M3UEpisodeRelation(models.Model):
"""Links M3U accounts to Episodes with provider-specific information"""
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations')
episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations')
# Streaming information (provider-specific)
url = models.URLField(max_length=2048)
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
container_extension = models.CharField(max_length=10, blank=True, null=True)
# Provider-specific data
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.")
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'M3U Episode Relation'
verbose_name_plural = 'M3U Episode Relations'
unique_together = [('m3u_account', 'stream_id')]
def __str__(self):
return f"{self.m3u_account.name} - {self.episode}"
def get_stream_url(self):
"""Get the full stream URL for this episode from this provider"""
return self.url

View file

@ -1,5 +1,8 @@
from rest_framework import serializers
from .models import Series, VODCategory, VODConnection, Movie, Episode
from .models import (
Series, VODCategory, Movie, Episode,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
)
from apps.channels.serializers import LogoSerializer
from apps.m3u.serializers import M3UAccountSerializer
@ -14,8 +17,6 @@ class VODCategorySerializer(serializers.ModelSerializer):
class SeriesSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
episode_count = serializers.SerializerMethodField()
class Meta:
@ -28,40 +29,59 @@ class SeriesSerializer(serializers.ModelSerializer):
class MovieSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
stream_url = serializers.SerializerMethodField()
class Meta:
model = Movie
fields = '__all__'
def get_stream_url(self, obj):
return obj.get_stream_url()
class EpisodeSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
series = SeriesSerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
stream_url = serializers.SerializerMethodField()
class Meta:
model = Episode
fields = '__all__'
def get_stream_url(self, obj):
return obj.get_stream_url()
class VODConnectionSerializer(serializers.ModelSerializer):
content_name = serializers.SerializerMethodField()
class M3USeriesRelationSerializer(serializers.ModelSerializer):
series = SeriesSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
class Meta:
model = VODConnection
model = M3USeriesRelation
fields = '__all__'
def get_content_name(self, obj):
if obj.content_object:
return getattr(obj.content_object, 'name', 'Unknown')
return 'Unknown'
class M3UMovieRelationSerializer(serializers.ModelSerializer):
movie = MovieSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
class Meta:
model = M3UMovieRelation
fields = '__all__'
class M3UEpisodeRelationSerializer(serializers.ModelSerializer):
episode = EpisodeSerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
class Meta:
model = M3UEpisodeRelation
fields = '__all__'
class EnhancedSeriesSerializer(serializers.ModelSerializer):
"""Enhanced serializer for series with provider information"""
logo = LogoSerializer(read_only=True)
providers = M3USeriesRelationSerializer(source='m3u_relations', many=True, read_only=True)
episode_count = serializers.SerializerMethodField()
class Meta:
model = Series
fields = '__all__'
def get_episode_count(self, obj):
return obj.episodes.count()

View file

@ -1,379 +1,508 @@
import logging
import requests
import json
import re
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
from .models import Series, VODCategory, VODConnection, Movie, Episode
from django.db import transaction
from apps.m3u.models import M3UAccount
from apps.channels.models import Logo
from core.xtream_codes import Client as XtreamCodesClient
from .models import (
VODCategory, Series, Movie, Episode,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
)
from apps.channels.models import Logo
import logging
import json
logger = logging.getLogger(__name__)
@shared_task(bind=True)
def refresh_vod_content(self, account_id):
"""Refresh VOD content from XtreamCodes API"""
@shared_task
def refresh_vod_content(account_id):
"""Refresh VOD content for an M3U account"""
try:
account = M3UAccount.objects.get(id=account_id)
account = M3UAccount.objects.get(id=account_id, is_active=True)
if account.account_type != M3UAccount.Types.XC:
logger.warning(f"Account {account_id} is not XtreamCodes type")
return
logger.warning(f"VOD refresh called for non-XC account {account_id}")
return "VOD refresh only available for XtreamCodes accounts"
# Get movies and series
refresh_movies(account)
refresh_series(account)
logger.info(f"Starting VOD refresh for account {account.name}")
logger.info(f"Successfully refreshed VOD content for account {account_id}")
with XtreamCodesClient(
account.server_url,
account.username,
account.password,
account.get_user_agent().user_agent
) as client:
# Refresh movies
refresh_movies(client, account)
# Refresh series
refresh_series(client, account)
logger.info(f"VOD refresh completed for account {account.name}")
return f"VOD refresh completed for account {account.name}"
except M3UAccount.DoesNotExist:
logger.error(f"M3U Account {account_id} not found")
except Exception as e:
logger.error(f"Error refreshing VOD content for account {account_id}: {e}")
logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}")
return f"VOD refresh failed: {str(e)}"
def extract_year_from_title(title):
"""Extract year from movie title if present"""
if not title:
def refresh_movies(client, account):
"""Refresh movie content - only basic list, no detailed calls"""
logger.info(f"Refreshing movies for account {account.name}")
# Get movie categories
categories = client.get_vod_categories()
for category_data in categories:
category_name = category_data.get('category_name', 'Unknown')
category_id = category_data.get('category_id')
# Get or create category
category, created = VODCategory.objects.get_or_create(
name=category_name,
category_type='movie',
defaults={'name': category_name}
)
# Get movies in this category - only basic list
movies = client.get_vod_streams(category_id)
for movie_data in movies:
process_movie_basic(client, account, movie_data, category)
def refresh_series(client, account):
"""Refresh series content - only basic list, no detailed calls"""
logger.info(f"Refreshing series for account {account.name}")
# Get series categories
categories = client.get_series_categories()
for category_data in categories:
category_name = category_data.get('category_name', 'Unknown')
category_id = category_data.get('category_id')
# Get or create category
category, created = VODCategory.objects.get_or_create(
name=category_name,
category_type='series',
defaults={'name': category_name}
)
# Get series in this category - only basic list
series_list = client.get_series(category_id)
for series_data in series_list:
process_series_basic(client, account, series_data, category)
def process_movie_basic(client, account, movie_data, category):
"""Process a single movie - basic info only, no detailed API call"""
try:
stream_id = movie_data.get('stream_id')
name = movie_data.get('name', 'Unknown')
# Extract all available metadata from the basic data
year = extract_year(movie_data.get('added', '')) # Use added date as fallback
if not year and movie_data.get('year'):
year = extract_year(str(movie_data.get('year')))
# Extract TMDB and IMDB IDs if available in basic data
tmdb_id = movie_data.get('tmdb_id') or movie_data.get('tmdb')
imdb_id = movie_data.get('imdb_id') or movie_data.get('imdb')
# Extract additional metadata that might be available in basic data
description = movie_data.get('description') or movie_data.get('plot') or ''
rating = movie_data.get('rating') or movie_data.get('vote_average') or ''
genre = movie_data.get('genre') or movie_data.get('category_name') or ''
duration_minutes = None
# Try to extract duration from various possible fields
if movie_data.get('duration_secs'):
duration_minutes = convert_duration_to_minutes(movie_data.get('duration_secs'))
elif movie_data.get('duration'):
# Handle duration that might be in different formats
duration_str = str(movie_data.get('duration'))
if duration_str.isdigit():
duration_minutes = int(duration_str) # Assume minutes if just a number
else:
# Try to parse time format like "01:30:00"
try:
time_parts = duration_str.split(':')
if len(time_parts) == 3:
hours, minutes, seconds = map(int, time_parts)
duration_minutes = (hours * 60) + minutes
elif len(time_parts) == 2:
minutes, seconds = map(int, time_parts)
duration_minutes = minutes
except (ValueError, AttributeError):
pass
# Build info dict with all extracted data
info = {
'plot': description,
'rating': rating,
'genre': genre,
'duration_secs': movie_data.get('duration_secs'),
}
# Use find_or_create_movie to handle duplicates properly
movie = find_or_create_movie(
name=name,
year=year,
tmdb_id=tmdb_id,
imdb_id=imdb_id,
info=info
)
# Handle logo from basic data if available
if movie_data.get('stream_icon'):
logo, _ = Logo.objects.get_or_create(
url=movie_data['stream_icon'],
defaults={'name': name}
)
if not movie.logo:
movie.logo = logo
movie.save(update_fields=['logo'])
# Create or update relation
stream_url = client.get_vod_stream_url(stream_id)
relation, created = M3UMovieRelation.objects.update_or_create(
m3u_account=account,
stream_id=str(stream_id),
defaults={
'movie': movie,
'category': category,
'url': stream_url,
'container_extension': movie_data.get('container_extension', 'mp4'),
'custom_properties': {
'basic_data': movie_data,
'detailed_fetched': False # Flag to indicate detailed data not fetched
}
}
)
if created:
logger.debug(f"Created new movie relation: {name}")
else:
logger.debug(f"Updated movie relation: {name}")
except Exception as e:
logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {str(e)}")
def process_series_basic(client, account, series_data, category):
"""Process a single series - basic info only, no detailed API call"""
try:
series_id = series_data.get('series_id')
name = series_data.get('name', 'Unknown')
# Extract all available metadata from the basic data
year = extract_year(series_data.get('releaseDate', '')) # Use releaseDate from API
if not year and series_data.get('release_date'):
year = extract_year(series_data.get('release_date'))
# Extract TMDB and IMDB IDs if available in basic data
tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id')
imdb_id = series_data.get('imdb') or series_data.get('imdb_id')
# Extract additional metadata that matches the actual API response
description = series_data.get('plot') or series_data.get('description') or series_data.get('overview') or ''
rating = series_data.get('rating') or series_data.get('vote_average') or ''
genre = series_data.get('genre') or ''
# Build info dict with all extracted data
info = {
'plot': description,
'rating': rating,
'genre': genre,
}
# Use find_or_create_series to handle duplicates properly
series = find_or_create_series(
name=name,
year=year,
tmdb_id=tmdb_id,
imdb_id=imdb_id,
info=info
)
# Handle logo from basic data if available
if series_data.get('cover'):
logo, _ = Logo.objects.get_or_create(
url=series_data['cover'],
defaults={'name': name}
)
if not series.logo:
series.logo = logo
series.save(update_fields=['logo'])
# Create or update series relation
series_relation, created = M3USeriesRelation.objects.update_or_create(
m3u_account=account,
external_series_id=str(series_id),
defaults={
'series': series,
'category': category,
'custom_properties': {
'basic_data': series_data,
'detailed_fetched': False, # Flag to indicate detailed data not fetched
'episodes_fetched': False # Flag to indicate episodes not fetched
},
'last_episode_refresh': None # Set to None since we haven't fetched episodes
}
)
if created:
logger.debug(f"Created new series relation: {name}")
else:
logger.debug(f"Updated series relation: {name}")
except Exception as e:
logger.error(f"Error processing series {series_data.get('name', 'Unknown')}: {str(e)}")
# Remove the detailed processing functions since they're no longer used during refresh
# process_movie and process_series are now only called on-demand
def refresh_series_episodes(account, series, external_series_id, episodes_data=None):
"""Refresh episodes for a series - only called on-demand"""
try:
if not episodes_data:
# Fetch detailed series info including episodes
with XtreamCodesClient(
account.server_url,
account.username,
account.password,
account.get_user_agent().user_agent
) as client:
series_info = client.get_series_info(external_series_id)
if series_info:
# Update series with detailed info
info = series_info.get('info', {})
if info:
series.description = info.get('plot', series.description)
series.rating = info.get('rating', series.rating)
series.genre = info.get('genre', series.genre)
if info.get('releasedate'):
series.year = extract_year(info.get('releasedate'))
series.save()
episodes_data = series_info.get('episodes', {})
else:
episodes_data = {}
# Clear existing episodes for this account to handle deletions
Episode.objects.filter(
series=series,
m3u_relations__m3u_account=account
).delete()
for season_num, season_episodes in episodes_data.items():
for episode_data in season_episodes:
process_episode(account, series, episode_data, int(season_num))
# Update the series relation to mark episodes as fetched
series_relation = M3USeriesRelation.objects.filter(
series=series,
m3u_account=account
).first()
if series_relation:
custom_props = series_relation.custom_properties or {}
custom_props['episodes_fetched'] = True
custom_props['detailed_fetched'] = True
series_relation.custom_properties = custom_props
series_relation.last_episode_refresh = timezone.now()
series_relation.save()
except Exception as e:
logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}")
def find_or_create_movie(name, year, tmdb_id, imdb_id, info):
"""Find existing movie or create new one based on metadata"""
# Try to find by TMDB ID first
if tmdb_id:
movie = Movie.objects.filter(tmdb_id=tmdb_id).first()
if movie:
# Update with any new info we have
updated = False
if info.get('plot') and not movie.description:
movie.description = info.get('plot')
updated = True
if info.get('rating') and not movie.rating:
movie.rating = info.get('rating')
updated = True
if info.get('genre') and not movie.genre:
movie.genre = info.get('genre')
updated = True
if not movie.year and year:
movie.year = year
updated = True
duration = convert_duration_to_minutes(info.get('duration_secs'))
if duration and not movie.duration:
movie.duration = duration
updated = True
if updated:
movie.save()
return movie
# Try to find by IMDB ID
if imdb_id:
movie = Movie.objects.filter(imdb_id=imdb_id).first()
if movie:
# Update with any new info we have
updated = False
if info.get('plot') and not movie.description:
movie.description = info.get('plot')
updated = True
if info.get('rating') and not movie.rating:
movie.rating = info.get('rating')
updated = True
if info.get('genre') and not movie.genre:
movie.genre = info.get('genre')
updated = True
if not movie.year and year:
movie.year = year
updated = True
duration = convert_duration_to_minutes(info.get('duration_secs'))
if duration and not movie.duration:
movie.duration = duration
updated = True
if updated:
movie.save()
return movie
# Try to find by name and year - use first() to handle multiple matches
if year:
movie = Movie.objects.filter(name=name, year=year).first()
if movie:
return movie
# Try to find by name only if no year provided
movie = Movie.objects.filter(name=name).first()
if movie:
return movie
# Create new movie with all available data
return Movie.objects.create(
name=name,
year=year,
tmdb_id=tmdb_id,
imdb_id=imdb_id,
description=info.get('plot', ''),
rating=info.get('rating', ''),
genre=info.get('genre', ''),
duration=convert_duration_to_minutes(info.get('duration_secs'))
)
def find_or_create_series(name, year, tmdb_id, imdb_id, info):
"""Find existing series or create new one based on metadata"""
# Try to find by TMDB ID first
if tmdb_id:
series = Series.objects.filter(tmdb_id=tmdb_id).first()
if series:
# Update with any new info we have
updated = False
if info.get('plot') and not series.description:
series.description = info.get('plot')
updated = True
if info.get('rating') and not series.rating:
series.rating = info.get('rating')
updated = True
if info.get('genre') and not series.genre:
series.genre = info.get('genre')
updated = True
if not series.year and year:
series.year = year
updated = True
if updated:
series.save()
return series
# Try to find by IMDB ID
if imdb_id:
series = Series.objects.filter(imdb_id=imdb_id).first()
if series:
# Update with any new info we have
updated = False
if info.get('plot') and not series.description:
series.description = info.get('plot')
updated = True
if info.get('rating') and not series.rating:
series.rating = info.get('rating')
updated = True
if info.get('genre') and not series.genre:
series.genre = info.get('genre')
updated = True
if not series.year and year:
series.year = year
updated = True
if updated:
series.save()
return series
# Try to find by name and year - use first() to handle multiple matches
if year:
series = Series.objects.filter(name=name, year=year).first()
if series:
return series
# Try to find by name only if no year provided
series = Series.objects.filter(name=name).first()
if series:
return series
# Create new series with all available data
return Series.objects.create(
name=name,
year=year,
tmdb_id=tmdb_id,
imdb_id=imdb_id,
description=info.get('plot', ''),
rating=info.get('rating', ''),
genre=info.get('genre', '')
)
def extract_year(date_string):
"""Extract year from date string"""
if not date_string:
return None
try:
return int(date_string.split('-')[0])
except (ValueError, IndexError):
return None
# Pattern for (YYYY) format
pattern1 = r'\((\d{4})\)'
# Pattern for - YYYY format
pattern2 = r'\s-\s(\d{4})'
# Pattern for YYYY at the end
pattern3 = r'\s(\d{4})$'
for pattern in [pattern1, pattern2, pattern3]:
match = re.search(pattern, title)
if match:
year = int(match.group(1))
# Validate year is reasonable (between 1900 and current year + 5)
if 1900 <= year <= 2030:
return year
return None
def extract_year_from_data(data, title_key='name'):
"""Extract year from various data sources with fallback options"""
def convert_duration_to_minutes(duration_secs):
"""Convert duration from seconds to minutes"""
if not duration_secs:
return None
try:
# First try the year field
year = data.get('year')
if year and str(year).strip() and str(year).strip() != '':
try:
year_int = int(year)
if 1900 <= year_int <= 2030:
return year_int
except (ValueError, TypeError):
pass
return int(duration_secs) // 60
except (ValueError, TypeError):
return None
# Try releaseDate or release_date fields
for date_field in ['releaseDate', 'release_date']:
date_value = data.get(date_field)
if date_value and isinstance(date_value, str) and date_value.strip():
# Extract year from date format like "2011-09-19"
try:
year_str = date_value.split('-')[0].strip()
if year_str:
year = int(year_str)
if 1900 <= year <= 2030:
return year
except (ValueError, IndexError):
continue
# Finally try extracting from title
title = data.get(title_key, '')
if title and title.strip():
return extract_year_from_title(title)
except Exception:
# Don't fail processing if year extraction fails
pass
return None
def refresh_movies(account):
"""Refresh movie content"""
try:
# Get movie categories
categories_url = f"{account.server_url}/player_api.php"
params = {
'username': account.username,
'password': account.password,
'action': 'get_vod_categories'
}
response = requests.get(categories_url, params=params, timeout=30)
response.raise_for_status()
categories_data = response.json()
# Create a mapping of category_id to category name for lookup
category_id_to_name = {}
for cat_data in categories_data:
category_id_to_name[cat_data.get('category_id')] = cat_data['category_name']
# Create/update categories
VODCategory.objects.get_or_create(
name=cat_data['category_name'],
m3u_account=account,
category_type='movie',
defaults={
'name': cat_data['category_name'],
'category_type': 'movie'
}
)
# Get movies
movies_url = f"{account.server_url}/player_api.php"
params['action'] = 'get_vod_streams'
response = requests.get(movies_url, params=params, timeout=30)
response.raise_for_status()
movies_data = response.json()
for movie_data in movies_data:
try:
# Get category
category = None
category_id = movie_data.get('category_id')
if category_id:
# First try to get category name from our mapping
category_name = category_id_to_name.get(category_id)
if not category_name:
# Fallback to category_name from movie data
category_name = movie_data.get('category_name', '')
if category_name:
try:
category = VODCategory.objects.filter(
name=category_name,
m3u_account=account,
category_type='movie'
).first()
except Exception as e:
logger.warning(f"Error finding category for movie {movie_data.get('name', 'Unknown')}: {e}")
category = None
# Create/update movie
stream_url = f"{account.server_url}/movie/{account.username}/{account.password}/{movie_data['stream_id']}.{movie_data.get('container_extension', 'mp4')}"
# Extract year from title if not provided in API
year = extract_year_from_data(movie_data, 'name')
movie_data_dict = {
'name': movie_data['name'],
'url': stream_url,
'category': category,
'year': year,
'rating': movie_data.get('rating'),
'genre': movie_data.get('genre'),
'duration': movie_data.get('duration_secs', 0) // 60 if movie_data.get('duration_secs') else None,
'container_extension': movie_data.get('container_extension'),
'tmdb_id': movie_data.get('tmdb_id'),
'imdb_id': movie_data.get('imdb_id'),
'custom_properties': movie_data if movie_data else None
}
# Use new Movie model
movie, created = Movie.objects.update_or_create(
stream_id=movie_data['stream_id'],
m3u_account=account,
defaults=movie_data_dict
)
# Handle logo
if movie_data.get('stream_icon'):
logo, _ = Logo.objects.get_or_create(
url=movie_data['stream_icon'],
defaults={'name': movie_data['name']}
)
movie.logo = logo
movie.save()
except Exception as e:
logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {e}")
continue
except Exception as e:
logger.error(f"Error refreshing movies for account {account.id}: {e}")
def refresh_series(account):
"""Refresh series and episodes content"""
try:
# Get series categories
categories_url = f"{account.server_url}/player_api.php"
params = {
'username': account.username,
'password': account.password,
'action': 'get_series_categories'
}
response = requests.get(categories_url, params=params, timeout=30)
response.raise_for_status()
categories_data = response.json()
# Create a mapping of category_id to category name for lookup
category_id_to_name = {}
for cat_data in categories_data:
category_id_to_name[cat_data.get('category_id')] = cat_data['category_name']
# Create/update series categories
VODCategory.objects.get_or_create(
name=cat_data['category_name'],
m3u_account=account,
category_type='series',
defaults={
'name': cat_data['category_name'],
'category_type': 'series'
}
)
# Get series list
series_url = f"{account.server_url}/player_api.php"
params['action'] = 'get_series'
response = requests.get(series_url, params=params, timeout=30)
response.raise_for_status()
series_data = response.json()
for series_item in series_data:
try:
# Get category
category = None
category_id = series_item.get('category_id')
if category_id:
# First try to get category name from our mapping
category_name = category_id_to_name.get(category_id)
if not category_name:
# Fallback to category_name from series data
category_name = series_item.get('category_name', '')
if category_name:
try:
category = VODCategory.objects.filter(
name=category_name,
m3u_account=account,
category_type='series'
).first()
except Exception as e:
logger.warning(f"Error finding category for series {series_item.get('name', 'Unknown')}: {e}")
category = None
# Create/update series
# Extract year from series data
year = extract_year_from_data(series_item, 'name')
series_data_dict = {
'name': series_item['name'],
'description': series_item.get('plot'),
'year': year,
'rating': series_item.get('rating'),
'genre': series_item.get('genre'),
'category': category,
'tmdb_id': series_item.get('tmdb_id'),
'imdb_id': series_item.get('imdb_id'),
'custom_properties': series_item if series_item else None
}
series, created = Series.objects.update_or_create(
series_id=series_item['series_id'],
m3u_account=account,
defaults=series_data_dict
)
# Handle series logo
if series_item.get('cover'):
logo, _ = Logo.objects.get_or_create(
url=series_item['cover'],
defaults={'name': series_item['name']}
)
series.logo = logo
series.save()
except Exception as e:
logger.error(f"Error processing series {series_item.get('name', 'Unknown')}: {e}")
continue
except Exception as e:
logger.error(f"Error refreshing series for account {account.id}: {e}")
def refresh_series_episodes(account, series, series_id):
"""Refresh episodes for a specific series"""
try:
episodes_url = f"{account.server_url}/player_api.php"
params = {
'username': account.username,
'password': account.password,
'action': 'get_series_info',
'series_id': series_id
}
response = requests.get(episodes_url, params=params, timeout=30)
response.raise_for_status()
series_info = response.json()
# Process episodes by season
if 'episodes' in series_info:
for season_num, episodes in series_info['episodes'].items():
for episode_data in episodes:
try:
# Build episode stream URL
stream_url = f"{account.server_url}/series/{account.username}/{account.password}/{episode_data['id']}.{episode_data.get('container_extension', 'mp4')}"
# Get episode info (metadata is nested in 'info' object)
episode_info = episode_data.get('info', {})
episode_dict = {
'name': episode_data.get('title', f"Episode {episode_data.get('episode_num', '')}"),
'series': series,
'season_number': int(season_num) if season_num.isdigit() else None,
'episode_number': episode_data.get('episode_num'),
'url': stream_url,
'description': episode_info.get('plot') or episode_info.get('overview'),
'release_date': episode_info.get('release_date') or episode_info.get('releasedate'),
'rating': episode_info.get('rating'),
'duration': episode_info.get('duration_secs'),
'container_extension': episode_data.get('container_extension'),
'tmdb_id': episode_info.get('tmdb_id'),
'imdb_id': episode_info.get('imdb_id'),
'custom_properties': episode_data if episode_data else None
}
# Use new Episode model
episode, created = Episode.objects.update_or_create(
stream_id=episode_data['id'],
m3u_account=account,
defaults=episode_dict
)
except Exception as e:
logger.error(f"Error processing episode {episode_data.get('title', 'Unknown')}: {e}")
continue
# Update last_episode_refresh timestamp
series.last_episode_refresh = timezone.now()
series.save(update_fields=['last_episode_refresh'])
except Exception as e:
logger.error(f"Error refreshing episodes for series {series_id}: {e}")
@shared_task
def cleanup_inactive_vod_connections():
"""Clean up inactive VOD connections"""
cutoff_time = timezone.now() - timedelta(minutes=30)
inactive_connections = VODConnection.objects.filter(last_activity__lt=cutoff_time)
def cleanup_orphaned_vod_content():
"""Clean up VOD content that has no M3U relations"""
# Clean up movies with no relations
orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True)
movie_count = orphaned_movies.count()
orphaned_movies.delete()
count = inactive_connections.count()
if count > 0:
inactive_connections.delete()
logger.info(f"Cleaned up {count} inactive VOD connections")
# Clean up series with no relations
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
series_count = orphaned_series.count()
orphaned_series.delete()
return count
# Episodes will be cleaned up via CASCADE when series are deleted
logger.info(f"Cleaned up {movie_count} orphaned movies and {series_count} orphaned series")
return f"Cleaned up {movie_count} movies and {series_count} series"

View file

@ -73,7 +73,6 @@ def stream_view(request, channel_uuid):
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
profiles = [obj for obj in m3u_profiles if not obj.is_default]
# -- Loop through profiles and pick the first active one --
for profile in [default_profile] + profiles:
logger.debug(f'Checking profile {profile.name}...')
@ -174,7 +173,7 @@ def stream_view(request, channel_uuid):
persistent_lock.release()
logger.debug("Persistent lock released for channel ID=%s", channel.id)
return StreamingHttpResponse(
stream_generator(process, stream, persistent_lock),
content_type="video/MP2T"
)
return StreamingHttpResponse(
stream_generator(process, stream, persistent_lock),
content_type="video/MP2T"
)

View file

@ -370,6 +370,14 @@ class Client:
"""Get the playback URL for a VOD"""
return f"{self.server_url}/movie/{self.username}/{self.password}/{vod_id}.{container_extension}"
def get_movie_stream_url(self, vod_id, container_extension="mp4"):
"""Get the playback URL for a movie (alias for get_vod_stream_url)"""
return self.get_vod_stream_url(vod_id, container_extension)
def get_episode_stream_url(self, episode_id, container_extension="mp4"):
"""Get the playback URL for an episode"""
return f"{self.server_url}/series/{self.username}/{self.password}/{episode_id}.{container_extension}"
def close(self):
"""Close the session and cleanup resources"""
if hasattr(self, 'session') and self.session:

View file

@ -238,6 +238,11 @@ const SeriesModal = ({ series, opened, onClose }) => {
fetchSeriesInfo(series.id)
.then((details) => {
setDetailedSeries(details);
// Check if episodes were fetched
if (!details.episodes_fetched) {
// Episodes not yet fetched, may need to wait for background fetch
console.log('Episodes not yet fetched for series, may load incrementally');
}
})
.catch((error) => {
console.warn('Failed to fetch series details, using basic info:', error);
@ -541,10 +546,10 @@ const SeriesModal = ({ series, opened, onClose }) => {
{/* Provider Information */}
{displaySeries.m3u_account && (
<Box mt="md">
<Text size="sm" weight={500} mb={8}>IPTV Provider</Text>
<Text size="sm" weight={500} mb={4}>Provider Information</Text>
<Group spacing="md">
<Badge color="blue" variant="light">
{displaySeries.m3u_account.name || displaySeries.m3u_account}
{displaySeries.m3u_account.name}
</Badge>
{displaySeries.m3u_account.account_type && (
<Badge color="gray" variant="outline" size="xs">
@ -764,7 +769,6 @@ const SeriesModal = ({ series, opened, onClose }) => {
title="Trailer"
size="xl"
centered
withCloseButton
>
<Box style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}>
{trailerUrl && (