mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Convert to relation tables to support multiple providers for each vod.
This commit is contained in:
parent
44a2cf518c
commit
0e388968c4
14 changed files with 1429 additions and 955 deletions
|
|
@ -1003,7 +1003,7 @@ def xc_get_epg(request, user, short=False):
|
|||
|
||||
def xc_get_vod_categories(user):
|
||||
"""Get VOD categories for XtreamCodes API"""
|
||||
from apps.vod.models import VODCategory
|
||||
from apps.vod.models import VODCategory, M3UMovieRelation
|
||||
|
||||
response = []
|
||||
|
||||
|
|
@ -1021,13 +1021,16 @@ def xc_get_vod_categories(user):
|
|||
else:
|
||||
m3u_accounts = []
|
||||
|
||||
# Get categories that have movie relations with these accounts
|
||||
categories = VODCategory.objects.filter(
|
||||
m3u_account__in=m3u_accounts
|
||||
category_type='movie',
|
||||
m3umovierelation__m3u_account__in=m3u_accounts
|
||||
).distinct()
|
||||
else:
|
||||
# Admins can see all categories
|
||||
# Admins can see all categories that have active movie relations
|
||||
categories = VODCategory.objects.filter(
|
||||
m3u_account__is_active=True
|
||||
category_type='movie',
|
||||
m3umovierelation__m3u_account__is_active=True
|
||||
).distinct()
|
||||
|
||||
for category in categories:
|
||||
|
|
@ -1042,7 +1045,7 @@ def xc_get_vod_categories(user):
|
|||
|
||||
def xc_get_vod_streams(request, user, category_id=None):
|
||||
"""Get VOD streams (movies) for XtreamCodes API"""
|
||||
from apps.vod.models import Movie
|
||||
from apps.vod.models import M3UMovieRelation
|
||||
|
||||
streams = []
|
||||
|
||||
|
|
@ -1065,14 +1068,18 @@ def xc_get_vod_streams(request, user, category_id=None):
|
|||
if category_id:
|
||||
filters["category_id"] = category_id
|
||||
|
||||
movies = Movie.objects.filter(**filters).select_related('category', 'logo', 'm3u_account')
|
||||
# Get movie relations instead of movies directly
|
||||
movie_relations = M3UMovieRelation.objects.filter(**filters).select_related(
|
||||
'movie', 'movie__logo', 'category', 'm3u_account'
|
||||
)
|
||||
|
||||
for movie in movies:
|
||||
for relation in movie_relations:
|
||||
movie = relation.movie
|
||||
streams.append({
|
||||
"num": movie.id,
|
||||
"num": relation.id, # Use relation ID as num
|
||||
"name": movie.name,
|
||||
"stream_type": "movie",
|
||||
"stream_id": movie.id,
|
||||
"stream_id": relation.id, # Use relation ID
|
||||
"stream_icon": (
|
||||
None if not movie.logo
|
||||
else request.build_absolute_uri(
|
||||
|
|
@ -1081,12 +1088,12 @@ def xc_get_vod_streams(request, user, category_id=None):
|
|||
),
|
||||
"rating": movie.rating or "0",
|
||||
"rating_5based": float(movie.rating or 0) / 2 if movie.rating else 0,
|
||||
"added": int(time.time()), # TODO: use actual created date
|
||||
"added": int(relation.created_at.timestamp()),
|
||||
"is_adult": 0,
|
||||
"category_id": str(movie.category.id) if movie.category else "0",
|
||||
"container_extension": movie.container_extension or "mp4",
|
||||
"category_id": str(relation.category.id) if relation.category else "0",
|
||||
"container_extension": relation.container_extension or "mp4",
|
||||
"custom_sid": None,
|
||||
"direct_source": movie.url,
|
||||
"direct_source": relation.url,
|
||||
})
|
||||
|
||||
return streams
|
||||
|
|
@ -1094,7 +1101,7 @@ def xc_get_vod_streams(request, user, category_id=None):
|
|||
|
||||
def xc_get_series_categories(user):
|
||||
"""Get series categories for XtreamCodes API"""
|
||||
from apps.vod.models import VODCategory
|
||||
from apps.vod.models import VODCategory, M3USeriesRelation
|
||||
|
||||
response = []
|
||||
|
||||
|
|
@ -1110,14 +1117,15 @@ def xc_get_series_categories(user):
|
|||
else:
|
||||
m3u_accounts = []
|
||||
|
||||
# Get categories that have series relations with these accounts
|
||||
categories = VODCategory.objects.filter(
|
||||
m3u_account__in=m3u_accounts,
|
||||
series__isnull=False # Only categories that have series
|
||||
category_type='series',
|
||||
m3useriesrelation__m3u_account__in=m3u_accounts
|
||||
).distinct()
|
||||
else:
|
||||
categories = VODCategory.objects.filter(
|
||||
m3u_account__is_active=True,
|
||||
series__isnull=False
|
||||
category_type='series',
|
||||
m3useriesrelation__m3u_account__is_active=True
|
||||
).distinct()
|
||||
|
||||
for category in categories:
|
||||
|
|
@ -1132,7 +1140,7 @@ def xc_get_series_categories(user):
|
|||
|
||||
def xc_get_series(request, user, category_id=None):
|
||||
"""Get series list for XtreamCodes API"""
|
||||
from apps.vod.models import Series
|
||||
from apps.vod.models import M3USeriesRelation
|
||||
|
||||
series_list = []
|
||||
|
||||
|
|
@ -1154,31 +1162,35 @@ def xc_get_series(request, user, category_id=None):
|
|||
if category_id:
|
||||
filters["category_id"] = category_id
|
||||
|
||||
series = Series.objects.filter(**filters).select_related('category', 'logo', 'm3u_account')
|
||||
# Get series relations instead of series directly
|
||||
series_relations = M3USeriesRelation.objects.filter(**filters).select_related(
|
||||
'series', 'series__logo', 'category', 'm3u_account'
|
||||
)
|
||||
|
||||
for serie in series:
|
||||
for relation in series_relations:
|
||||
series = relation.series
|
||||
series_list.append({
|
||||
"num": serie.id,
|
||||
"name": serie.name,
|
||||
"series_id": serie.id,
|
||||
"num": relation.id, # Use relation ID
|
||||
"name": series.name,
|
||||
"series_id": relation.id, # Use relation ID
|
||||
"cover": (
|
||||
None if not serie.logo
|
||||
None if not series.logo
|
||||
else request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[serie.logo.id])
|
||||
reverse("api:channels:logo-cache", args=[series.logo.id])
|
||||
)
|
||||
),
|
||||
"plot": serie.description or "",
|
||||
"plot": series.description or "",
|
||||
"cast": "",
|
||||
"director": "",
|
||||
"genre": serie.genre or "",
|
||||
"release_date": str(serie.year) if serie.year else "",
|
||||
"last_modified": int(time.time()),
|
||||
"rating": serie.rating or "0",
|
||||
"rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0,
|
||||
"genre": series.genre or "",
|
||||
"release_date": str(series.year) if series.year else "",
|
||||
"last_modified": int(relation.updated_at.timestamp()),
|
||||
"rating": series.rating or "0",
|
||||
"rating_5based": float(series.rating or 0) / 2 if series.rating else 0,
|
||||
"backdrop_path": [],
|
||||
"youtube_trailer": "",
|
||||
"episode_run_time": "",
|
||||
"category_id": str(serie.category.id) if serie.category else "0",
|
||||
"category_id": str(relation.category.id) if relation.category else "0",
|
||||
})
|
||||
|
||||
return series_list
|
||||
|
|
@ -1186,12 +1198,12 @@ def xc_get_series(request, user, category_id=None):
|
|||
|
||||
def xc_get_series_info(request, user, series_id):
|
||||
"""Get detailed series information including episodes"""
|
||||
from apps.vod.models import Series, Episode
|
||||
from apps.vod.models import M3USeriesRelation, M3UEpisodeRelation
|
||||
|
||||
if not series_id:
|
||||
raise Http404()
|
||||
|
||||
# Get series with user access filtering
|
||||
# Get series relation with user access filtering
|
||||
filters = {"id": series_id, "m3u_account__is_active": True}
|
||||
|
||||
if user.user_level == 0:
|
||||
|
|
@ -1207,33 +1219,36 @@ def xc_get_series_info(request, user, series_id):
|
|||
raise Http404()
|
||||
|
||||
try:
|
||||
serie = Series.objects.get(**filters)
|
||||
except Series.DoesNotExist:
|
||||
series_relation = M3USeriesRelation.objects.select_related('series', 'series__logo').get(**filters)
|
||||
series = series_relation.series
|
||||
except M3USeriesRelation.DoesNotExist:
|
||||
raise Http404()
|
||||
|
||||
# Get episodes grouped by season
|
||||
episodes = Episode.objects.filter(
|
||||
series=serie
|
||||
).order_by('season_number', 'episode_number')
|
||||
# Get episodes for this series from the same M3U account
|
||||
episode_relations = M3UEpisodeRelation.objects.filter(
|
||||
episode__series=series,
|
||||
m3u_account=series_relation.m3u_account
|
||||
).select_related('episode').order_by('episode__season_number', 'episode__episode_number')
|
||||
|
||||
# Group episodes by season
|
||||
seasons = {}
|
||||
for episode in episodes:
|
||||
for relation in episode_relations:
|
||||
episode = relation.episode
|
||||
season_num = episode.season_number or 1
|
||||
if season_num not in seasons:
|
||||
seasons[season_num] = []
|
||||
|
||||
seasons[season_num].append({
|
||||
"id": episode.stream_id,
|
||||
"id": relation.stream_id,
|
||||
"episode_num": episode.episode_number or 0,
|
||||
"title": episode.name,
|
||||
"container_extension": episode.container_extension or "mp4",
|
||||
"container_extension": relation.container_extension or "mp4",
|
||||
"info": {
|
||||
"air_date": f"{episode.year}-01-01" if episode.year else "",
|
||||
"air_date": f"{episode.release_date}" if episode.release_date else "",
|
||||
"crew": "",
|
||||
"directed_by": "",
|
||||
"episode_num": episode.episode_number or 0,
|
||||
"id": episode.stream_id,
|
||||
"id": relation.stream_id,
|
||||
"imdb_id": episode.imdb_id or "",
|
||||
"name": episode.name,
|
||||
"overview": episode.description or "",
|
||||
|
|
@ -1243,7 +1258,7 @@ def xc_get_series_info(request, user, series_id):
|
|||
"vote_average": float(episode.rating or 0),
|
||||
"vote_count": 0,
|
||||
"writer": "",
|
||||
"release_date": f"{episode.year}-01-01" if episode.year else "",
|
||||
"release_date": f"{episode.release_date}" if episode.release_date else "",
|
||||
"duration_secs": (episode.duration or 0) * 60,
|
||||
"duration": f"{episode.duration or 0} min",
|
||||
"video": {},
|
||||
|
|
@ -1256,25 +1271,25 @@ def xc_get_series_info(request, user, series_id):
|
|||
info = {
|
||||
"seasons": list(seasons.keys()),
|
||||
"info": {
|
||||
"name": serie.name,
|
||||
"name": series.name,
|
||||
"cover": (
|
||||
None if not serie.logo
|
||||
None if not series.logo
|
||||
else request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[serie.logo.id])
|
||||
reverse("api:channels:logo-cache", args=[series.logo.id])
|
||||
)
|
||||
),
|
||||
"plot": serie.description or "",
|
||||
"plot": series.description or "",
|
||||
"cast": "",
|
||||
"director": "",
|
||||
"genre": serie.genre or "",
|
||||
"release_date": str(serie.year) if serie.year else "",
|
||||
"last_modified": int(time.time()),
|
||||
"rating": serie.rating or "0",
|
||||
"rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0,
|
||||
"genre": series.genre or "",
|
||||
"release_date": str(series.year) if series.year else "",
|
||||
"last_modified": int(series_relation.updated_at.timestamp()),
|
||||
"rating": series.rating or "0",
|
||||
"rating_5based": float(series.rating or 0) / 2 if series.rating else 0,
|
||||
"backdrop_path": [],
|
||||
"youtube_trailer": "",
|
||||
"episode_run_time": "",
|
||||
"category_id": str(serie.category.id) if serie.category else "0",
|
||||
"category_id": str(series_relation.category.id) if series_relation.category else "0",
|
||||
},
|
||||
"episodes": dict(seasons)
|
||||
}
|
||||
|
|
@ -1284,12 +1299,12 @@ def xc_get_series_info(request, user, series_id):
|
|||
|
||||
def xc_get_vod_info(request, user, vod_id):
|
||||
"""Get detailed VOD (movie) information"""
|
||||
from apps.vod.models import Movie
|
||||
from apps.vod.models import M3UMovieRelation
|
||||
|
||||
if not vod_id:
|
||||
raise Http404()
|
||||
|
||||
# Get Movie with user access filtering
|
||||
# Get movie relation with user access filtering
|
||||
filters = {"id": vod_id, "m3u_account__is_active": True}
|
||||
|
||||
if user.user_level == 0:
|
||||
|
|
@ -1305,8 +1320,9 @@ def xc_get_vod_info(request, user, vod_id):
|
|||
raise Http404()
|
||||
|
||||
try:
|
||||
movie = Movie.objects.get(**filters)
|
||||
except Movie.DoesNotExist:
|
||||
movie_relation = M3UMovieRelation.objects.select_related('movie', 'movie__logo').get(**filters)
|
||||
movie = movie_relation.movie
|
||||
except M3UMovieRelation.DoesNotExist:
|
||||
raise Http404()
|
||||
|
||||
info = {
|
||||
|
|
@ -1346,15 +1362,14 @@ def xc_get_vod_info(request, user, vod_id):
|
|||
"rating": float(movie.rating or 0),
|
||||
},
|
||||
"movie_data": {
|
||||
"stream_id": movie.id,
|
||||
"stream_id": movie_relation.id, # Use relation ID
|
||||
"name": movie.name,
|
||||
"added": int(time.time()),
|
||||
"category_id": str(movie.category.id) if movie.category else "0",
|
||||
"container_extension": movie.container_extension or "mp4",
|
||||
"added": int(movie_relation.created_at.timestamp()),
|
||||
"category_id": str(movie_relation.category.id) if movie_relation.category else "0",
|
||||
"container_extension": movie_relation.container_extension or "mp4",
|
||||
"custom_sid": "",
|
||||
"direct_source": movie.url,
|
||||
"direct_source": movie_relation.url,
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
return info
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ from . import views
|
|||
app_name = 'vod_proxy'
|
||||
|
||||
urlpatterns = [
|
||||
# Movie streaming
|
||||
path('movie/<uuid:movie_uuid>', views.stream_movie, name='stream_movie'),
|
||||
path('movie/<uuid:movie_uuid>/position', views.update_movie_position, name='update_movie_position'),
|
||||
# Generic VOD streaming (supports movies, episodes, series)
|
||||
path('<str:content_type>/<uuid:content_id>/', views.VODStreamView.as_view(), name='vod_stream'),
|
||||
path('<str:content_type>/<uuid:content_id>/<int:profile_id>/', views.VODStreamView.as_view(), name='vod_stream_with_profile'),
|
||||
|
||||
# Episode streaming
|
||||
path('episode/<uuid:episode_uuid>', views.stream_episode, name='stream_episode'),
|
||||
path('episode/<uuid:episode_uuid>/position', views.update_episode_position, name='update_episode_position'),
|
||||
# VOD playlist generation
|
||||
path('playlist/', views.VODPlaylistView.as_view(), name='vod_playlist'),
|
||||
path('playlist/<int:profile_id>/', views.VODPlaylistView.as_view(), name='vod_playlist_with_profile'),
|
||||
|
||||
# Position tracking
|
||||
path('position/<uuid:content_id>/', views.VODPositionView.as_view(), name='vod_position'),
|
||||
]
|
||||
|
|
|
|||
58
apps/proxy/vod_proxy/utils.py
Normal file
58
apps/proxy/vod_proxy/utils.py
Normal 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
|
||||
|
|
@ -1,235 +1,334 @@
|
|||
"""
|
||||
VOD (Video on Demand) proxy views for handling movie and series streaming.
|
||||
Supports M3U profiles for authentication and URL transformation.
|
||||
"""
|
||||
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
import requests
|
||||
from django.http import StreamingHttpResponse, JsonResponse
|
||||
from django.http import StreamingHttpResponse, JsonResponse, Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.decorators import api_view
|
||||
|
||||
from apps.vod.models import Movie, Episode
|
||||
from dispatcharr.utils import network_access_allowed, get_client_ip
|
||||
from core.models import UserAgent, CoreSettings
|
||||
from .connection_manager import get_connection_manager
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from apps.vod.models import Movie, Series, Episode
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from apps.proxy.vod_proxy.connection_manager import VODConnectionManager
|
||||
from .utils import get_client_info, create_vod_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(["GET"])
|
||||
def stream_movie(request, movie_uuid):
|
||||
"""Stream movie content with connection tracking and range support"""
|
||||
return _stream_content(request, Movie, movie_uuid, "movie")
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class VODStreamView(View):
|
||||
"""Handle VOD streaming requests with M3U profile support"""
|
||||
|
||||
def get(self, request, content_type, content_id, profile_id=None):
|
||||
"""
|
||||
Stream VOD content (movies or series episodes)
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(["GET"])
|
||||
def stream_episode(request, episode_uuid):
|
||||
"""Stream episode content with connection tracking and range support"""
|
||||
return _stream_content(request, Episode, episode_uuid, "episode")
|
||||
Args:
|
||||
content_type: 'movie', 'series', or 'episode'
|
||||
content_id: ID of the content
|
||||
profile_id: Optional M3U profile ID for authentication
|
||||
"""
|
||||
logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, profile: {profile_id}")
|
||||
logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}")
|
||||
logger.info(f"[VOD-REQUEST] Request method: {request.method}")
|
||||
|
||||
|
||||
def _stream_content(request, model_class, content_uuid, content_type_name):
|
||||
"""Generic function to stream VOD content"""
|
||||
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
|
||||
# Get content object
|
||||
content = get_object_or_404(model_class, uuid=content_uuid)
|
||||
|
||||
# Generate client ID and get client info
|
||||
client_id = f"vod_client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||||
client_ip = get_client_ip(request)
|
||||
client_user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||
|
||||
logger.info(f"[{client_id}] VOD stream request for: {content.name}")
|
||||
|
||||
try:
|
||||
# Get connection manager
|
||||
connection_manager = get_connection_manager()
|
||||
|
||||
# Get available M3U profile for connection management
|
||||
m3u_account = content.m3u_account
|
||||
available_profile = None
|
||||
|
||||
for profile in m3u_account.profiles.filter(is_active=True):
|
||||
# Use standardized connection counting method
|
||||
current_connections = connection_manager.get_profile_connections(profile.id)
|
||||
if profile.max_streams == 0 or current_connections < profile.max_streams:
|
||||
available_profile = profile
|
||||
break
|
||||
|
||||
if not available_profile:
|
||||
return JsonResponse(
|
||||
{"error": "No available connections for this VOD"},
|
||||
status=503
|
||||
)
|
||||
|
||||
# Create connection tracking record in Redis
|
||||
connection_created = connection_manager.create_connection(
|
||||
content_type=content_type_name,
|
||||
content_uuid=str(content_uuid),
|
||||
content_name=content.name,
|
||||
client_id=client_id,
|
||||
client_ip=client_ip,
|
||||
user_agent=client_user_agent,
|
||||
m3u_profile=available_profile
|
||||
)
|
||||
|
||||
if not connection_created:
|
||||
return JsonResponse(
|
||||
{"error": "Failed to create connection tracking"},
|
||||
status=503
|
||||
)
|
||||
|
||||
# Get user agent for upstream request
|
||||
try:
|
||||
user_agent_obj = m3u_account.get_user_agent()
|
||||
upstream_user_agent = user_agent_obj.user_agent
|
||||
except:
|
||||
default_ua_id = CoreSettings.get_default_user_agent_id()
|
||||
user_agent_obj = UserAgent.objects.get(id=default_ua_id)
|
||||
upstream_user_agent = user_agent_obj.user_agent
|
||||
client_ip, user_agent = get_client_info(request)
|
||||
logger.info(f"[VOD-CLIENT] Client info - IP: {client_ip}, User-Agent: {user_agent[:100]}...")
|
||||
|
||||
# Handle range requests for seeking
|
||||
range_header = request.META.get('HTTP_RANGE')
|
||||
headers = {
|
||||
'User-Agent': upstream_user_agent,
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
# Get the content object
|
||||
content_obj = self._get_content_object(content_type, content_id)
|
||||
if not content_obj:
|
||||
logger.error(f"[VOD-ERROR] Content not found: {content_type} {content_id}")
|
||||
raise Http404(f"Content not found: {content_type} {content_id}")
|
||||
|
||||
if range_header:
|
||||
headers['Range'] = range_header
|
||||
logger.debug(f"[{client_id}] Range request: {range_header}")
|
||||
logger.info(f"[VOD-CONTENT] Found content: {content_obj.title if hasattr(content_obj, 'title') else getattr(content_obj, 'name', 'Unknown')}")
|
||||
logger.info(f"[VOD-CONTENT] Content URL: {getattr(content_obj, 'url', 'No URL found')}")
|
||||
|
||||
# Stream the VOD content
|
||||
try:
|
||||
response = requests.get(
|
||||
content.url,
|
||||
headers=headers,
|
||||
stream=True,
|
||||
timeout=(10, 60)
|
||||
# Get M3U account and profile
|
||||
m3u_account = content_obj.m3u_account
|
||||
logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}")
|
||||
|
||||
m3u_profile = self._get_m3u_profile(m3u_account, profile_id, user_agent)
|
||||
|
||||
if not m3u_profile:
|
||||
logger.error(f"[VOD-ERROR] No suitable M3U profile found for {content_type} {content_id}")
|
||||
return HttpResponse("No available stream", status=503)
|
||||
|
||||
logger.info(f"[VOD-PROFILE] Using M3U profile: {m3u_profile.id} (max_streams: {m3u_profile.max_streams}, current: {m3u_profile.current_viewers})")
|
||||
|
||||
# Track connection start in Redis
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
redis_client = RedisClient.get_client()
|
||||
profile_connections_key = f"profile_connections:{m3u_profile.id}"
|
||||
current_count = redis_client.incr(profile_connections_key)
|
||||
logger.debug(f"Incremented VOD profile {m3u_profile.id} connections to {current_count}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error tracking connection in Redis: {e}")
|
||||
|
||||
# Transform URL based on profile
|
||||
stream_url = self._transform_url(content_obj, m3u_profile)
|
||||
logger.info(f"[VOD-URL] Final stream URL: {stream_url}")
|
||||
|
||||
# Validate stream URL
|
||||
if not stream_url or not stream_url.startswith(('http://', 'https://')):
|
||||
logger.error(f"[VOD-ERROR] Invalid stream URL: {stream_url}")
|
||||
return HttpResponse("Invalid stream URL", status=500)
|
||||
|
||||
# Get connection manager
|
||||
connection_manager = VODConnectionManager.get_instance()
|
||||
|
||||
# Stream the content
|
||||
logger.info("[VOD-STREAM] Calling connection manager to stream content")
|
||||
response = connection_manager.stream_content(
|
||||
content_obj=content_obj,
|
||||
stream_url=stream_url,
|
||||
m3u_profile=m3u_profile,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
request=request
|
||||
)
|
||||
|
||||
if response.status_code not in [200, 206]:
|
||||
logger.error(f"[{client_id}] Upstream error: {response.status_code}")
|
||||
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id)
|
||||
return JsonResponse(
|
||||
{"error": f"Upstream server error: {response.status_code}"},
|
||||
status=response.status_code
|
||||
)
|
||||
logger.info(f"[VOD-SUCCESS] Stream response created successfully, type: {type(response)}")
|
||||
return response
|
||||
|
||||
# Determine content type
|
||||
content_type_header = response.headers.get('Content-Type', 'video/mp4')
|
||||
content_length = response.headers.get('Content-Length')
|
||||
content_range = response.headers.get('Content-Range')
|
||||
except Exception as e:
|
||||
logger.error(f"[VOD-EXCEPTION] Error streaming {content_type} {content_id}: {e}", exc_info=True)
|
||||
return HttpResponse(f"Streaming error: {str(e)}", status=500)
|
||||
|
||||
# Create streaming response
|
||||
def stream_generator():
|
||||
bytes_sent = 0
|
||||
def _get_content_object(self, content_type, content_id):
|
||||
"""Get the content object based on type and UUID"""
|
||||
try:
|
||||
logger.info(f"[CONTENT-LOOKUP] Looking up {content_type} with UUID {content_id}")
|
||||
if content_type == 'movie':
|
||||
obj = get_object_or_404(Movie, uuid=content_id)
|
||||
logger.info(f"[CONTENT-FOUND] Movie: {obj.name} (ID: {obj.id})")
|
||||
return obj
|
||||
elif content_type == 'episode':
|
||||
obj = get_object_or_404(Episode, uuid=content_id)
|
||||
logger.info(f"[CONTENT-FOUND] Episode: {obj.name} (ID: {obj.id}, Series: {obj.series.name})")
|
||||
return obj
|
||||
elif content_type == 'series':
|
||||
# For series, get the first episode
|
||||
series = get_object_or_404(Series, uuid=content_id)
|
||||
logger.info(f"[CONTENT-FOUND] Series: {series.name} (ID: {series.id})")
|
||||
episode = series.episodes.first()
|
||||
if not episode:
|
||||
logger.error(f"[CONTENT-ERROR] No episodes found for series {series.name}")
|
||||
raise Http404("No episodes found for series")
|
||||
logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})")
|
||||
return episode
|
||||
else:
|
||||
logger.error(f"[CONTENT-ERROR] Invalid content type: {content_type}")
|
||||
raise Http404(f"Invalid content type: {content_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting content object: {e}")
|
||||
return None
|
||||
|
||||
def _get_m3u_profile(self, content_obj, profile_id, user_agent):
|
||||
"""Get appropriate M3U profile for streaming"""
|
||||
try:
|
||||
# Get M3U account from content object's relations
|
||||
m3u_account = None
|
||||
|
||||
if hasattr(content_obj, 'm3u_relations'):
|
||||
# This is a Movie or Episode with relations
|
||||
relation = content_obj.m3u_relations.filter(m3u_account__is_active=True).first()
|
||||
if relation:
|
||||
m3u_account = relation.m3u_account
|
||||
elif hasattr(content_obj, 'series'):
|
||||
# This is an Episode, get relation through series
|
||||
relation = content_obj.series.m3u_relations.filter(m3u_account__is_active=True).first()
|
||||
if relation:
|
||||
m3u_account = relation.m3u_account
|
||||
|
||||
if not m3u_account:
|
||||
logger.error("No M3U account found for content object")
|
||||
return None
|
||||
|
||||
# If specific profile requested, try to use it
|
||||
if profile_id:
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
bytes_sent += len(chunk)
|
||||
yield chunk
|
||||
profile = M3UAccountProfile.objects.get(
|
||||
id=profile_id,
|
||||
m3u_account=m3u_account,
|
||||
is_active=True
|
||||
)
|
||||
if profile.current_viewers < profile.max_streams or profile.max_streams == 0:
|
||||
return profile
|
||||
except M3UAccountProfile.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Update connection activity periodically
|
||||
if bytes_sent % (8192 * 10) == 0: # Every ~80KB
|
||||
connection_manager.update_connection_activity(
|
||||
content_type_name,
|
||||
str(content_uuid),
|
||||
client_id,
|
||||
bytes_sent=len(chunk)
|
||||
)
|
||||
# Find available profile based on user agent matching
|
||||
profiles = M3UAccountProfile.objects.filter(
|
||||
m3u_account=m3u_account,
|
||||
is_active=True
|
||||
).order_by('current_viewers')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{client_id}] Streaming error: {e}")
|
||||
finally:
|
||||
# Clean up connection when streaming ends
|
||||
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id)
|
||||
logger.info(f"[{client_id}] Connection cleaned up")
|
||||
for profile in profiles:
|
||||
# Check if profile matches user agent pattern
|
||||
if self._matches_user_agent_pattern(profile, user_agent):
|
||||
if profile.current_viewers < profile.max_streams or profile.max_streams == 0:
|
||||
return profile
|
||||
|
||||
# Build response with appropriate headers
|
||||
streaming_response = StreamingHttpResponse(
|
||||
stream_generator(),
|
||||
content_type=content_type_header,
|
||||
status=response.status_code
|
||||
)
|
||||
# Fallback to default profile
|
||||
return profiles.filter(is_default=True).first()
|
||||
|
||||
# Copy important headers
|
||||
if content_length:
|
||||
streaming_response['Content-Length'] = content_length
|
||||
if content_range:
|
||||
streaming_response['Content-Range'] = content_range
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting M3U profile: {e}")
|
||||
return None
|
||||
|
||||
# Add CORS and caching headers
|
||||
streaming_response['Accept-Ranges'] = 'bytes'
|
||||
streaming_response['Access-Control-Allow-Origin'] = '*'
|
||||
streaming_response['Cache-Control'] = 'no-cache'
|
||||
def _matches_user_agent_pattern(self, profile, user_agent):
|
||||
"""Check if user agent matches profile pattern"""
|
||||
try:
|
||||
import re
|
||||
pattern = profile.search_pattern
|
||||
if pattern and user_agent:
|
||||
return bool(re.search(pattern, user_agent, re.IGNORECASE))
|
||||
return True # If no pattern, match all
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
logger.info(f"[{client_id}] Started streaming {content_type_name}: {content.name}")
|
||||
return streaming_response
|
||||
def _transform_url(self, content_obj, m3u_profile):
|
||||
"""Transform URL based on M3U profile settings"""
|
||||
try:
|
||||
import re
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[{client_id}] Request error: {e}")
|
||||
connection_manager.remove_connection(content_type_name, str(content_uuid), client_id)
|
||||
return JsonResponse(
|
||||
{"error": "Failed to connect to upstream server"},
|
||||
status=502
|
||||
)
|
||||
# Get URL from the content object's relations
|
||||
original_url = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{client_id}] Unexpected error: {e}")
|
||||
return JsonResponse(
|
||||
{"error": "Internal server error"},
|
||||
status=500
|
||||
)
|
||||
if hasattr(content_obj, 'm3u_relations'):
|
||||
# This is a Movie or Episode with relations
|
||||
relation = content_obj.m3u_relations.filter(
|
||||
m3u_account=m3u_profile.m3u_account
|
||||
).first()
|
||||
if relation:
|
||||
original_url = relation.url
|
||||
elif hasattr(content_obj, 'series'):
|
||||
# This is an Episode, get URL from episode relation
|
||||
from .models import M3UEpisodeRelation
|
||||
relation = M3UEpisodeRelation.objects.filter(
|
||||
episode=content_obj,
|
||||
m3u_account=m3u_profile.m3u_account
|
||||
).first()
|
||||
if relation:
|
||||
original_url = relation.url
|
||||
|
||||
if not original_url:
|
||||
logger.error("No URL found for content object")
|
||||
return None
|
||||
|
||||
search_pattern = m3u_profile.search_pattern
|
||||
replace_pattern = m3u_profile.replace_pattern
|
||||
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern)
|
||||
|
||||
if search_pattern and replace_pattern:
|
||||
transformed_url = re.sub(search_pattern, safe_replace_pattern, original_url)
|
||||
logger.debug(f"URL transformed from {original_url} to {transformed_url}")
|
||||
return transformed_url
|
||||
|
||||
return original_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error transforming URL: {e}")
|
||||
return None
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class VODPlaylistView(View):
|
||||
"""Generate M3U playlists for VOD content"""
|
||||
|
||||
def get(self, request, profile_id=None):
|
||||
"""Generate VOD playlist"""
|
||||
try:
|
||||
# Get profile if specified
|
||||
m3u_profile = None
|
||||
if profile_id:
|
||||
try:
|
||||
m3u_profile = M3UAccountProfile.objects.get(
|
||||
id=profile_id,
|
||||
is_active=True
|
||||
)
|
||||
except M3UAccountProfile.DoesNotExist:
|
||||
return HttpResponse("Profile not found", status=404)
|
||||
|
||||
# Generate playlist content
|
||||
playlist_content = self._generate_playlist(m3u_profile)
|
||||
|
||||
response = HttpResponse(playlist_content, content_type='application/vnd.apple.mpegurl')
|
||||
response['Content-Disposition'] = 'attachment; filename="vod_playlist.m3u8"'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating VOD playlist: {e}")
|
||||
return HttpResponse("Playlist generation error", status=500)
|
||||
|
||||
def _generate_playlist(self, m3u_profile=None):
|
||||
"""Generate M3U playlist content for VOD"""
|
||||
lines = ["#EXTM3U"]
|
||||
|
||||
# Add movies
|
||||
movies = Movie.objects.filter(is_active=True)
|
||||
if m3u_profile:
|
||||
movies = movies.filter(m3u_account=m3u_profile.m3u_account)
|
||||
|
||||
for movie in movies:
|
||||
profile_param = f"?profile={m3u_profile.id}" if m3u_profile else ""
|
||||
lines.append(f'#EXTINF:-1 tvg-id="{movie.tmdb_id}" group-title="Movies",{movie.title}')
|
||||
lines.append(f'/proxy/vod/movie/{movie.uuid}/{profile_param}')
|
||||
|
||||
# Add series
|
||||
series_list = Series.objects.filter(is_active=True)
|
||||
if m3u_profile:
|
||||
series_list = series_list.filter(m3u_account=m3u_profile.m3u_account)
|
||||
|
||||
for series in series_list:
|
||||
for episode in series.episodes.all():
|
||||
profile_param = f"?profile={m3u_profile.id}" if m3u_profile else ""
|
||||
episode_title = f"{series.title} - S{episode.season_number:02d}E{episode.episode_number:02d}"
|
||||
lines.append(f'#EXTINF:-1 tvg-id="{series.tmdb_id}" group-title="Series",{episode_title}')
|
||||
lines.append(f'/proxy/vod/episode/{episode.uuid}/{profile_param}')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(["POST"])
|
||||
def update_movie_position(request, movie_uuid):
|
||||
"""Update playback position for a movie"""
|
||||
return _update_position(request, Movie, movie_uuid, "movie")
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class VODPositionView(View):
|
||||
"""Handle VOD position updates"""
|
||||
|
||||
def post(self, request, content_id):
|
||||
"""Update playback position for VOD content"""
|
||||
try:
|
||||
import json
|
||||
data = json.loads(request.body)
|
||||
client_id = data.get('client_id')
|
||||
position = data.get('position', 0)
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(["POST"])
|
||||
def update_episode_position(request, episode_uuid):
|
||||
"""Update playback position for an episode"""
|
||||
return _update_position(request, Episode, episode_uuid, "episode")
|
||||
# Find the content object
|
||||
content_obj = None
|
||||
try:
|
||||
content_obj = Movie.objects.get(uuid=content_id)
|
||||
except Movie.DoesNotExist:
|
||||
try:
|
||||
content_obj = Episode.objects.get(uuid=content_id)
|
||||
except Episode.DoesNotExist:
|
||||
return JsonResponse({'error': 'Content not found'}, status=404)
|
||||
|
||||
# Here you could store the position in a model or cache
|
||||
# For now, just return success
|
||||
logger.info(f"Position update for {content_obj.__class__.__name__} {content_id}: {position}s")
|
||||
|
||||
def _update_position(request, model_class, content_uuid, content_type_name):
|
||||
"""Generic function to update playback position"""
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'content_id': str(content_id),
|
||||
'position': position
|
||||
})
|
||||
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
|
||||
client_id = request.data.get('client_id')
|
||||
position = request.data.get('position', 0)
|
||||
|
||||
if not client_id:
|
||||
return JsonResponse({"error": "Client ID required"}, status=400)
|
||||
|
||||
try:
|
||||
content = get_object_or_404(model_class, uuid=content_uuid)
|
||||
connection_manager = get_connection_manager()
|
||||
|
||||
# Update position in Redis
|
||||
success = connection_manager.update_connection_activity(
|
||||
content_type_name,
|
||||
str(content_uuid),
|
||||
client_id,
|
||||
position_seconds=position
|
||||
)
|
||||
|
||||
if not success:
|
||||
return JsonResponse({"error": "Connection not found"}, status=404)
|
||||
|
||||
return JsonResponse({"status": "success"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Position update error: {e}")
|
||||
return JsonResponse({"error": "Internal server error"}, status=500)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating VOD position: {e}")
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
|
|
|||
|
|
@ -1,58 +1,67 @@
|
|||
from django.contrib import admin
|
||||
from .models import Series, VODCategory, VODConnection, Movie, Episode
|
||||
from .models import (
|
||||
Series, VODCategory, Movie, Episode,
|
||||
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
|
||||
)
|
||||
|
||||
|
||||
@admin.register(VODCategory)
|
||||
class VODCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'category_type', 'm3u_account', 'created_at']
|
||||
list_filter = ['category_type', 'm3u_account', 'created_at']
|
||||
list_display = ['name', 'category_type', 'created_at']
|
||||
list_filter = ['category_type', 'created_at']
|
||||
search_fields = ['name']
|
||||
|
||||
|
||||
@admin.register(Series)
|
||||
class SeriesAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'year', 'genre', 'm3u_account', 'created_at']
|
||||
list_filter = ['m3u_account', 'category', 'year', 'created_at']
|
||||
search_fields = ['name', 'description', 'series_id']
|
||||
list_display = ['name', 'year', 'genre', 'created_at']
|
||||
list_filter = ['year', 'created_at']
|
||||
search_fields = ['name', 'description', 'tmdb_id', 'imdb_id']
|
||||
readonly_fields = ['uuid', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
@admin.register(Movie)
|
||||
class MovieAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'year', 'genre', 'duration', 'm3u_account', 'created_at']
|
||||
list_filter = ['m3u_account', 'category', 'year', 'created_at']
|
||||
search_fields = ['name', 'description', 'stream_id']
|
||||
list_display = ['name', 'year', 'genre', 'duration', 'created_at']
|
||||
list_filter = ['year', 'created_at']
|
||||
search_fields = ['name', 'description', 'tmdb_id', 'imdb_id']
|
||||
readonly_fields = ['uuid', 'created_at', 'updated_at']
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('category', 'logo', 'm3u_account')
|
||||
return super().get_queryset(request).select_related('logo')
|
||||
|
||||
|
||||
@admin.register(Episode)
|
||||
class EpisodeAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'm3u_account', 'created_at']
|
||||
list_filter = ['m3u_account', 'series', 'season_number', 'created_at']
|
||||
search_fields = ['name', 'description', 'stream_id', 'series__name']
|
||||
list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'created_at']
|
||||
list_filter = ['series', 'season_number', 'created_at']
|
||||
search_fields = ['name', 'description', 'series__name']
|
||||
readonly_fields = ['uuid', 'created_at', 'updated_at']
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('series', 'm3u_account')
|
||||
return super().get_queryset(request).select_related('series')
|
||||
|
||||
|
||||
@admin.register(VODConnection)
|
||||
class VODConnectionAdmin(admin.ModelAdmin):
|
||||
list_display = ['get_content_name', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds']
|
||||
list_filter = ['connected_at', 'last_activity']
|
||||
search_fields = ['client_ip', 'client_id']
|
||||
readonly_fields = ['connected_at']
|
||||
@admin.register(M3UMovieRelation)
|
||||
class M3UMovieRelationAdmin(admin.ModelAdmin):
|
||||
list_display = ['movie', 'm3u_account', 'category', 'stream_id', 'created_at']
|
||||
list_filter = ['m3u_account', 'category', 'created_at']
|
||||
search_fields = ['movie__name', 'm3u_account__name', 'stream_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
def get_content_name(self, obj):
|
||||
if obj.content_object:
|
||||
return obj.content_object.name
|
||||
elif obj.vod:
|
||||
return obj.vod.name
|
||||
return "Unknown"
|
||||
get_content_name.short_description = "Content"
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('content_object', 'm3u_profile')
|
||||
@admin.register(M3USeriesRelation)
|
||||
class M3USeriesRelationAdmin(admin.ModelAdmin):
|
||||
list_display = ['series', 'm3u_account', 'category', 'external_series_id', 'created_at']
|
||||
list_filter = ['m3u_account', 'category', 'created_at']
|
||||
search_fields = ['series__name', 'm3u_account__name', 'external_series_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
@admin.register(M3UEpisodeRelation)
|
||||
class M3UEpisodeRelationAdmin(admin.ModelAdmin):
|
||||
list_display = ['episode', 'm3u_account', 'stream_id', 'created_at']
|
||||
list_filter = ['m3u_account', 'created_at']
|
||||
search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from .api_views import (
|
|||
EpisodeViewSet,
|
||||
SeriesViewSet,
|
||||
VODCategoryViewSet,
|
||||
VODConnectionViewSet,
|
||||
)
|
||||
|
||||
app_name = 'vod'
|
||||
|
|
@ -15,6 +14,5 @@ router.register(r'movies', MovieViewSet, basename='movie')
|
|||
router.register(r'episodes', EpisodeViewSet, basename='episode')
|
||||
router.register(r'series', SeriesViewSet, basename='series')
|
||||
router.register(r'categories', VODCategoryViewSet, basename='vodcategory')
|
||||
router.register(r'connections', VODConnectionViewSet, basename='vodconnection')
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
|
|
|||
|
|
@ -10,15 +10,19 @@ from apps.accounts.permissions import (
|
|||
Authenticated,
|
||||
permission_classes_by_action,
|
||||
)
|
||||
from .models import Series, VODCategory, VODConnection, Movie, Episode
|
||||
from .models import (
|
||||
Series, VODCategory, Movie, Episode,
|
||||
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
|
||||
)
|
||||
from .serializers import (
|
||||
MovieSerializer,
|
||||
EpisodeSerializer,
|
||||
SeriesSerializer,
|
||||
VODCategorySerializer,
|
||||
VODConnectionSerializer
|
||||
M3UMovieRelationSerializer,
|
||||
M3USeriesRelationSerializer,
|
||||
M3UEpisodeRelationSerializer
|
||||
)
|
||||
from core.xtream_codes import Client as XtreamCodesClient
|
||||
from .tasks import refresh_series_episodes
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
|
@ -28,15 +32,14 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class MovieFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(lookup_expr="icontains")
|
||||
category = django_filters.CharFilter(field_name="category__name", lookup_expr="icontains")
|
||||
m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
|
||||
m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id")
|
||||
year = django_filters.NumberFilter()
|
||||
year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte")
|
||||
year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte")
|
||||
|
||||
class Meta:
|
||||
model = Movie
|
||||
fields = ['name', 'category', 'm3u_account', 'year']
|
||||
fields = ['name', 'm3u_account', 'year']
|
||||
|
||||
|
||||
class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
|
@ -57,84 +60,133 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
return Movie.objects.select_related(
|
||||
'category', 'logo', 'm3u_account'
|
||||
).filter(m3u_account__is_active=True)
|
||||
# Only return movies that have active M3U relations
|
||||
return Movie.objects.filter(
|
||||
m3u_relations__m3u_account__is_active=True
|
||||
).distinct().select_related('logo').prefetch_related('m3u_relations__m3u_account')
|
||||
|
||||
def _extract_year(self, date_string):
|
||||
"""Extract year from date string"""
|
||||
if not date_string:
|
||||
return None
|
||||
try:
|
||||
return int(date_string.split('-')[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
@action(detail=True, methods=['get'], url_path='providers')
|
||||
def get_providers(self, request, pk=None):
|
||||
"""Get all providers (M3U accounts) that have this movie"""
|
||||
movie = self.get_object()
|
||||
relations = M3UMovieRelation.objects.filter(
|
||||
movie=movie,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account', 'category')
|
||||
|
||||
def _convert_duration_to_minutes(self, duration_secs):
|
||||
"""Convert duration from seconds to minutes"""
|
||||
if not duration_secs:
|
||||
return 0
|
||||
try:
|
||||
return int(duration_secs) // 60
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
serializer = M3UMovieRelationSerializer(relations, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='provider-info')
|
||||
def provider_info(self, request, pk=None):
|
||||
"""Get detailed movie information from the original provider"""
|
||||
logger.debug(f"MovieViewSet.provider_info called for movie ID: {pk}")
|
||||
movie = self.get_object()
|
||||
logger.debug(f"Retrieved movie: {movie.name} (ID: {movie.id})")
|
||||
|
||||
if not movie.m3u_account:
|
||||
# Get the first active relation
|
||||
relation = M3UMovieRelation.objects.filter(
|
||||
movie=movie,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account').first()
|
||||
|
||||
if not relation:
|
||||
return Response(
|
||||
{'error': 'No M3U account associated with this movie'},
|
||||
{'error': 'No active M3U account associated with this movie'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if detailed data has been fetched
|
||||
custom_props = relation.custom_properties or {}
|
||||
detailed_fetched = custom_props.get('detailed_fetched', False)
|
||||
|
||||
# If detailed data hasn't been fetched, fetch it now
|
||||
if not detailed_fetched:
|
||||
try:
|
||||
from core.xtream_codes import Client as XtreamCodesClient
|
||||
|
||||
with XtreamCodesClient(
|
||||
server_url=relation.m3u_account.server_url,
|
||||
username=relation.m3u_account.username,
|
||||
password=relation.m3u_account.password,
|
||||
user_agent=relation.m3u_account.get_user_agent().user_agent
|
||||
) as client:
|
||||
# Get detailed VOD info from provider
|
||||
vod_info = client.get_vod_info(relation.stream_id)
|
||||
|
||||
if vod_info and 'info' in vod_info:
|
||||
# Update movie with detailed info
|
||||
info = vod_info.get('info', {})
|
||||
movie_data = vod_info.get('movie_data', {})
|
||||
|
||||
movie.description = info.get('plot', movie.description)
|
||||
movie.rating = info.get('rating', movie.rating)
|
||||
movie.genre = info.get('genre', movie.genre)
|
||||
movie.duration = self._convert_duration_to_minutes(info.get('duration_secs'))
|
||||
if info.get('releasedate'):
|
||||
movie.year = self._extract_year(info.get('releasedate'))
|
||||
movie.save()
|
||||
|
||||
# Update relation with detailed data
|
||||
custom_props['detailed_info'] = info
|
||||
custom_props['movie_data'] = movie_data
|
||||
custom_props['detailed_fetched'] = True
|
||||
relation.custom_properties = custom_props
|
||||
relation.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching detailed VOD info for movie {pk}: {str(e)}")
|
||||
# Continue with available data
|
||||
|
||||
try:
|
||||
# Create XtreamCodes client
|
||||
from core.xtream_codes import Client as XtreamCodesClient
|
||||
|
||||
# Create XtreamCodes client for final response (minimal call)
|
||||
with XtreamCodesClient(
|
||||
server_url=movie.m3u_account.server_url,
|
||||
username=movie.m3u_account.username,
|
||||
password=movie.m3u_account.password,
|
||||
user_agent=movie.m3u_account.user_agent
|
||||
server_url=relation.m3u_account.server_url,
|
||||
username=relation.m3u_account.username,
|
||||
password=relation.m3u_account.password,
|
||||
user_agent=relation.m3u_account.get_user_agent().user_agent
|
||||
) as client:
|
||||
# Get detailed VOD info from provider
|
||||
logger.debug(f"Fetching VOD info for movie {movie.id} with stream ID {movie.stream_id} from provider")
|
||||
vod_info = client.get_vod_info(movie.stream_id)
|
||||
|
||||
if not vod_info or 'info' not in vod_info:
|
||||
return Response(
|
||||
{'error': 'No information available from provider'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
# Use cached detailed data if available
|
||||
custom_props = relation.custom_properties or {}
|
||||
info = custom_props.get('detailed_info', {})
|
||||
movie_data = custom_props.get('movie_data', {})
|
||||
|
||||
# Extract and format the info
|
||||
info = vod_info.get('info', {})
|
||||
movie_data = vod_info.get('movie_data', {})
|
||||
# If no cached data, use basic data
|
||||
if not info:
|
||||
basic_data = custom_props.get('basic_data', {})
|
||||
info = {
|
||||
'name': movie.name,
|
||||
'plot': movie.description,
|
||||
'rating': movie.rating,
|
||||
'genre': movie.genre,
|
||||
}
|
||||
movie_data = {
|
||||
'container_extension': basic_data.get('container_extension', 'mp4'),
|
||||
'added': basic_data.get('added', ''),
|
||||
}
|
||||
|
||||
# Build response with all available fields
|
||||
# Build response with available data
|
||||
response_data = {
|
||||
'id': movie.id,
|
||||
'stream_id': movie.stream_id,
|
||||
'stream_id': relation.stream_id,
|
||||
'name': info.get('name', movie.name),
|
||||
'o_name': info.get('o_name', ''),
|
||||
'description': info.get('description', info.get('plot', '')),
|
||||
'plot': info.get('plot', info.get('description', '')),
|
||||
'year': self._extract_year(info.get('releasedate', '')),
|
||||
'description': info.get('description', info.get('plot', movie.description)),
|
||||
'plot': info.get('plot', info.get('description', movie.description)),
|
||||
'year': movie.year or self._extract_year(info.get('releasedate', '')),
|
||||
'release_date': info.get('release_date', ''),
|
||||
'releasedate': info.get('releasedate', ''),
|
||||
'genre': info.get('genre', ''),
|
||||
'genre': info.get('genre', movie.genre),
|
||||
'director': info.get('director', ''),
|
||||
'actors': info.get('actors', info.get('cast', '')),
|
||||
'cast': info.get('cast', info.get('actors', '')),
|
||||
'country': info.get('country', ''),
|
||||
'rating': info.get('rating', 0),
|
||||
'tmdb_id': info.get('tmdb_id', ''),
|
||||
'rating': info.get('rating', movie.rating or 0),
|
||||
'tmdb_id': info.get('tmdb_id', movie.tmdb_id or ''),
|
||||
'youtube_trailer': info.get('youtube_trailer', ''),
|
||||
'duration': self._convert_duration_to_minutes(info.get('duration_secs', 0)),
|
||||
'duration_secs': info.get('duration_secs', 0),
|
||||
'duration': movie.duration or self._convert_duration_to_minutes(info.get('duration_secs', 0)),
|
||||
'duration_secs': info.get('duration_secs', (movie.duration or 0) * 60),
|
||||
'episode_run_time': info.get('episode_run_time', 0),
|
||||
'age': info.get('age', ''),
|
||||
'backdrop_path': info.get('backdrop_path', []),
|
||||
|
|
@ -149,12 +201,18 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'direct_source': movie_data.get('direct_source', ''),
|
||||
'category_id': movie_data.get('category_id', ''),
|
||||
'added': movie_data.get('added', ''),
|
||||
# Include M3U account info
|
||||
'm3u_account': {
|
||||
'id': relation.m3u_account.id,
|
||||
'name': relation.m3u_account.name,
|
||||
'account_type': relation.m3u_account.account_type
|
||||
}
|
||||
}
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching VOD info from provider for movie {pk}: {str(e)}")
|
||||
logger.error(f"Error in provider info for movie {pk}: {str(e)}")
|
||||
return Response(
|
||||
{'error': f'Failed to fetch information from provider: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
|
@ -213,9 +271,46 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
return Series.objects.select_related(
|
||||
'category', 'logo', 'm3u_account'
|
||||
).prefetch_related('episodes').filter(m3u_account__is_active=True)
|
||||
# Only return series that have active M3U relations
|
||||
return Series.objects.filter(
|
||||
m3u_relations__m3u_account__is_active=True
|
||||
).distinct().select_related('logo').prefetch_related('episodes', 'm3u_relations__m3u_account')
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='providers')
|
||||
def get_providers(self, request, pk=None):
|
||||
"""Get all providers (M3U accounts) that have this series"""
|
||||
series = self.get_object()
|
||||
relations = M3USeriesRelation.objects.filter(
|
||||
series=series,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account', 'category')
|
||||
|
||||
serializer = M3USeriesRelationSerializer(relations, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='episodes')
|
||||
def get_episodes(self, request, pk=None):
|
||||
"""Get episodes for this series with provider information"""
|
||||
series = self.get_object()
|
||||
episodes = Episode.objects.filter(series=series).prefetch_related(
|
||||
'm3u_relations__m3u_account'
|
||||
).order_by('season_number', 'episode_number')
|
||||
|
||||
episodes_data = []
|
||||
for episode in episodes:
|
||||
episode_serializer = EpisodeSerializer(episode)
|
||||
episode_data = episode_serializer.data
|
||||
|
||||
# Add provider information
|
||||
relations = M3UEpisodeRelation.objects.filter(
|
||||
episode=episode,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account')
|
||||
|
||||
episode_data['providers'] = M3UEpisodeRelationSerializer(relations, many=True).data
|
||||
episodes_data.append(episode_data)
|
||||
|
||||
return Response(episodes_data)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='provider-info')
|
||||
def series_info(self, request, pk=None):
|
||||
|
|
@ -224,9 +319,15 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
series = self.get_object()
|
||||
logger.debug(f"Retrieved series: {series.name} (ID: {series.id})")
|
||||
|
||||
if not series.m3u_account:
|
||||
# Get the first active relation
|
||||
relation = M3USeriesRelation.objects.filter(
|
||||
series=series,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account').first()
|
||||
|
||||
if not relation:
|
||||
return Response(
|
||||
{'error': 'No M3U account associated with this series'},
|
||||
{'error': 'No active M3U account associated with this series'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
|
@ -236,28 +337,36 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
refresh_interval_hours = int(request.query_params.get("refresh_interval", 24)) # Default to 24 hours
|
||||
|
||||
now = timezone.now()
|
||||
last_refreshed = series.last_episode_refresh
|
||||
last_refreshed = relation.last_episode_refresh
|
||||
|
||||
# Force refresh if episodes have never been populated (last_episode_refresh is null)
|
||||
if last_refreshed is None:
|
||||
# Check if detailed data has been fetched
|
||||
custom_props = relation.custom_properties or {}
|
||||
episodes_fetched = custom_props.get('episodes_fetched', False)
|
||||
detailed_fetched = custom_props.get('detailed_fetched', False)
|
||||
|
||||
# Force refresh if episodes have never been fetched or if forced
|
||||
if not episodes_fetched or not detailed_fetched or force_refresh:
|
||||
force_refresh = True
|
||||
logger.debug(f"Series {series.id} has never been refreshed, forcing refresh")
|
||||
else:
|
||||
logger.debug(f"Series {series.id} last refreshed at {last_refreshed}, now is {now}")
|
||||
logger.debug(f"Series {series.id} needs detailed/episode refresh, forcing refresh")
|
||||
elif last_refreshed and (now - last_refreshed) > timedelta(hours=refresh_interval_hours):
|
||||
force_refresh = True
|
||||
logger.debug(f"Series {series.id} refresh interval exceeded, forcing refresh")
|
||||
|
||||
if force_refresh or (last_refreshed and (now - last_refreshed) > timedelta(hours=refresh_interval_hours)):
|
||||
if force_refresh:
|
||||
logger.debug(f"Refreshing series {series.id} data from provider")
|
||||
# Use existing refresh logic
|
||||
# Use existing refresh logic with external_series_id
|
||||
from .tasks import refresh_series_episodes
|
||||
account = series.m3u_account
|
||||
account = relation.m3u_account
|
||||
if account and account.is_active:
|
||||
refresh_series_episodes(account, series, series.series_id)
|
||||
refresh_series_episodes(account, series, relation.external_series_id)
|
||||
series.refresh_from_db() # Reload from database after refresh
|
||||
relation.refresh_from_db() # Reload relation too
|
||||
|
||||
# Return the database data (which should now be fresh)
|
||||
custom_props = relation.custom_properties or {}
|
||||
response_data = {
|
||||
'id': series.id,
|
||||
'series_id': series.series_id,
|
||||
'series_id': relation.external_series_id,
|
||||
'name': series.name,
|
||||
'description': series.description,
|
||||
'year': series.year,
|
||||
|
|
@ -265,25 +374,27 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'rating': series.rating,
|
||||
'tmdb_id': series.tmdb_id,
|
||||
'imdb_id': series.imdb_id,
|
||||
'category_id': series.category.id if series.category else None,
|
||||
'category_name': series.category.name if series.category else None,
|
||||
'category_id': relation.category.id if relation.category else None,
|
||||
'category_name': relation.category.name if relation.category else None,
|
||||
'cover': {
|
||||
'id': series.logo.id,
|
||||
'url': series.logo.url,
|
||||
'name': series.logo.name,
|
||||
} if series.logo else None,
|
||||
'last_refreshed': series.updated_at,
|
||||
'custom_properties': series.custom_properties or {},
|
||||
'custom_properties': custom_props,
|
||||
'm3u_account': {
|
||||
'id': series.m3u_account.id,
|
||||
'name': series.m3u_account.name,
|
||||
'account_type': series.m3u_account.account_type
|
||||
} if series.m3u_account else None,
|
||||
'id': relation.m3u_account.id,
|
||||
'name': relation.m3u_account.name,
|
||||
'account_type': relation.m3u_account.account_type
|
||||
},
|
||||
'episodes_fetched': custom_props.get('episodes_fetched', False),
|
||||
'detailed_fetched': custom_props.get('detailed_fetched', False)
|
||||
}
|
||||
|
||||
# Always include episodes for series info
|
||||
# Always include episodes for series info if they've been fetched
|
||||
include_episodes = request.query_params.get('include_episodes', 'true').lower() == 'true'
|
||||
if include_episodes:
|
||||
if include_episodes and custom_props.get('episodes_fetched', False):
|
||||
logger.debug(f"Including episodes for series {series.id}")
|
||||
episodes_by_season = {}
|
||||
for episode in series.episodes.all().order_by('season_number', 'episode_number'):
|
||||
|
|
@ -291,6 +402,12 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
if season_key not in episodes_by_season:
|
||||
episodes_by_season[season_key] = []
|
||||
|
||||
# Get episode relation for additional data
|
||||
episode_relation = M3UEpisodeRelation.objects.filter(
|
||||
episode=episode,
|
||||
m3u_account=relation.m3u_account
|
||||
).first()
|
||||
|
||||
episode_data = {
|
||||
'id': episode.id,
|
||||
'uuid': episode.uuid,
|
||||
|
|
@ -303,8 +420,8 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
'plot': episode.description,
|
||||
'duration': episode.duration,
|
||||
'rating': episode.rating,
|
||||
'movie_image': episode.custom_properties.get('info', {}).get('movie_image') if episode.custom_properties else None,
|
||||
'container_extension': episode.container_extension,
|
||||
'movie_image': episode_relation.custom_properties.get('info', {}).get('movie_image') if episode_relation and episode_relation.custom_properties else None,
|
||||
'container_extension': episode_relation.container_extension if episode_relation else 'mp4',
|
||||
'type': 'episode',
|
||||
'series': {
|
||||
'id': series.id,
|
||||
|
|
@ -315,6 +432,9 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
|
||||
response_data['episodes'] = episodes_by_season
|
||||
logger.debug(f"Added {len(episodes_by_season)} seasons of episodes to response")
|
||||
elif include_episodes:
|
||||
# Episodes not yet fetched, include empty episodes list
|
||||
response_data['episodes'] = {}
|
||||
|
||||
logger.debug(f"Returning series info response for series {series.id}")
|
||||
return Response(response_data)
|
||||
|
|
@ -352,21 +472,3 @@ class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
|
||||
class VODConnectionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for monitoring VOD connections"""
|
||||
queryset = VODConnection.objects.all()
|
||||
serializer_class = VODConnectionSerializer
|
||||
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||
ordering = ['-connected_at']
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
return VODConnection.objects.select_related('m3u_profile')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-05 20:40
|
||||
# Generated by Django 5.2.4 on 2025-08-07 17:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
|
@ -10,27 +10,33 @@ class Migration(migrations.Migration):
|
|||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'),
|
||||
('dispatcharr_channels', '0024_channelgroupm3uaccount_enable_vod_sync'),
|
||||
('m3u', '0012_alter_m3uaccount_refresh_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VODCategory',
|
||||
name='Movie',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('year', models.IntegerField(blank=True, null=True)),
|
||||
('rating', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('genre', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)),
|
||||
('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vod_categories', to='m3u.m3uaccount')),
|
||||
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VOD Category',
|
||||
'verbose_name_plural': 'VOD Categories',
|
||||
'verbose_name': 'Movie',
|
||||
'verbose_name_plural': 'Movies',
|
||||
'ordering': ['name'],
|
||||
'unique_together': {('name', 'm3u_account', 'category_type')},
|
||||
'unique_together': {('name', 'year', 'imdb_id'), ('name', 'year', 'tmdb_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
|
|
@ -43,42 +49,17 @@ class Migration(migrations.Migration):
|
|||
('year', models.IntegerField(blank=True, null=True)),
|
||||
('rating', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('genre', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)),
|
||||
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
|
||||
('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)),
|
||||
('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_episode_refresh', models.DateTimeField(blank=True, help_text='Last time episodes were refreshed', null=True)),
|
||||
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')),
|
||||
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series', to='m3u.m3uaccount')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Series',
|
||||
'verbose_name_plural': 'Series',
|
||||
'ordering': ['name'],
|
||||
'unique_together': {('series_id', 'm3u_account')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VODConnection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('client_id', models.CharField(max_length=255)),
|
||||
('client_ip', models.GenericIPAddressField()),
|
||||
('user_agent', models.TextField(blank=True, null=True)),
|
||||
('connected_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_activity', models.DateTimeField(auto_now=True)),
|
||||
('bytes_sent', models.BigIntegerField(default=0)),
|
||||
('position_seconds', models.IntegerField(default=0, help_text='Current playback position')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('m3u_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vod_connections', to='m3u.m3uaccountprofile')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VOD Connection',
|
||||
'verbose_name_plural': 'VOD Connections',
|
||||
'unique_together': {('name', 'year', 'imdb_id'), ('name', 'year', 'tmdb_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
|
|
@ -93,52 +74,91 @@ class Migration(migrations.Migration):
|
|||
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)),
|
||||
('season_number', models.IntegerField(blank=True, null=True)),
|
||||
('episode_number', models.IntegerField(blank=True, null=True)),
|
||||
('url', models.URLField(max_length=2048)),
|
||||
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
|
||||
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
|
||||
('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)),
|
||||
('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='m3u.m3uaccount')),
|
||||
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Episode',
|
||||
'verbose_name_plural': 'Episodes',
|
||||
'ordering': ['series__name', 'season_number', 'episode_number'],
|
||||
'unique_together': {('stream_id', 'm3u_account')},
|
||||
'unique_together': {('series', 'season_number', 'episode_number')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Movie',
|
||||
name='VODCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('year', models.IntegerField(blank=True, null=True)),
|
||||
('rating', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('genre', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)),
|
||||
('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VOD Category',
|
||||
'verbose_name_plural': 'VOD Categories',
|
||||
'ordering': ['name'],
|
||||
'unique_together': {('name', 'category_type')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='M3UEpisodeRelation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField(max_length=2048)),
|
||||
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
|
||||
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
|
||||
('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)),
|
||||
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')),
|
||||
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movies', to='m3u.m3uaccount')),
|
||||
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.episode')),
|
||||
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='m3u.m3uaccount')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'M3U Episode Relation',
|
||||
'verbose_name_plural': 'M3U Episode Relations',
|
||||
'unique_together': {('m3u_account', 'stream_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='M3USeriesRelation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('external_series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)),
|
||||
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_episode_refresh', models.DateTimeField(blank=True, help_text='Last time episodes were refreshed', null=True)),
|
||||
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series_relations', to='m3u.m3uaccount')),
|
||||
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.series')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Movie',
|
||||
'verbose_name_plural': 'Movies',
|
||||
'ordering': ['name'],
|
||||
'unique_together': {('stream_id', 'm3u_account')},
|
||||
'verbose_name': 'M3U Series Relation',
|
||||
'verbose_name_plural': 'M3U Series Relations',
|
||||
'unique_together': {('m3u_account', 'external_series_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='M3UMovieRelation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField(max_length=2048)),
|
||||
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
|
||||
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movie_relations', to='m3u.m3uaccount')),
|
||||
('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.movie')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'M3U Movie Relation',
|
||||
'verbose_name_plural': 'M3U Movie Relations',
|
||||
'unique_together': {('m3u_account', 'stream_id')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,21 +22,14 @@ class VODCategory(models.Model):
|
|||
default='movie',
|
||||
help_text="Type of content this category contains"
|
||||
)
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='vod_categories',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "VOD Category"
|
||||
verbose_name_plural = "VOD Categories"
|
||||
verbose_name = 'VOD Category'
|
||||
verbose_name_plural = 'VOD Categories'
|
||||
ordering = ['name']
|
||||
unique_together = ['name', 'm3u_account', 'category_type']
|
||||
unique_together = [('name', 'category_type')]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_category_type_display()})"
|
||||
|
|
@ -51,28 +44,27 @@ class Series(models.Model):
|
|||
rating = models.CharField(max_length=10, blank=True, null=True)
|
||||
genre = models.CharField(max_length=255, blank=True, null=True)
|
||||
logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='series'
|
||||
)
|
||||
series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider")
|
||||
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata")
|
||||
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata")
|
||||
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
|
||||
|
||||
# Metadata IDs for deduplication
|
||||
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
|
||||
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Series"
|
||||
verbose_name_plural = "Series"
|
||||
verbose_name = 'Series'
|
||||
verbose_name_plural = 'Series'
|
||||
ordering = ['name']
|
||||
unique_together = ['series_id', 'm3u_account']
|
||||
# Create unique constraint for deduplication
|
||||
unique_together = [
|
||||
('name', 'year', 'tmdb_id'),
|
||||
('name', 'year', 'imdb_id'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.year or 'Unknown'})"
|
||||
year_str = f" ({self.year})" if self.year else ""
|
||||
return f"{self.name}{year_str}"
|
||||
|
||||
|
||||
class Movie(models.Model):
|
||||
|
|
@ -84,43 +76,28 @@ class Movie(models.Model):
|
|||
rating = models.CharField(max_length=10, blank=True, null=True)
|
||||
genre = models.CharField(max_length=255, blank=True, null=True)
|
||||
duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes")
|
||||
|
||||
# Streaming information
|
||||
url = models.URLField(max_length=2048)
|
||||
logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
|
||||
# M3U relationship
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='movies'
|
||||
)
|
||||
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
|
||||
container_extension = models.CharField(max_length=10, blank=True, null=True)
|
||||
|
||||
# Metadata IDs
|
||||
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata")
|
||||
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata")
|
||||
|
||||
# Additional properties
|
||||
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
|
||||
# Metadata IDs for deduplication
|
||||
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
|
||||
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Movie"
|
||||
verbose_name_plural = "Movies"
|
||||
verbose_name = 'Movie'
|
||||
verbose_name_plural = 'Movies'
|
||||
ordering = ['name']
|
||||
unique_together = ['stream_id', 'm3u_account']
|
||||
# Create unique constraint for deduplication
|
||||
unique_together = [
|
||||
('name', 'year', 'tmdb_id'),
|
||||
('name', 'year', 'imdb_id'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.year or 'Unknown'})"
|
||||
|
||||
def get_stream_url(self):
|
||||
"""Generate the proxied stream URL for this movie"""
|
||||
return f"/proxy/vod/movie/{self.uuid}"
|
||||
year_str = f" ({self.year})" if self.year else ""
|
||||
return f"{self.name}{year_str}"
|
||||
|
||||
|
||||
class Episode(models.Model):
|
||||
|
|
@ -137,75 +114,108 @@ class Episode(models.Model):
|
|||
season_number = models.IntegerField(blank=True, null=True)
|
||||
episode_number = models.IntegerField(blank=True, null=True)
|
||||
|
||||
# Streaming information
|
||||
url = models.URLField(max_length=2048)
|
||||
|
||||
# M3U relationship
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='episodes'
|
||||
)
|
||||
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
|
||||
container_extension = models.CharField(max_length=10, blank=True, null=True)
|
||||
|
||||
# Metadata IDs
|
||||
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata")
|
||||
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata")
|
||||
|
||||
# Additional properties
|
||||
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
|
||||
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
|
||||
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Episode"
|
||||
verbose_name_plural = "Episodes"
|
||||
verbose_name = 'Episode'
|
||||
verbose_name_plural = 'Episodes'
|
||||
ordering = ['series__name', 'season_number', 'episode_number']
|
||||
unique_together = ['stream_id', 'm3u_account']
|
||||
unique_together = [
|
||||
('series', 'season_number', 'episode_number'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
season_ep = f"S{self.season_number:02d}E{self.episode_number:02d}" if self.season_number and self.episode_number else ""
|
||||
return f"{self.series.name} {season_ep} - {self.name}"
|
||||
season_ep = f"S{self.season_number or 0:02d}E{self.episode_number or 0:02d}"
|
||||
return f"{self.series.name} - {season_ep} - {self.name}"
|
||||
|
||||
def get_stream_url(self):
|
||||
"""Generate the proxied stream URL for this episode"""
|
||||
return f"/proxy/vod/episode/{self.uuid}"
|
||||
|
||||
class VODConnection(models.Model):
|
||||
"""Track active VOD connections for connection limit management"""
|
||||
# Use generic foreign key to support both Movie and Episode
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
# New relation models to link M3U accounts with VOD content
|
||||
|
||||
m3u_profile = models.ForeignKey(
|
||||
'm3u.M3UAccountProfile',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='vod_connections'
|
||||
)
|
||||
client_id = models.CharField(max_length=255)
|
||||
client_ip = models.GenericIPAddressField()
|
||||
user_agent = models.TextField(blank=True, null=True)
|
||||
connected_at = models.DateTimeField(auto_now_add=True)
|
||||
last_activity = models.DateTimeField(auto_now=True)
|
||||
bytes_sent = models.BigIntegerField(default=0)
|
||||
position_seconds = models.IntegerField(default=0, help_text="Current playback position")
|
||||
class M3USeriesRelation(models.Model):
|
||||
"""Links M3U accounts to Series with provider-specific information"""
|
||||
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='series_relations')
|
||||
series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='m3u_relations')
|
||||
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
|
||||
# Provider-specific fields - renamed to avoid clash with series ForeignKey
|
||||
external_series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider")
|
||||
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data")
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "VOD Connection"
|
||||
verbose_name_plural = "VOD Connections"
|
||||
verbose_name = 'M3U Series Relation'
|
||||
verbose_name_plural = 'M3U Series Relations'
|
||||
unique_together = [('m3u_account', 'external_series_id')]
|
||||
|
||||
def __str__(self):
|
||||
content_name = getattr(self.content_object, 'name', 'Unknown') if self.content_object else 'Unknown'
|
||||
return f"{content_name} - {self.client_ip} ({self.client_id})"
|
||||
return f"{self.m3u_account.name} - {self.series.name}"
|
||||
|
||||
def update_activity(self, bytes_sent=0, position=0):
|
||||
"""Update connection activity"""
|
||||
self.last_activity = timezone.now()
|
||||
if bytes_sent:
|
||||
self.bytes_sent += bytes_sent
|
||||
if position:
|
||||
self.position_seconds = position
|
||||
self.save(update_fields=['last_activity', 'bytes_sent', 'position_seconds'])
|
||||
|
||||
class M3UMovieRelation(models.Model):
|
||||
"""Links M3U accounts to Movies with provider-specific information"""
|
||||
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='movie_relations')
|
||||
movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='m3u_relations')
|
||||
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
|
||||
# Streaming information (provider-specific)
|
||||
url = models.URLField(max_length=2048)
|
||||
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
|
||||
container_extension = models.CharField(max_length=10, blank=True, null=True)
|
||||
|
||||
# Provider-specific data
|
||||
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.")
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'M3U Movie Relation'
|
||||
verbose_name_plural = 'M3U Movie Relations'
|
||||
unique_together = [('m3u_account', 'stream_id')]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.m3u_account.name} - {self.movie.name}"
|
||||
|
||||
def get_stream_url(self):
|
||||
"""Get the full stream URL for this movie from this provider"""
|
||||
return self.url
|
||||
|
||||
|
||||
class M3UEpisodeRelation(models.Model):
|
||||
"""Links M3U accounts to Episodes with provider-specific information"""
|
||||
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations')
|
||||
episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations')
|
||||
|
||||
# Streaming information (provider-specific)
|
||||
url = models.URLField(max_length=2048)
|
||||
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
|
||||
container_extension = models.CharField(max_length=10, blank=True, null=True)
|
||||
|
||||
# Provider-specific data
|
||||
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.")
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'M3U Episode Relation'
|
||||
verbose_name_plural = 'M3U Episode Relations'
|
||||
unique_together = [('m3u_account', 'stream_id')]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.m3u_account.name} - {self.episode}"
|
||||
|
||||
def get_stream_url(self):
|
||||
"""Get the full stream URL for this episode from this provider"""
|
||||
return self.url
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Series, VODCategory, VODConnection, Movie, Episode
|
||||
from .models import (
|
||||
Series, VODCategory, Movie, Episode,
|
||||
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
|
||||
)
|
||||
from apps.channels.serializers import LogoSerializer
|
||||
from apps.m3u.serializers import M3UAccountSerializer
|
||||
|
||||
|
|
@ -14,8 +17,6 @@ class VODCategorySerializer(serializers.ModelSerializer):
|
|||
|
||||
class SeriesSerializer(serializers.ModelSerializer):
|
||||
logo = LogoSerializer(read_only=True)
|
||||
category = VODCategorySerializer(read_only=True)
|
||||
m3u_account = M3UAccountSerializer(read_only=True)
|
||||
episode_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -28,40 +29,59 @@ class SeriesSerializer(serializers.ModelSerializer):
|
|||
|
||||
class MovieSerializer(serializers.ModelSerializer):
|
||||
logo = LogoSerializer(read_only=True)
|
||||
category = VODCategorySerializer(read_only=True)
|
||||
m3u_account = M3UAccountSerializer(read_only=True)
|
||||
stream_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Movie
|
||||
fields = '__all__'
|
||||
|
||||
def get_stream_url(self, obj):
|
||||
return obj.get_stream_url()
|
||||
|
||||
|
||||
class EpisodeSerializer(serializers.ModelSerializer):
|
||||
logo = LogoSerializer(read_only=True)
|
||||
series = SeriesSerializer(read_only=True)
|
||||
m3u_account = M3UAccountSerializer(read_only=True)
|
||||
stream_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Episode
|
||||
fields = '__all__'
|
||||
|
||||
def get_stream_url(self, obj):
|
||||
return obj.get_stream_url()
|
||||
|
||||
|
||||
class VODConnectionSerializer(serializers.ModelSerializer):
|
||||
content_name = serializers.SerializerMethodField()
|
||||
class M3USeriesRelationSerializer(serializers.ModelSerializer):
|
||||
series = SeriesSerializer(read_only=True)
|
||||
category = VODCategorySerializer(read_only=True)
|
||||
m3u_account = M3UAccountSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VODConnection
|
||||
model = M3USeriesRelation
|
||||
fields = '__all__'
|
||||
|
||||
def get_content_name(self, obj):
|
||||
if obj.content_object:
|
||||
return getattr(obj.content_object, 'name', 'Unknown')
|
||||
return 'Unknown'
|
||||
|
||||
class M3UMovieRelationSerializer(serializers.ModelSerializer):
|
||||
movie = MovieSerializer(read_only=True)
|
||||
category = VODCategorySerializer(read_only=True)
|
||||
m3u_account = M3UAccountSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = M3UMovieRelation
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class M3UEpisodeRelationSerializer(serializers.ModelSerializer):
|
||||
episode = EpisodeSerializer(read_only=True)
|
||||
m3u_account = M3UAccountSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = M3UEpisodeRelation
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class EnhancedSeriesSerializer(serializers.ModelSerializer):
|
||||
"""Enhanced serializer for series with provider information"""
|
||||
logo = LogoSerializer(read_only=True)
|
||||
providers = M3USeriesRelationSerializer(source='m3u_relations', many=True, read_only=True)
|
||||
episode_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Series
|
||||
fields = '__all__'
|
||||
|
||||
def get_episode_count(self, obj):
|
||||
return obj.episodes.count()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,379 +1,508 @@
|
|||
import logging
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import Series, VODCategory, VODConnection, Movie, Episode
|
||||
from django.db import transaction
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.channels.models import Logo
|
||||
from core.xtream_codes import Client as XtreamCodesClient
|
||||
from .models import (
|
||||
VODCategory, Series, Movie, Episode,
|
||||
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
|
||||
)
|
||||
from apps.channels.models import Logo
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def refresh_vod_content(self, account_id):
|
||||
"""Refresh VOD content from XtreamCodes API"""
|
||||
@shared_task
|
||||
def refresh_vod_content(account_id):
|
||||
"""Refresh VOD content for an M3U account"""
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
account = M3UAccount.objects.get(id=account_id, is_active=True)
|
||||
|
||||
if account.account_type != M3UAccount.Types.XC:
|
||||
logger.warning(f"Account {account_id} is not XtreamCodes type")
|
||||
return
|
||||
logger.warning(f"VOD refresh called for non-XC account {account_id}")
|
||||
return "VOD refresh only available for XtreamCodes accounts"
|
||||
|
||||
# Get movies and series
|
||||
refresh_movies(account)
|
||||
refresh_series(account)
|
||||
logger.info(f"Starting VOD refresh for account {account.name}")
|
||||
|
||||
logger.info(f"Successfully refreshed VOD content for account {account_id}")
|
||||
with XtreamCodesClient(
|
||||
account.server_url,
|
||||
account.username,
|
||||
account.password,
|
||||
account.get_user_agent().user_agent
|
||||
) as client:
|
||||
|
||||
# Refresh movies
|
||||
refresh_movies(client, account)
|
||||
|
||||
# Refresh series
|
||||
refresh_series(client, account)
|
||||
|
||||
logger.info(f"VOD refresh completed for account {account.name}")
|
||||
return f"VOD refresh completed for account {account.name}"
|
||||
|
||||
except M3UAccount.DoesNotExist:
|
||||
logger.error(f"M3U Account {account_id} not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing VOD content for account {account_id}: {e}")
|
||||
logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}")
|
||||
return f"VOD refresh failed: {str(e)}"
|
||||
|
||||
|
||||
def extract_year_from_title(title):
|
||||
"""Extract year from movie title if present"""
|
||||
if not title:
|
||||
def refresh_movies(client, account):
|
||||
"""Refresh movie content - only basic list, no detailed calls"""
|
||||
logger.info(f"Refreshing movies for account {account.name}")
|
||||
|
||||
# Get movie categories
|
||||
categories = client.get_vod_categories()
|
||||
|
||||
for category_data in categories:
|
||||
category_name = category_data.get('category_name', 'Unknown')
|
||||
category_id = category_data.get('category_id')
|
||||
|
||||
# Get or create category
|
||||
category, created = VODCategory.objects.get_or_create(
|
||||
name=category_name,
|
||||
category_type='movie',
|
||||
defaults={'name': category_name}
|
||||
)
|
||||
|
||||
# Get movies in this category - only basic list
|
||||
movies = client.get_vod_streams(category_id)
|
||||
|
||||
for movie_data in movies:
|
||||
process_movie_basic(client, account, movie_data, category)
|
||||
|
||||
|
||||
def refresh_series(client, account):
|
||||
"""Refresh series content - only basic list, no detailed calls"""
|
||||
logger.info(f"Refreshing series for account {account.name}")
|
||||
|
||||
# Get series categories
|
||||
categories = client.get_series_categories()
|
||||
|
||||
for category_data in categories:
|
||||
category_name = category_data.get('category_name', 'Unknown')
|
||||
category_id = category_data.get('category_id')
|
||||
|
||||
# Get or create category
|
||||
category, created = VODCategory.objects.get_or_create(
|
||||
name=category_name,
|
||||
category_type='series',
|
||||
defaults={'name': category_name}
|
||||
)
|
||||
|
||||
# Get series in this category - only basic list
|
||||
series_list = client.get_series(category_id)
|
||||
|
||||
for series_data in series_list:
|
||||
process_series_basic(client, account, series_data, category)
|
||||
|
||||
|
||||
def process_movie_basic(client, account, movie_data, category):
|
||||
"""Process a single movie - basic info only, no detailed API call"""
|
||||
try:
|
||||
stream_id = movie_data.get('stream_id')
|
||||
name = movie_data.get('name', 'Unknown')
|
||||
|
||||
# Extract all available metadata from the basic data
|
||||
year = extract_year(movie_data.get('added', '')) # Use added date as fallback
|
||||
if not year and movie_data.get('year'):
|
||||
year = extract_year(str(movie_data.get('year')))
|
||||
|
||||
# Extract TMDB and IMDB IDs if available in basic data
|
||||
tmdb_id = movie_data.get('tmdb_id') or movie_data.get('tmdb')
|
||||
imdb_id = movie_data.get('imdb_id') or movie_data.get('imdb')
|
||||
|
||||
# Extract additional metadata that might be available in basic data
|
||||
description = movie_data.get('description') or movie_data.get('plot') or ''
|
||||
rating = movie_data.get('rating') or movie_data.get('vote_average') or ''
|
||||
genre = movie_data.get('genre') or movie_data.get('category_name') or ''
|
||||
duration_minutes = None
|
||||
|
||||
# Try to extract duration from various possible fields
|
||||
if movie_data.get('duration_secs'):
|
||||
duration_minutes = convert_duration_to_minutes(movie_data.get('duration_secs'))
|
||||
elif movie_data.get('duration'):
|
||||
# Handle duration that might be in different formats
|
||||
duration_str = str(movie_data.get('duration'))
|
||||
if duration_str.isdigit():
|
||||
duration_minutes = int(duration_str) # Assume minutes if just a number
|
||||
else:
|
||||
# Try to parse time format like "01:30:00"
|
||||
try:
|
||||
time_parts = duration_str.split(':')
|
||||
if len(time_parts) == 3:
|
||||
hours, minutes, seconds = map(int, time_parts)
|
||||
duration_minutes = (hours * 60) + minutes
|
||||
elif len(time_parts) == 2:
|
||||
minutes, seconds = map(int, time_parts)
|
||||
duration_minutes = minutes
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Build info dict with all extracted data
|
||||
info = {
|
||||
'plot': description,
|
||||
'rating': rating,
|
||||
'genre': genre,
|
||||
'duration_secs': movie_data.get('duration_secs'),
|
||||
}
|
||||
|
||||
# Use find_or_create_movie to handle duplicates properly
|
||||
movie = find_or_create_movie(
|
||||
name=name,
|
||||
year=year,
|
||||
tmdb_id=tmdb_id,
|
||||
imdb_id=imdb_id,
|
||||
info=info
|
||||
)
|
||||
|
||||
# Handle logo from basic data if available
|
||||
if movie_data.get('stream_icon'):
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=movie_data['stream_icon'],
|
||||
defaults={'name': name}
|
||||
)
|
||||
if not movie.logo:
|
||||
movie.logo = logo
|
||||
movie.save(update_fields=['logo'])
|
||||
|
||||
# Create or update relation
|
||||
stream_url = client.get_vod_stream_url(stream_id)
|
||||
|
||||
relation, created = M3UMovieRelation.objects.update_or_create(
|
||||
m3u_account=account,
|
||||
stream_id=str(stream_id),
|
||||
defaults={
|
||||
'movie': movie,
|
||||
'category': category,
|
||||
'url': stream_url,
|
||||
'container_extension': movie_data.get('container_extension', 'mp4'),
|
||||
'custom_properties': {
|
||||
'basic_data': movie_data,
|
||||
'detailed_fetched': False # Flag to indicate detailed data not fetched
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
logger.debug(f"Created new movie relation: {name}")
|
||||
else:
|
||||
logger.debug(f"Updated movie relation: {name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {str(e)}")
|
||||
|
||||
|
||||
def process_series_basic(client, account, series_data, category):
|
||||
"""Process a single series - basic info only, no detailed API call"""
|
||||
try:
|
||||
series_id = series_data.get('series_id')
|
||||
name = series_data.get('name', 'Unknown')
|
||||
|
||||
# Extract all available metadata from the basic data
|
||||
year = extract_year(series_data.get('releaseDate', '')) # Use releaseDate from API
|
||||
if not year and series_data.get('release_date'):
|
||||
year = extract_year(series_data.get('release_date'))
|
||||
|
||||
# Extract TMDB and IMDB IDs if available in basic data
|
||||
tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id')
|
||||
imdb_id = series_data.get('imdb') or series_data.get('imdb_id')
|
||||
|
||||
# Extract additional metadata that matches the actual API response
|
||||
description = series_data.get('plot') or series_data.get('description') or series_data.get('overview') or ''
|
||||
rating = series_data.get('rating') or series_data.get('vote_average') or ''
|
||||
genre = series_data.get('genre') or ''
|
||||
|
||||
# Build info dict with all extracted data
|
||||
info = {
|
||||
'plot': description,
|
||||
'rating': rating,
|
||||
'genre': genre,
|
||||
}
|
||||
|
||||
# Use find_or_create_series to handle duplicates properly
|
||||
series = find_or_create_series(
|
||||
name=name,
|
||||
year=year,
|
||||
tmdb_id=tmdb_id,
|
||||
imdb_id=imdb_id,
|
||||
info=info
|
||||
)
|
||||
|
||||
# Handle logo from basic data if available
|
||||
if series_data.get('cover'):
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=series_data['cover'],
|
||||
defaults={'name': name}
|
||||
)
|
||||
if not series.logo:
|
||||
series.logo = logo
|
||||
series.save(update_fields=['logo'])
|
||||
|
||||
# Create or update series relation
|
||||
series_relation, created = M3USeriesRelation.objects.update_or_create(
|
||||
m3u_account=account,
|
||||
external_series_id=str(series_id),
|
||||
defaults={
|
||||
'series': series,
|
||||
'category': category,
|
||||
'custom_properties': {
|
||||
'basic_data': series_data,
|
||||
'detailed_fetched': False, # Flag to indicate detailed data not fetched
|
||||
'episodes_fetched': False # Flag to indicate episodes not fetched
|
||||
},
|
||||
'last_episode_refresh': None # Set to None since we haven't fetched episodes
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
logger.debug(f"Created new series relation: {name}")
|
||||
else:
|
||||
logger.debug(f"Updated series relation: {name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing series {series_data.get('name', 'Unknown')}: {str(e)}")
|
||||
|
||||
|
||||
# Remove the detailed processing functions since they're no longer used during refresh
|
||||
# process_movie and process_series are now only called on-demand
|
||||
|
||||
def refresh_series_episodes(account, series, external_series_id, episodes_data=None):
|
||||
"""Refresh episodes for a series - only called on-demand"""
|
||||
try:
|
||||
if not episodes_data:
|
||||
# Fetch detailed series info including episodes
|
||||
with XtreamCodesClient(
|
||||
account.server_url,
|
||||
account.username,
|
||||
account.password,
|
||||
account.get_user_agent().user_agent
|
||||
) as client:
|
||||
series_info = client.get_series_info(external_series_id)
|
||||
if series_info:
|
||||
# Update series with detailed info
|
||||
info = series_info.get('info', {})
|
||||
if info:
|
||||
series.description = info.get('plot', series.description)
|
||||
series.rating = info.get('rating', series.rating)
|
||||
series.genre = info.get('genre', series.genre)
|
||||
if info.get('releasedate'):
|
||||
series.year = extract_year(info.get('releasedate'))
|
||||
series.save()
|
||||
|
||||
episodes_data = series_info.get('episodes', {})
|
||||
else:
|
||||
episodes_data = {}
|
||||
|
||||
# Clear existing episodes for this account to handle deletions
|
||||
Episode.objects.filter(
|
||||
series=series,
|
||||
m3u_relations__m3u_account=account
|
||||
).delete()
|
||||
|
||||
for season_num, season_episodes in episodes_data.items():
|
||||
for episode_data in season_episodes:
|
||||
process_episode(account, series, episode_data, int(season_num))
|
||||
|
||||
# Update the series relation to mark episodes as fetched
|
||||
series_relation = M3USeriesRelation.objects.filter(
|
||||
series=series,
|
||||
m3u_account=account
|
||||
).first()
|
||||
|
||||
if series_relation:
|
||||
custom_props = series_relation.custom_properties or {}
|
||||
custom_props['episodes_fetched'] = True
|
||||
custom_props['detailed_fetched'] = True
|
||||
series_relation.custom_properties = custom_props
|
||||
series_relation.last_episode_refresh = timezone.now()
|
||||
series_relation.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}")
|
||||
|
||||
|
||||
def find_or_create_movie(name, year, tmdb_id, imdb_id, info):
|
||||
"""Find existing movie or create new one based on metadata"""
|
||||
# Try to find by TMDB ID first
|
||||
if tmdb_id:
|
||||
movie = Movie.objects.filter(tmdb_id=tmdb_id).first()
|
||||
if movie:
|
||||
# Update with any new info we have
|
||||
updated = False
|
||||
if info.get('plot') and not movie.description:
|
||||
movie.description = info.get('plot')
|
||||
updated = True
|
||||
if info.get('rating') and not movie.rating:
|
||||
movie.rating = info.get('rating')
|
||||
updated = True
|
||||
if info.get('genre') and not movie.genre:
|
||||
movie.genre = info.get('genre')
|
||||
updated = True
|
||||
if not movie.year and year:
|
||||
movie.year = year
|
||||
updated = True
|
||||
duration = convert_duration_to_minutes(info.get('duration_secs'))
|
||||
if duration and not movie.duration:
|
||||
movie.duration = duration
|
||||
updated = True
|
||||
if updated:
|
||||
movie.save()
|
||||
return movie
|
||||
|
||||
# Try to find by IMDB ID
|
||||
if imdb_id:
|
||||
movie = Movie.objects.filter(imdb_id=imdb_id).first()
|
||||
if movie:
|
||||
# Update with any new info we have
|
||||
updated = False
|
||||
if info.get('plot') and not movie.description:
|
||||
movie.description = info.get('plot')
|
||||
updated = True
|
||||
if info.get('rating') and not movie.rating:
|
||||
movie.rating = info.get('rating')
|
||||
updated = True
|
||||
if info.get('genre') and not movie.genre:
|
||||
movie.genre = info.get('genre')
|
||||
updated = True
|
||||
if not movie.year and year:
|
||||
movie.year = year
|
||||
updated = True
|
||||
duration = convert_duration_to_minutes(info.get('duration_secs'))
|
||||
if duration and not movie.duration:
|
||||
movie.duration = duration
|
||||
updated = True
|
||||
if updated:
|
||||
movie.save()
|
||||
return movie
|
||||
|
||||
# Try to find by name and year - use first() to handle multiple matches
|
||||
if year:
|
||||
movie = Movie.objects.filter(name=name, year=year).first()
|
||||
if movie:
|
||||
return movie
|
||||
|
||||
# Try to find by name only if no year provided
|
||||
movie = Movie.objects.filter(name=name).first()
|
||||
if movie:
|
||||
return movie
|
||||
|
||||
# Create new movie with all available data
|
||||
return Movie.objects.create(
|
||||
name=name,
|
||||
year=year,
|
||||
tmdb_id=tmdb_id,
|
||||
imdb_id=imdb_id,
|
||||
description=info.get('plot', ''),
|
||||
rating=info.get('rating', ''),
|
||||
genre=info.get('genre', ''),
|
||||
duration=convert_duration_to_minutes(info.get('duration_secs'))
|
||||
)
|
||||
|
||||
|
||||
def find_or_create_series(name, year, tmdb_id, imdb_id, info):
|
||||
"""Find existing series or create new one based on metadata"""
|
||||
# Try to find by TMDB ID first
|
||||
if tmdb_id:
|
||||
series = Series.objects.filter(tmdb_id=tmdb_id).first()
|
||||
if series:
|
||||
# Update with any new info we have
|
||||
updated = False
|
||||
if info.get('plot') and not series.description:
|
||||
series.description = info.get('plot')
|
||||
updated = True
|
||||
if info.get('rating') and not series.rating:
|
||||
series.rating = info.get('rating')
|
||||
updated = True
|
||||
if info.get('genre') and not series.genre:
|
||||
series.genre = info.get('genre')
|
||||
updated = True
|
||||
if not series.year and year:
|
||||
series.year = year
|
||||
updated = True
|
||||
if updated:
|
||||
series.save()
|
||||
return series
|
||||
|
||||
# Try to find by IMDB ID
|
||||
if imdb_id:
|
||||
series = Series.objects.filter(imdb_id=imdb_id).first()
|
||||
if series:
|
||||
# Update with any new info we have
|
||||
updated = False
|
||||
if info.get('plot') and not series.description:
|
||||
series.description = info.get('plot')
|
||||
updated = True
|
||||
if info.get('rating') and not series.rating:
|
||||
series.rating = info.get('rating')
|
||||
updated = True
|
||||
if info.get('genre') and not series.genre:
|
||||
series.genre = info.get('genre')
|
||||
updated = True
|
||||
if not series.year and year:
|
||||
series.year = year
|
||||
updated = True
|
||||
if updated:
|
||||
series.save()
|
||||
return series
|
||||
|
||||
# Try to find by name and year - use first() to handle multiple matches
|
||||
if year:
|
||||
series = Series.objects.filter(name=name, year=year).first()
|
||||
if series:
|
||||
return series
|
||||
|
||||
# Try to find by name only if no year provided
|
||||
series = Series.objects.filter(name=name).first()
|
||||
if series:
|
||||
return series
|
||||
|
||||
# Create new series with all available data
|
||||
return Series.objects.create(
|
||||
name=name,
|
||||
year=year,
|
||||
tmdb_id=tmdb_id,
|
||||
imdb_id=imdb_id,
|
||||
description=info.get('plot', ''),
|
||||
rating=info.get('rating', ''),
|
||||
genre=info.get('genre', '')
|
||||
)
|
||||
|
||||
|
||||
def extract_year(date_string):
|
||||
"""Extract year from date string"""
|
||||
if not date_string:
|
||||
return None
|
||||
try:
|
||||
return int(date_string.split('-')[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
# Pattern for (YYYY) format
|
||||
pattern1 = r'\((\d{4})\)'
|
||||
# Pattern for - YYYY format
|
||||
pattern2 = r'\s-\s(\d{4})'
|
||||
# Pattern for YYYY at the end
|
||||
pattern3 = r'\s(\d{4})$'
|
||||
|
||||
for pattern in [pattern1, pattern2, pattern3]:
|
||||
match = re.search(pattern, title)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
# Validate year is reasonable (between 1900 and current year + 5)
|
||||
if 1900 <= year <= 2030:
|
||||
return year
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_year_from_data(data, title_key='name'):
|
||||
"""Extract year from various data sources with fallback options"""
|
||||
def convert_duration_to_minutes(duration_secs):
|
||||
"""Convert duration from seconds to minutes"""
|
||||
if not duration_secs:
|
||||
return None
|
||||
try:
|
||||
# First try the year field
|
||||
year = data.get('year')
|
||||
if year and str(year).strip() and str(year).strip() != '':
|
||||
try:
|
||||
year_int = int(year)
|
||||
if 1900 <= year_int <= 2030:
|
||||
return year_int
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return int(duration_secs) // 60
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# Try releaseDate or release_date fields
|
||||
for date_field in ['releaseDate', 'release_date']:
|
||||
date_value = data.get(date_field)
|
||||
if date_value and isinstance(date_value, str) and date_value.strip():
|
||||
# Extract year from date format like "2011-09-19"
|
||||
try:
|
||||
year_str = date_value.split('-')[0].strip()
|
||||
if year_str:
|
||||
year = int(year_str)
|
||||
if 1900 <= year <= 2030:
|
||||
return year
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# Finally try extracting from title
|
||||
title = data.get(title_key, '')
|
||||
if title and title.strip():
|
||||
return extract_year_from_title(title)
|
||||
|
||||
except Exception:
|
||||
# Don't fail processing if year extraction fails
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def refresh_movies(account):
|
||||
"""Refresh movie content"""
|
||||
try:
|
||||
# Get movie categories
|
||||
categories_url = f"{account.server_url}/player_api.php"
|
||||
params = {
|
||||
'username': account.username,
|
||||
'password': account.password,
|
||||
'action': 'get_vod_categories'
|
||||
}
|
||||
|
||||
response = requests.get(categories_url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
categories_data = response.json()
|
||||
|
||||
# Create a mapping of category_id to category name for lookup
|
||||
category_id_to_name = {}
|
||||
for cat_data in categories_data:
|
||||
category_id_to_name[cat_data.get('category_id')] = cat_data['category_name']
|
||||
|
||||
# Create/update categories
|
||||
VODCategory.objects.get_or_create(
|
||||
name=cat_data['category_name'],
|
||||
m3u_account=account,
|
||||
category_type='movie',
|
||||
defaults={
|
||||
'name': cat_data['category_name'],
|
||||
'category_type': 'movie'
|
||||
}
|
||||
)
|
||||
|
||||
# Get movies
|
||||
movies_url = f"{account.server_url}/player_api.php"
|
||||
params['action'] = 'get_vod_streams'
|
||||
|
||||
response = requests.get(movies_url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
movies_data = response.json()
|
||||
|
||||
for movie_data in movies_data:
|
||||
try:
|
||||
# Get category
|
||||
category = None
|
||||
category_id = movie_data.get('category_id')
|
||||
|
||||
if category_id:
|
||||
# First try to get category name from our mapping
|
||||
category_name = category_id_to_name.get(category_id)
|
||||
if not category_name:
|
||||
# Fallback to category_name from movie data
|
||||
category_name = movie_data.get('category_name', '')
|
||||
|
||||
if category_name:
|
||||
try:
|
||||
category = VODCategory.objects.filter(
|
||||
name=category_name,
|
||||
m3u_account=account,
|
||||
category_type='movie'
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error finding category for movie {movie_data.get('name', 'Unknown')}: {e}")
|
||||
category = None
|
||||
|
||||
# Create/update movie
|
||||
stream_url = f"{account.server_url}/movie/{account.username}/{account.password}/{movie_data['stream_id']}.{movie_data.get('container_extension', 'mp4')}"
|
||||
|
||||
# Extract year from title if not provided in API
|
||||
year = extract_year_from_data(movie_data, 'name')
|
||||
|
||||
movie_data_dict = {
|
||||
'name': movie_data['name'],
|
||||
'url': stream_url,
|
||||
'category': category,
|
||||
'year': year,
|
||||
'rating': movie_data.get('rating'),
|
||||
'genre': movie_data.get('genre'),
|
||||
'duration': movie_data.get('duration_secs', 0) // 60 if movie_data.get('duration_secs') else None,
|
||||
'container_extension': movie_data.get('container_extension'),
|
||||
'tmdb_id': movie_data.get('tmdb_id'),
|
||||
'imdb_id': movie_data.get('imdb_id'),
|
||||
'custom_properties': movie_data if movie_data else None
|
||||
}
|
||||
|
||||
# Use new Movie model
|
||||
movie, created = Movie.objects.update_or_create(
|
||||
stream_id=movie_data['stream_id'],
|
||||
m3u_account=account,
|
||||
defaults=movie_data_dict
|
||||
)
|
||||
|
||||
# Handle logo
|
||||
if movie_data.get('stream_icon'):
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=movie_data['stream_icon'],
|
||||
defaults={'name': movie_data['name']}
|
||||
)
|
||||
movie.logo = logo
|
||||
movie.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing movies for account {account.id}: {e}")
|
||||
|
||||
|
||||
def refresh_series(account):
|
||||
"""Refresh series and episodes content"""
|
||||
try:
|
||||
# Get series categories
|
||||
categories_url = f"{account.server_url}/player_api.php"
|
||||
params = {
|
||||
'username': account.username,
|
||||
'password': account.password,
|
||||
'action': 'get_series_categories'
|
||||
}
|
||||
|
||||
response = requests.get(categories_url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
categories_data = response.json()
|
||||
|
||||
# Create a mapping of category_id to category name for lookup
|
||||
category_id_to_name = {}
|
||||
for cat_data in categories_data:
|
||||
category_id_to_name[cat_data.get('category_id')] = cat_data['category_name']
|
||||
|
||||
# Create/update series categories
|
||||
VODCategory.objects.get_or_create(
|
||||
name=cat_data['category_name'],
|
||||
m3u_account=account,
|
||||
category_type='series',
|
||||
defaults={
|
||||
'name': cat_data['category_name'],
|
||||
'category_type': 'series'
|
||||
}
|
||||
)
|
||||
|
||||
# Get series list
|
||||
series_url = f"{account.server_url}/player_api.php"
|
||||
params['action'] = 'get_series'
|
||||
|
||||
response = requests.get(series_url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
series_data = response.json()
|
||||
|
||||
for series_item in series_data:
|
||||
try:
|
||||
# Get category
|
||||
category = None
|
||||
category_id = series_item.get('category_id')
|
||||
|
||||
if category_id:
|
||||
# First try to get category name from our mapping
|
||||
category_name = category_id_to_name.get(category_id)
|
||||
if not category_name:
|
||||
# Fallback to category_name from series data
|
||||
category_name = series_item.get('category_name', '')
|
||||
|
||||
if category_name:
|
||||
try:
|
||||
category = VODCategory.objects.filter(
|
||||
name=category_name,
|
||||
m3u_account=account,
|
||||
category_type='series'
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error finding category for series {series_item.get('name', 'Unknown')}: {e}")
|
||||
category = None
|
||||
|
||||
# Create/update series
|
||||
# Extract year from series data
|
||||
year = extract_year_from_data(series_item, 'name')
|
||||
|
||||
series_data_dict = {
|
||||
'name': series_item['name'],
|
||||
'description': series_item.get('plot'),
|
||||
'year': year,
|
||||
'rating': series_item.get('rating'),
|
||||
'genre': series_item.get('genre'),
|
||||
'category': category,
|
||||
'tmdb_id': series_item.get('tmdb_id'),
|
||||
'imdb_id': series_item.get('imdb_id'),
|
||||
'custom_properties': series_item if series_item else None
|
||||
}
|
||||
|
||||
series, created = Series.objects.update_or_create(
|
||||
series_id=series_item['series_id'],
|
||||
m3u_account=account,
|
||||
defaults=series_data_dict
|
||||
)
|
||||
|
||||
# Handle series logo
|
||||
if series_item.get('cover'):
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=series_item['cover'],
|
||||
defaults={'name': series_item['name']}
|
||||
)
|
||||
series.logo = logo
|
||||
series.save()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing series {series_item.get('name', 'Unknown')}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing series for account {account.id}: {e}")
|
||||
|
||||
|
||||
def refresh_series_episodes(account, series, series_id):
|
||||
"""Refresh episodes for a specific series"""
|
||||
try:
|
||||
episodes_url = f"{account.server_url}/player_api.php"
|
||||
params = {
|
||||
'username': account.username,
|
||||
'password': account.password,
|
||||
'action': 'get_series_info',
|
||||
'series_id': series_id
|
||||
}
|
||||
|
||||
response = requests.get(episodes_url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
series_info = response.json()
|
||||
|
||||
# Process episodes by season
|
||||
if 'episodes' in series_info:
|
||||
for season_num, episodes in series_info['episodes'].items():
|
||||
for episode_data in episodes:
|
||||
try:
|
||||
# Build episode stream URL
|
||||
stream_url = f"{account.server_url}/series/{account.username}/{account.password}/{episode_data['id']}.{episode_data.get('container_extension', 'mp4')}"
|
||||
|
||||
# Get episode info (metadata is nested in 'info' object)
|
||||
episode_info = episode_data.get('info', {})
|
||||
|
||||
episode_dict = {
|
||||
'name': episode_data.get('title', f"Episode {episode_data.get('episode_num', '')}"),
|
||||
'series': series,
|
||||
'season_number': int(season_num) if season_num.isdigit() else None,
|
||||
'episode_number': episode_data.get('episode_num'),
|
||||
'url': stream_url,
|
||||
'description': episode_info.get('plot') or episode_info.get('overview'),
|
||||
'release_date': episode_info.get('release_date') or episode_info.get('releasedate'),
|
||||
'rating': episode_info.get('rating'),
|
||||
'duration': episode_info.get('duration_secs'),
|
||||
'container_extension': episode_data.get('container_extension'),
|
||||
'tmdb_id': episode_info.get('tmdb_id'),
|
||||
'imdb_id': episode_info.get('imdb_id'),
|
||||
'custom_properties': episode_data if episode_data else None
|
||||
}
|
||||
# Use new Episode model
|
||||
episode, created = Episode.objects.update_or_create(
|
||||
stream_id=episode_data['id'],
|
||||
m3u_account=account,
|
||||
defaults=episode_dict
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing episode {episode_data.get('title', 'Unknown')}: {e}")
|
||||
continue
|
||||
|
||||
# Update last_episode_refresh timestamp
|
||||
series.last_episode_refresh = timezone.now()
|
||||
series.save(update_fields=['last_episode_refresh'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing episodes for series {series_id}: {e}")
|
||||
|
||||
@shared_task
|
||||
def cleanup_inactive_vod_connections():
|
||||
"""Clean up inactive VOD connections"""
|
||||
cutoff_time = timezone.now() - timedelta(minutes=30)
|
||||
inactive_connections = VODConnection.objects.filter(last_activity__lt=cutoff_time)
|
||||
def cleanup_orphaned_vod_content():
|
||||
"""Clean up VOD content that has no M3U relations"""
|
||||
# Clean up movies with no relations
|
||||
orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True)
|
||||
movie_count = orphaned_movies.count()
|
||||
orphaned_movies.delete()
|
||||
|
||||
count = inactive_connections.count()
|
||||
if count > 0:
|
||||
inactive_connections.delete()
|
||||
logger.info(f"Cleaned up {count} inactive VOD connections")
|
||||
# Clean up series with no relations
|
||||
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
|
||||
series_count = orphaned_series.count()
|
||||
orphaned_series.delete()
|
||||
|
||||
return count
|
||||
# Episodes will be cleaned up via CASCADE when series are deleted
|
||||
|
||||
logger.info(f"Cleaned up {movie_count} orphaned movies and {series_count} orphaned series")
|
||||
return f"Cleaned up {movie_count} movies and {series_count} series"
|
||||
|
|
@ -73,7 +73,6 @@ def stream_view(request, channel_uuid):
|
|||
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
|
||||
profiles = [obj for obj in m3u_profiles if not obj.is_default]
|
||||
|
||||
|
||||
# -- Loop through profiles and pick the first active one --
|
||||
for profile in [default_profile] + profiles:
|
||||
logger.debug(f'Checking profile {profile.name}...')
|
||||
|
|
@ -174,7 +173,7 @@ def stream_view(request, channel_uuid):
|
|||
persistent_lock.release()
|
||||
logger.debug("Persistent lock released for channel ID=%s", channel.id)
|
||||
|
||||
return StreamingHttpResponse(
|
||||
stream_generator(process, stream, persistent_lock),
|
||||
content_type="video/MP2T"
|
||||
)
|
||||
return StreamingHttpResponse(
|
||||
stream_generator(process, stream, persistent_lock),
|
||||
content_type="video/MP2T"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -370,6 +370,14 @@ class Client:
|
|||
"""Get the playback URL for a VOD"""
|
||||
return f"{self.server_url}/movie/{self.username}/{self.password}/{vod_id}.{container_extension}"
|
||||
|
||||
def get_movie_stream_url(self, vod_id, container_extension="mp4"):
|
||||
"""Get the playback URL for a movie (alias for get_vod_stream_url)"""
|
||||
return self.get_vod_stream_url(vod_id, container_extension)
|
||||
|
||||
def get_episode_stream_url(self, episode_id, container_extension="mp4"):
|
||||
"""Get the playback URL for an episode"""
|
||||
return f"{self.server_url}/series/{self.username}/{self.password}/{episode_id}.{container_extension}"
|
||||
|
||||
def close(self):
|
||||
"""Close the session and cleanup resources"""
|
||||
if hasattr(self, 'session') and self.session:
|
||||
|
|
|
|||
|
|
@ -238,6 +238,11 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
fetchSeriesInfo(series.id)
|
||||
.then((details) => {
|
||||
setDetailedSeries(details);
|
||||
// Check if episodes were fetched
|
||||
if (!details.episodes_fetched) {
|
||||
// Episodes not yet fetched, may need to wait for background fetch
|
||||
console.log('Episodes not yet fetched for series, may load incrementally');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to fetch series details, using basic info:', error);
|
||||
|
|
@ -541,10 +546,10 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
{/* Provider Information */}
|
||||
{displaySeries.m3u_account && (
|
||||
<Box mt="md">
|
||||
<Text size="sm" weight={500} mb={8}>IPTV Provider</Text>
|
||||
<Text size="sm" weight={500} mb={4}>Provider Information</Text>
|
||||
<Group spacing="md">
|
||||
<Badge color="blue" variant="light">
|
||||
{displaySeries.m3u_account.name || displaySeries.m3u_account}
|
||||
{displaySeries.m3u_account.name}
|
||||
</Badge>
|
||||
{displaySeries.m3u_account.account_type && (
|
||||
<Badge color="gray" variant="outline" size="xs">
|
||||
|
|
@ -764,7 +769,6 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
title="Trailer"
|
||||
size="xl"
|
||||
centered
|
||||
withCloseButton
|
||||
>
|
||||
<Box style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}>
|
||||
{trailerUrl && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue