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

View file

@ -4,11 +4,14 @@ from . import views
app_name = 'vod_proxy' app_name = 'vod_proxy'
urlpatterns = [ urlpatterns = [
# Movie streaming # Generic VOD streaming (supports movies, episodes, series)
path('movie/<uuid:movie_uuid>', views.stream_movie, name='stream_movie'), path('<str:content_type>/<uuid:content_id>/', views.VODStreamView.as_view(), name='vod_stream'),
path('movie/<uuid:movie_uuid>/position', views.update_movie_position, name='update_movie_position'), path('<str:content_type>/<uuid:content_id>/<int:profile_id>/', views.VODStreamView.as_view(), name='vod_stream_with_profile'),
# Episode streaming # VOD playlist generation
path('episode/<uuid:episode_uuid>', views.stream_episode, name='stream_episode'), path('playlist/', views.VODPlaylistView.as_view(), name='vod_playlist'),
path('episode/<uuid:episode_uuid>/position', views.update_episode_position, name='update_episode_position'), 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 time
import random import random
import logging import logging
import requests 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.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view from django.utils.decorators import method_decorator
from django.views import View
from apps.vod.models import Movie, Episode from apps.vod.models import Movie, Series, Episode
from dispatcharr.utils import network_access_allowed, get_client_ip from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings from apps.proxy.vod_proxy.connection_manager import VODConnectionManager
from .connection_manager import get_connection_manager from .utils import get_client_info, create_vod_response
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@csrf_exempt @method_decorator(csrf_exempt, name='dispatch')
@api_view(["GET"]) class VODStreamView(View):
def stream_movie(request, movie_uuid): """Handle VOD streaming requests with M3U profile support"""
"""Stream movie content with connection tracking and range support"""
return _stream_content(request, Movie, movie_uuid, "movie")
def get(self, request, content_type, content_id, profile_id=None):
"""
Stream VOD content (movies or series episodes)
@csrf_exempt Args:
@api_view(["GET"]) content_type: 'movie', 'series', or 'episode'
def stream_episode(request, episode_uuid): content_id: ID of the content
"""Stream episode content with connection tracking and range support""" profile_id: Optional M3U profile ID for authentication
return _stream_content(request, Episode, episode_uuid, "episode") """
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: try:
user_agent_obj = m3u_account.get_user_agent() client_ip, user_agent = get_client_info(request)
upstream_user_agent = user_agent_obj.user_agent logger.info(f"[VOD-CLIENT] Client info - IP: {client_ip}, User-Agent: {user_agent[:100]}...")
except:
default_ua_id = CoreSettings.get_default_user_agent_id()
user_agent_obj = UserAgent.objects.get(id=default_ua_id)
upstream_user_agent = user_agent_obj.user_agent
# Handle range requests for seeking # Get the content object
range_header = request.META.get('HTTP_RANGE') content_obj = self._get_content_object(content_type, content_id)
headers = { if not content_obj:
'User-Agent': upstream_user_agent, logger.error(f"[VOD-ERROR] Content not found: {content_type} {content_id}")
'Connection': 'keep-alive' raise Http404(f"Content not found: {content_type} {content_id}")
}
if range_header: logger.info(f"[VOD-CONTENT] Found content: {content_obj.title if hasattr(content_obj, 'title') else getattr(content_obj, 'name', 'Unknown')}")
headers['Range'] = range_header logger.info(f"[VOD-CONTENT] Content URL: {getattr(content_obj, 'url', 'No URL found')}")
logger.debug(f"[{client_id}] Range request: {range_header}")
# Stream the VOD content # Get M3U account and profile
try: m3u_account = content_obj.m3u_account
response = requests.get( logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}")
content.url,
headers=headers, m3u_profile = self._get_m3u_profile(m3u_account, profile_id, user_agent)
stream=True,
timeout=(10, 60) 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.info(f"[VOD-SUCCESS] Stream response created successfully, type: {type(response)}")
logger.error(f"[{client_id}] Upstream error: {response.status_code}") return response
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id)
return JsonResponse(
{"error": f"Upstream server error: {response.status_code}"},
status=response.status_code
)
# Determine content type except Exception as e:
content_type_header = response.headers.get('Content-Type', 'video/mp4') logger.error(f"[VOD-EXCEPTION] Error streaming {content_type} {content_id}: {e}", exc_info=True)
content_length = response.headers.get('Content-Length') return HttpResponse(f"Streaming error: {str(e)}", status=500)
content_range = response.headers.get('Content-Range')
# Create streaming response def _get_content_object(self, content_type, content_id):
def stream_generator(): """Get the content object based on type and UUID"""
bytes_sent = 0 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: try:
for chunk in response.iter_content(chunk_size=8192): profile = M3UAccountProfile.objects.get(
if chunk: id=profile_id,
bytes_sent += len(chunk) m3u_account=m3u_account,
yield chunk 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 # Find available profile based on user agent matching
if bytes_sent % (8192 * 10) == 0: # Every ~80KB profiles = M3UAccountProfile.objects.filter(
connection_manager.update_connection_activity( m3u_account=m3u_account,
content_type_name, is_active=True
str(content_uuid), ).order_by('current_viewers')
client_id,
bytes_sent=len(chunk)
)
except Exception as e: for profile in profiles:
logger.error(f"[{client_id}] Streaming error: {e}") # Check if profile matches user agent pattern
finally: if self._matches_user_agent_pattern(profile, user_agent):
# Clean up connection when streaming ends if profile.current_viewers < profile.max_streams or profile.max_streams == 0:
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id) return profile
logger.info(f"[{client_id}] Connection cleaned up")
# Build response with appropriate headers # Fallback to default profile
streaming_response = StreamingHttpResponse( return profiles.filter(is_default=True).first()
stream_generator(),
content_type=content_type_header,
status=response.status_code
)
# Copy important headers except Exception as e:
if content_length: logger.error(f"Error getting M3U profile: {e}")
streaming_response['Content-Length'] = content_length return None
if content_range:
streaming_response['Content-Range'] = content_range
# Add CORS and caching headers def _matches_user_agent_pattern(self, profile, user_agent):
streaming_response['Accept-Ranges'] = 'bytes' """Check if user agent matches profile pattern"""
streaming_response['Access-Control-Allow-Origin'] = '*' try:
streaming_response['Cache-Control'] = 'no-cache' 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}") def _transform_url(self, content_obj, m3u_profile):
return streaming_response """Transform URL based on M3U profile settings"""
try:
import re
except requests.RequestException as e: # Get URL from the content object's relations
logger.error(f"[{client_id}] Request error: {e}") original_url = None
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id)
return JsonResponse(
{"error": "Failed to connect to upstream server"},
status=502
)
except Exception as e: if hasattr(content_obj, 'm3u_relations'):
logger.error(f"[{client_id}] Unexpected error: {e}") # This is a Movie or Episode with relations
return JsonResponse( relation = content_obj.m3u_relations.filter(
{"error": "Internal server error"}, m3u_account=m3u_profile.m3u_account
status=500 ).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 @method_decorator(csrf_exempt, name='dispatch')
@api_view(["POST"]) class VODPositionView(View):
def update_movie_position(request, movie_uuid): """Handle VOD position updates"""
"""Update playback position for a movie"""
return _update_position(request, Movie, movie_uuid, "movie")
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 # Find the content object
@api_view(["POST"]) content_obj = None
def update_episode_position(request, episode_uuid): try:
"""Update playback position for an episode""" content_obj = Movie.objects.get(uuid=content_id)
return _update_position(request, Episode, episode_uuid, "episode") 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): return JsonResponse({
"""Generic function to update playback position""" 'success': True,
'content_id': str(content_id),
'position': position
})
if not network_access_allowed(request, "STREAMS"): except Exception as e:
return JsonResponse({"error": "Forbidden"}, status=403) logger.error(f"Error updating VOD position: {e}")
return JsonResponse({'error': str(e)}, status=500)
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)

View file

@ -1,58 +1,67 @@
from django.contrib import admin 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) @admin.register(VODCategory)
class VODCategoryAdmin(admin.ModelAdmin): class VODCategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'category_type', 'm3u_account', 'created_at'] list_display = ['name', 'category_type', 'created_at']
list_filter = ['category_type', 'm3u_account', 'created_at'] list_filter = ['category_type', 'created_at']
search_fields = ['name'] search_fields = ['name']
@admin.register(Series) @admin.register(Series)
class SeriesAdmin(admin.ModelAdmin): class SeriesAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'genre', 'm3u_account', 'created_at'] list_display = ['name', 'year', 'genre', 'created_at']
list_filter = ['m3u_account', 'category', 'year', 'created_at'] list_filter = ['year', 'created_at']
search_fields = ['name', 'description', 'series_id'] search_fields = ['name', 'description', 'tmdb_id', 'imdb_id']
readonly_fields = ['uuid', 'created_at', 'updated_at'] readonly_fields = ['uuid', 'created_at', 'updated_at']
@admin.register(Movie) @admin.register(Movie)
class MovieAdmin(admin.ModelAdmin): class MovieAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'genre', 'duration', 'm3u_account', 'created_at'] list_display = ['name', 'year', 'genre', 'duration', 'created_at']
list_filter = ['m3u_account', 'category', 'year', 'created_at'] list_filter = ['year', 'created_at']
search_fields = ['name', 'description', 'stream_id'] search_fields = ['name', 'description', 'tmdb_id', 'imdb_id']
readonly_fields = ['uuid', 'created_at', 'updated_at'] readonly_fields = ['uuid', 'created_at', 'updated_at']
def get_queryset(self, request): 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) @admin.register(Episode)
class EpisodeAdmin(admin.ModelAdmin): class EpisodeAdmin(admin.ModelAdmin):
list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'm3u_account', 'created_at'] list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'created_at']
list_filter = ['m3u_account', 'series', 'season_number', 'created_at'] list_filter = ['series', 'season_number', 'created_at']
search_fields = ['name', 'description', 'stream_id', 'series__name'] search_fields = ['name', 'description', 'series__name']
readonly_fields = ['uuid', 'created_at', 'updated_at'] readonly_fields = ['uuid', 'created_at', 'updated_at']
def get_queryset(self, request): 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) @admin.register(M3UMovieRelation)
class VODConnectionAdmin(admin.ModelAdmin): class M3UMovieRelationAdmin(admin.ModelAdmin):
list_display = ['get_content_name', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds'] list_display = ['movie', 'm3u_account', 'category', 'stream_id', 'created_at']
list_filter = ['connected_at', 'last_activity'] list_filter = ['m3u_account', 'category', 'created_at']
search_fields = ['client_ip', 'client_id'] search_fields = ['movie__name', 'm3u_account__name', 'stream_id']
readonly_fields = ['connected_at'] 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): @admin.register(M3USeriesRelation)
return super().get_queryset(request).select_related('content_object', 'm3u_profile') 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, EpisodeViewSet,
SeriesViewSet, SeriesViewSet,
VODCategoryViewSet, VODCategoryViewSet,
VODConnectionViewSet,
) )
app_name = 'vod' app_name = 'vod'
@ -15,6 +14,5 @@ router.register(r'movies', MovieViewSet, basename='movie')
router.register(r'episodes', EpisodeViewSet, basename='episode') router.register(r'episodes', EpisodeViewSet, basename='episode')
router.register(r'series', SeriesViewSet, basename='series') router.register(r'series', SeriesViewSet, basename='series')
router.register(r'categories', VODCategoryViewSet, basename='vodcategory') router.register(r'categories', VODCategoryViewSet, basename='vodcategory')
router.register(r'connections', VODConnectionViewSet, basename='vodconnection')
urlpatterns = router.urls urlpatterns = router.urls

View file

@ -10,15 +10,19 @@ from apps.accounts.permissions import (
Authenticated, Authenticated,
permission_classes_by_action, 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 ( from .serializers import (
MovieSerializer, MovieSerializer,
EpisodeSerializer, EpisodeSerializer,
SeriesSerializer, SeriesSerializer,
VODCategorySerializer, VODCategorySerializer,
VODConnectionSerializer M3UMovieRelationSerializer,
M3USeriesRelationSerializer,
M3UEpisodeRelationSerializer
) )
from core.xtream_codes import Client as XtreamCodesClient
from .tasks import refresh_series_episodes from .tasks import refresh_series_episodes
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
@ -28,15 +32,14 @@ logger = logging.getLogger(__name__)
class MovieFilter(django_filters.FilterSet): class MovieFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr="icontains") 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_relations__m3u_account__id")
m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
year = django_filters.NumberFilter() year = django_filters.NumberFilter()
year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte") year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte")
year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte") year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte")
class Meta: class Meta:
model = Movie model = Movie
fields = ['name', 'category', 'm3u_account', 'year'] fields = ['name', 'm3u_account', 'year']
class MovieViewSet(viewsets.ReadOnlyModelViewSet): class MovieViewSet(viewsets.ReadOnlyModelViewSet):
@ -57,84 +60,133 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
return [Authenticated()] return [Authenticated()]
def get_queryset(self): def get_queryset(self):
return Movie.objects.select_related( # Only return movies that have active M3U relations
'category', 'logo', 'm3u_account' return Movie.objects.filter(
).filter(m3u_account__is_active=True) m3u_relations__m3u_account__is_active=True
).distinct().select_related('logo').prefetch_related('m3u_relations__m3u_account')
def _extract_year(self, date_string): @action(detail=True, methods=['get'], url_path='providers')
"""Extract year from date string""" def get_providers(self, request, pk=None):
if not date_string: """Get all providers (M3U accounts) that have this movie"""
return None movie = self.get_object()
try: relations = M3UMovieRelation.objects.filter(
return int(date_string.split('-')[0]) movie=movie,
except (ValueError, IndexError): m3u_account__is_active=True
return None ).select_related('m3u_account', 'category')
def _convert_duration_to_minutes(self, duration_secs): serializer = M3UMovieRelationSerializer(relations, many=True)
"""Convert duration from seconds to minutes""" return Response(serializer.data)
if not duration_secs:
return 0
try:
return int(duration_secs) // 60
except (ValueError, TypeError):
return 0
@action(detail=True, methods=['get'], url_path='provider-info') @action(detail=True, methods=['get'], url_path='provider-info')
def provider_info(self, request, pk=None): def provider_info(self, request, pk=None):
"""Get detailed movie information from the original provider""" """Get detailed movie information from the original provider"""
logger.debug(f"MovieViewSet.provider_info called for movie ID: {pk}")
movie = self.get_object() 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( 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 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: try:
# Create XtreamCodes client from core.xtream_codes import Client as XtreamCodesClient
# Create XtreamCodes client for final response (minimal call)
with XtreamCodesClient( with XtreamCodesClient(
server_url=movie.m3u_account.server_url, server_url=relation.m3u_account.server_url,
username=movie.m3u_account.username, username=relation.m3u_account.username,
password=movie.m3u_account.password, password=relation.m3u_account.password,
user_agent=movie.m3u_account.user_agent user_agent=relation.m3u_account.get_user_agent().user_agent
) as client: ) 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: # Use cached detailed data if available
return Response( custom_props = relation.custom_properties or {}
{'error': 'No information available from provider'}, info = custom_props.get('detailed_info', {})
status=status.HTTP_404_NOT_FOUND movie_data = custom_props.get('movie_data', {})
)
# Extract and format the info # If no cached data, use basic data
info = vod_info.get('info', {}) if not info:
movie_data = vod_info.get('movie_data', {}) 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 = { response_data = {
'id': movie.id, 'id': movie.id,
'stream_id': movie.stream_id, 'stream_id': relation.stream_id,
'name': info.get('name', movie.name), 'name': info.get('name', movie.name),
'o_name': info.get('o_name', ''), 'o_name': info.get('o_name', ''),
'description': info.get('description', info.get('plot', '')), 'description': info.get('description', info.get('plot', movie.description)),
'plot': info.get('plot', info.get('description', '')), 'plot': info.get('plot', info.get('description', movie.description)),
'year': self._extract_year(info.get('releasedate', '')), 'year': movie.year or self._extract_year(info.get('releasedate', '')),
'release_date': info.get('release_date', ''), 'release_date': info.get('release_date', ''),
'releasedate': info.get('releasedate', ''), 'releasedate': info.get('releasedate', ''),
'genre': info.get('genre', ''), 'genre': info.get('genre', movie.genre),
'director': info.get('director', ''), 'director': info.get('director', ''),
'actors': info.get('actors', info.get('cast', '')), 'actors': info.get('actors', info.get('cast', '')),
'cast': info.get('cast', info.get('actors', '')), 'cast': info.get('cast', info.get('actors', '')),
'country': info.get('country', ''), 'country': info.get('country', ''),
'rating': info.get('rating', 0), 'rating': info.get('rating', movie.rating or 0),
'tmdb_id': info.get('tmdb_id', ''), 'tmdb_id': info.get('tmdb_id', movie.tmdb_id or ''),
'youtube_trailer': info.get('youtube_trailer', ''), 'youtube_trailer': info.get('youtube_trailer', ''),
'duration': self._convert_duration_to_minutes(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', 0), 'duration_secs': info.get('duration_secs', (movie.duration or 0) * 60),
'episode_run_time': info.get('episode_run_time', 0), 'episode_run_time': info.get('episode_run_time', 0),
'age': info.get('age', ''), 'age': info.get('age', ''),
'backdrop_path': info.get('backdrop_path', []), 'backdrop_path': info.get('backdrop_path', []),
@ -149,12 +201,18 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
'direct_source': movie_data.get('direct_source', ''), 'direct_source': movie_data.get('direct_source', ''),
'category_id': movie_data.get('category_id', ''), 'category_id': movie_data.get('category_id', ''),
'added': movie_data.get('added', ''), '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) return Response(response_data)
except Exception as e: 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( return Response(
{'error': f'Failed to fetch information from provider: {str(e)}'}, {'error': f'Failed to fetch information from provider: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR status=status.HTTP_500_INTERNAL_SERVER_ERROR
@ -213,9 +271,46 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
return [Authenticated()] return [Authenticated()]
def get_queryset(self): def get_queryset(self):
return Series.objects.select_related( # Only return series that have active M3U relations
'category', 'logo', 'm3u_account' return Series.objects.filter(
).prefetch_related('episodes').filter(m3u_account__is_active=True) 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') @action(detail=True, methods=['get'], url_path='provider-info')
def series_info(self, request, pk=None): def series_info(self, request, pk=None):
@ -224,9 +319,15 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
series = self.get_object() series = self.get_object()
logger.debug(f"Retrieved series: {series.name} (ID: {series.id})") 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( 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 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 refresh_interval_hours = int(request.query_params.get("refresh_interval", 24)) # Default to 24 hours
now = timezone.now() 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) # Check if detailed data has been fetched
if last_refreshed is None: 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 force_refresh = True
logger.debug(f"Series {series.id} has never been refreshed, forcing refresh") logger.debug(f"Series {series.id} needs detailed/episode refresh, forcing refresh")
else: elif last_refreshed and (now - last_refreshed) > timedelta(hours=refresh_interval_hours):
logger.debug(f"Series {series.id} last refreshed at {last_refreshed}, now is {now}") 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") 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 from .tasks import refresh_series_episodes
account = series.m3u_account account = relation.m3u_account
if account and account.is_active: 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 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) # Return the database data (which should now be fresh)
custom_props = relation.custom_properties or {}
response_data = { response_data = {
'id': series.id, 'id': series.id,
'series_id': series.series_id, 'series_id': relation.external_series_id,
'name': series.name, 'name': series.name,
'description': series.description, 'description': series.description,
'year': series.year, 'year': series.year,
@ -265,25 +374,27 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
'rating': series.rating, 'rating': series.rating,
'tmdb_id': series.tmdb_id, 'tmdb_id': series.tmdb_id,
'imdb_id': series.imdb_id, 'imdb_id': series.imdb_id,
'category_id': series.category.id if series.category else None, 'category_id': relation.category.id if relation.category else None,
'category_name': series.category.name if series.category else None, 'category_name': relation.category.name if relation.category else None,
'cover': { 'cover': {
'id': series.logo.id, 'id': series.logo.id,
'url': series.logo.url, 'url': series.logo.url,
'name': series.logo.name, 'name': series.logo.name,
} if series.logo else None, } if series.logo else None,
'last_refreshed': series.updated_at, 'last_refreshed': series.updated_at,
'custom_properties': series.custom_properties or {}, 'custom_properties': custom_props,
'm3u_account': { 'm3u_account': {
'id': series.m3u_account.id, 'id': relation.m3u_account.id,
'name': series.m3u_account.name, 'name': relation.m3u_account.name,
'account_type': series.m3u_account.account_type 'account_type': relation.m3u_account.account_type
} if series.m3u_account else None, },
'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' 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}") logger.debug(f"Including episodes for series {series.id}")
episodes_by_season = {} episodes_by_season = {}
for episode in series.episodes.all().order_by('season_number', 'episode_number'): 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: if season_key not in episodes_by_season:
episodes_by_season[season_key] = [] 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 = { episode_data = {
'id': episode.id, 'id': episode.id,
'uuid': episode.uuid, 'uuid': episode.uuid,
@ -303,8 +420,8 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
'plot': episode.description, 'plot': episode.description,
'duration': episode.duration, 'duration': episode.duration,
'rating': episode.rating, 'rating': episode.rating,
'movie_image': episode.custom_properties.get('info', {}).get('movie_image') if episode.custom_properties else None, 'movie_image': episode_relation.custom_properties.get('info', {}).get('movie_image') if episode_relation and episode_relation.custom_properties else None,
'container_extension': episode.container_extension, 'container_extension': episode_relation.container_extension if episode_relation else 'mp4',
'type': 'episode', 'type': 'episode',
'series': { 'series': {
'id': series.id, 'id': series.id,
@ -315,6 +432,9 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
response_data['episodes'] = episodes_by_season response_data['episodes'] = episodes_by_season
logger.debug(f"Added {len(episodes_by_season)} seasons of episodes to response") 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}") logger.debug(f"Returning series info response for series {series.id}")
return Response(response_data) return Response(response_data)
@ -352,21 +472,3 @@ class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet):
return [perm() for perm in permission_classes_by_action[self.action]] return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError: except KeyError:
return [Authenticated()] 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 django.db.models.deletion
import uuid import uuid
@ -10,27 +10,33 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('contenttypes', '0002_remove_content_type_name'), ('dispatcharr_channels', '0024_channelgroupm3uaccount_enable_vod_sync'),
('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'),
('m3u', '0012_alter_m3uaccount_refresh_interval'), ('m3u', '0012_alter_m3uaccount_refresh_interval'),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='VODCategory', name='Movie',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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={ options={
'verbose_name': 'VOD Category', 'verbose_name': 'Movie',
'verbose_name_plural': 'VOD Categories', 'verbose_name_plural': 'Movies',
'ordering': ['name'], 'ordering': ['name'],
'unique_together': {('name', 'm3u_account', 'category_type')}, 'unique_together': {('name', 'year', 'imdb_id'), ('name', 'year', 'tmdb_id')},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -43,42 +49,17 @@ class Migration(migrations.Migration):
('year', models.IntegerField(blank=True, null=True)), ('year', models.IntegerField(blank=True, null=True)),
('rating', models.CharField(blank=True, max_length=10, null=True)), ('rating', models.CharField(blank=True, max_length=10, null=True)),
('genre', models.CharField(blank=True, max_length=255, 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, db_index=True, help_text='TMDB ID for metadata', max_length=50, 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, db_index=True, help_text='IMDB 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)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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')), ('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={ options={
'verbose_name': 'Series', 'verbose_name': 'Series',
'verbose_name_plural': 'Series', 'verbose_name_plural': 'Series',
'ordering': ['name'], 'ordering': ['name'],
'unique_together': {('series_id', 'm3u_account')}, 'unique_together': {('name', 'year', 'imdb_id'), ('name', 'year', 'tmdb_id')},
},
),
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',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -93,52 +74,91 @@ class Migration(migrations.Migration):
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)), ('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)),
('season_number', models.IntegerField(blank=True, null=True)), ('season_number', models.IntegerField(blank=True, null=True)),
('episode_number', models.IntegerField(blank=True, null=True)), ('episode_number', models.IntegerField(blank=True, null=True)),
('url', models.URLField(max_length=2048)), ('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)), ('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
('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)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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')), ('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')),
], ],
options={ options={
'verbose_name': 'Episode', 'verbose_name': 'Episode',
'verbose_name_plural': 'Episodes', 'verbose_name_plural': 'Episodes',
'ordering': ['series__name', 'season_number', 'episode_number'], 'ordering': ['series__name', 'season_number', 'episode_number'],
'unique_together': {('stream_id', 'm3u_account')}, 'unique_together': {('series', 'season_number', 'episode_number')},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Movie', name='VODCategory',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True, null=True)), ('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)),
('year', models.IntegerField(blank=True, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('rating', models.CharField(blank=True, max_length=10, null=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('genre', models.CharField(blank=True, max_length=255, null=True)), ],
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=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)), ('url', models.URLField(max_length=2048)),
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)), ('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)), ('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)), ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', 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)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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')), ('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='movies', to='m3u.m3uaccount')), ('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')), ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
], ],
options={ options={
'verbose_name': 'Movie', 'verbose_name': 'M3U Series Relation',
'verbose_name_plural': 'Movies', 'verbose_name_plural': 'M3U Series Relations',
'ordering': ['name'], 'unique_together': {('m3u_account', 'external_series_id')},
'unique_together': {('stream_id', 'm3u_account')}, },
),
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', default='movie',
help_text="Type of content this category contains" 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
verbose_name = "VOD Category" verbose_name = 'VOD Category'
verbose_name_plural = "VOD Categories" verbose_name_plural = 'VOD Categories'
ordering = ['name'] ordering = ['name']
unique_together = ['name', 'm3u_account', 'category_type'] unique_together = [('name', 'category_type')]
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_category_type_display()})" 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) rating = models.CharField(max_length=10, blank=True, null=True)
genre = models.CharField(max_length=255, 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) 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( # Metadata IDs for deduplication
M3UAccount, tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
on_delete=models.CASCADE, imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
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")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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: class Meta:
verbose_name = "Series" verbose_name = 'Series'
verbose_name_plural = "Series" verbose_name_plural = 'Series'
ordering = ['name'] 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): 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): class Movie(models.Model):
@ -84,43 +76,28 @@ class Movie(models.Model):
rating = models.CharField(max_length=10, blank=True, null=True) rating = models.CharField(max_length=10, blank=True, null=True)
genre = models.CharField(max_length=255, 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") 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) 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 # Metadata IDs for deduplication
m3u_account = models.ForeignKey( tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
M3UAccount, imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
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")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
verbose_name = "Movie" verbose_name = 'Movie'
verbose_name_plural = "Movies" verbose_name_plural = 'Movies'
ordering = ['name'] 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): 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}"
def get_stream_url(self):
"""Generate the proxied stream URL for this movie"""
return f"/proxy/vod/movie/{self.uuid}"
class Episode(models.Model): class Episode(models.Model):
@ -137,75 +114,108 @@ class Episode(models.Model):
season_number = models.IntegerField(blank=True, null=True) season_number = models.IntegerField(blank=True, null=True)
episode_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 # Metadata IDs
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata") 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") imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
# Additional properties
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
verbose_name = "Episode" verbose_name = 'Episode'
verbose_name_plural = "Episodes" verbose_name_plural = 'Episodes'
ordering = ['series__name', 'season_number', 'episode_number'] ordering = ['series__name', 'season_number', 'episode_number']
unique_together = ['stream_id', 'm3u_account'] unique_together = [
('series', 'season_number', 'episode_number'),
]
def __str__(self): 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 "" 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}" 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): # New relation models to link M3U accounts with VOD content
"""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')
m3u_profile = models.ForeignKey( class M3USeriesRelation(models.Model):
'm3u.M3UAccountProfile', """Links M3U accounts to Series with provider-specific information"""
on_delete=models.CASCADE, m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='series_relations')
related_name='vod_connections' 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)
client_id = models.CharField(max_length=255)
client_ip = models.GenericIPAddressField() # Provider-specific fields - renamed to avoid clash with series ForeignKey
user_agent = models.TextField(blank=True, null=True) external_series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider")
connected_at = models.DateTimeField(auto_now_add=True) custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data")
last_activity = models.DateTimeField(auto_now=True)
bytes_sent = models.BigIntegerField(default=0) # Timestamps
position_seconds = models.IntegerField(default=0, help_text="Current playback position") 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: class Meta:
verbose_name = "VOD Connection" verbose_name = 'M3U Series Relation'
verbose_name_plural = "VOD Connections" verbose_name_plural = 'M3U Series Relations'
unique_together = [('m3u_account', 'external_series_id')]
def __str__(self): def __str__(self):
content_name = getattr(self.content_object, 'name', 'Unknown') if self.content_object else 'Unknown' return f"{self.m3u_account.name} - {self.series.name}"
return f"{content_name} - {self.client_ip} ({self.client_id})"
def update_activity(self, bytes_sent=0, position=0):
"""Update connection activity""" class M3UMovieRelation(models.Model):
self.last_activity = timezone.now() """Links M3U accounts to Movies with provider-specific information"""
if bytes_sent: m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='movie_relations')
self.bytes_sent += bytes_sent movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='m3u_relations')
if position: category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
self.position_seconds = position
self.save(update_fields=['last_activity', 'bytes_sent', 'position_seconds']) # 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 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.channels.serializers import LogoSerializer
from apps.m3u.serializers import M3UAccountSerializer from apps.m3u.serializers import M3UAccountSerializer
@ -14,8 +17,6 @@ class VODCategorySerializer(serializers.ModelSerializer):
class SeriesSerializer(serializers.ModelSerializer): class SeriesSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True) logo = LogoSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
episode_count = serializers.SerializerMethodField() episode_count = serializers.SerializerMethodField()
class Meta: class Meta:
@ -28,40 +29,59 @@ class SeriesSerializer(serializers.ModelSerializer):
class MovieSerializer(serializers.ModelSerializer): class MovieSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True) logo = LogoSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
stream_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = Movie model = Movie
fields = '__all__' fields = '__all__'
def get_stream_url(self, obj):
return obj.get_stream_url()
class EpisodeSerializer(serializers.ModelSerializer): class EpisodeSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
series = SeriesSerializer(read_only=True) series = SeriesSerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
stream_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = Episode model = Episode
fields = '__all__' fields = '__all__'
def get_stream_url(self, obj):
return obj.get_stream_url()
class M3USeriesRelationSerializer(serializers.ModelSerializer):
class VODConnectionSerializer(serializers.ModelSerializer): series = SeriesSerializer(read_only=True)
content_name = serializers.SerializerMethodField() category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
class Meta: class Meta:
model = VODConnection model = M3USeriesRelation
fields = '__all__' fields = '__all__'
def get_content_name(self, obj):
if obj.content_object: class M3UMovieRelationSerializer(serializers.ModelSerializer):
return getattr(obj.content_object, 'name', 'Unknown') movie = MovieSerializer(read_only=True)
return 'Unknown' 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 celery import shared_task
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from django.db import transaction
from .models import Series, VODCategory, VODConnection, Movie, Episode
from apps.m3u.models import M3UAccount from apps.m3u.models import M3UAccount
from apps.channels.models import Logo
from core.xtream_codes import Client as XtreamCodesClient 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__) logger = logging.getLogger(__name__)
@shared_task(bind=True) @shared_task
def refresh_vod_content(self, account_id): def refresh_vod_content(account_id):
"""Refresh VOD content from XtreamCodes API""" """Refresh VOD content for an M3U account"""
try: 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: if account.account_type != M3UAccount.Types.XC:
logger.warning(f"Account {account_id} is not XtreamCodes type") logger.warning(f"VOD refresh called for non-XC account {account_id}")
return return "VOD refresh only available for XtreamCodes accounts"
# Get movies and series logger.info(f"Starting VOD refresh for account {account.name}")
refresh_movies(account)
refresh_series(account)
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: 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): def refresh_movies(client, account):
"""Extract year from movie title if present""" """Refresh movie content - only basic list, no detailed calls"""
if not title: 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 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]: def convert_duration_to_minutes(duration_secs):
match = re.search(pattern, title) """Convert duration from seconds to minutes"""
if match: if not duration_secs:
year = int(match.group(1)) return None
# 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"""
try: try:
# First try the year field return int(duration_secs) // 60
year = data.get('year') except (ValueError, TypeError):
if year and str(year).strip() and str(year).strip() != '': return None
try:
year_int = int(year)
if 1900 <= year_int <= 2030:
return year_int
except (ValueError, TypeError):
pass
# 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 @shared_task
def cleanup_inactive_vod_connections(): def cleanup_orphaned_vod_content():
"""Clean up inactive VOD connections""" """Clean up VOD content that has no M3U relations"""
cutoff_time = timezone.now() - timedelta(minutes=30) # Clean up movies with no relations
inactive_connections = VODConnection.objects.filter(last_activity__lt=cutoff_time) orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True)
movie_count = orphaned_movies.count()
orphaned_movies.delete()
count = inactive_connections.count() # Clean up series with no relations
if count > 0: orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
inactive_connections.delete() series_count = orphaned_series.count()
logger.info(f"Cleaned up {count} inactive VOD connections") 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) 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] profiles = [obj for obj in m3u_profiles if not obj.is_default]
# -- Loop through profiles and pick the first active one -- # -- Loop through profiles and pick the first active one --
for profile in [default_profile] + profiles: for profile in [default_profile] + profiles:
logger.debug(f'Checking profile {profile.name}...') logger.debug(f'Checking profile {profile.name}...')
@ -174,7 +173,7 @@ def stream_view(request, channel_uuid):
persistent_lock.release() persistent_lock.release()
logger.debug("Persistent lock released for channel ID=%s", channel.id) logger.debug("Persistent lock released for channel ID=%s", channel.id)
return StreamingHttpResponse( return StreamingHttpResponse(
stream_generator(process, stream, persistent_lock), stream_generator(process, stream, persistent_lock),
content_type="video/MP2T" content_type="video/MP2T"
) )

