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