View file

@ -370,6 +370,14 @@ class Client:
"""Get the playback URL for a VOD""" """Get the playback URL for a VOD"""
return f"{self.server_url}/movie/{self.username}/{self.password}/{vod_id}.{container_extension}" 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): def close(self):
"""Close the session and cleanup resources""" """Close the session and cleanup resources"""
if hasattr(self, 'session') and self.session: if hasattr(self, 'session') and self.session:

View file

@ -238,6 +238,11 @@ const SeriesModal = ({ series, opened, onClose }) => {
fetchSeriesInfo(series.id) fetchSeriesInfo(series.id)
.then((details) => { .then((details) => {
setDetailedSeries(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) => { .catch((error) => {
console.warn('Failed to fetch series details, using basic info:', error); console.warn('Failed to fetch series details, using basic info:', error);
@ -541,10 +546,10 @@ const SeriesModal = ({ series, opened, onClose }) => {
{/* Provider Information */} {/* Provider Information */}
{displaySeries.m3u_account && ( {displaySeries.m3u_account && (
<Box mt="md"> <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"> <Group spacing="md">
<Badge color="blue" variant="light"> <Badge color="blue" variant="light">
{displaySeries.m3u_account.name || displaySeries.m3u_account} {displaySeries.m3u_account.name}
</Badge> </Badge>
{displaySeries.m3u_account.account_type && ( {displaySeries.m3u_account.account_type && (
<Badge color="gray" variant="outline" size="xs"> <Badge color="gray" variant="outline" size="xs">
@ -764,7 +769,6 @@ const SeriesModal = ({ series, opened, onClose }) => {
title="Trailer" title="Trailer"
size="xl" size="xl"
centered centered
withCloseButton
> >
<Box style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}> <Box style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}>
{trailerUrl && ( {trailerUrl && (