mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
Initial backend commit for vod
This commit is contained in:
parent
1c47b7f84a
commit
84aa631196
17 changed files with 1393 additions and 1 deletions
|
|
@ -22,6 +22,7 @@ Dispatcharr has officially entered **BETA**, bringing powerful new features and
|
|||
📊 **Real-Time Stats Dashboard** — Live insights into stream health and client activity\
|
||||
🧠 **EPG Auto-Match** — Match program data to channels automatically\
|
||||
⚙️ **Streamlink + FFmpeg Support** — Flexible backend options for streaming and recording\
|
||||
🎬 **VOD Management** — Full Video on Demand support with movies and TV series\
|
||||
🧼 **UI & UX Enhancements** — Smoother, faster, more responsive interface\
|
||||
🛁 **Output Compatibility** — HDHomeRun, M3U, and XMLTV EPG support for Plex, Jellyfin, and more
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ Dispatcharr has officially entered **BETA**, bringing powerful new features and
|
|||
|
||||
✅ **Full IPTV Control** — Import, organize, proxy, and monitor IPTV streams on your own terms\
|
||||
✅ **Smart Playlist Handling** — M3U import, filtering, grouping, and failover support\
|
||||
✅ **VOD Content Management** — Organize movies and TV series with metadata and streaming\
|
||||
✅ **Reliable EPG Integration** — Match and manage TV guide data with ease\
|
||||
✅ **Clean & Responsive Interface** — Modern design that gets out of your way\
|
||||
✅ **Fully Self-Hosted** — Total control, zero reliance on third-party services
|
||||
|
|
|
|||
|
|
@ -789,7 +789,20 @@ def xc_player_api(request, full=False):
|
|||
"get_series_info",
|
||||
"get_vod_info",
|
||||
]:
|
||||
return JsonResponse([], safe=False)
|
||||
if action == "get_vod_categories":
|
||||
return JsonResponse(xc_get_vod_categories(user), safe=False)
|
||||
elif action == "get_vod_streams":
|
||||
return JsonResponse(xc_get_vod_streams(request, user, request.GET.get("category_id")), safe=False)
|
||||
elif action == "get_series_categories":
|
||||
return JsonResponse(xc_get_series_categories(user), safe=False)
|
||||
elif action == "get_series":
|
||||
return JsonResponse(xc_get_series(request, user, request.GET.get("category_id")), safe=False)
|
||||
elif action == "get_series_info":
|
||||
return JsonResponse(xc_get_series_info(request, user, request.GET.get("series_id")), safe=False)
|
||||
elif action == "get_vod_info":
|
||||
return JsonResponse(xc_get_vod_info(request, user, request.GET.get("vod_id")), safe=False)
|
||||
else:
|
||||
return JsonResponse([], safe=False)
|
||||
|
||||
raise Http404()
|
||||
|
||||
|
|
@ -986,3 +999,362 @@ def xc_get_epg(request, user, short=False):
|
|||
output['epg_listings'].append(program_output)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def xc_get_vod_categories(user):
|
||||
"""Get VOD categories for XtreamCodes API"""
|
||||
from apps.vod.models import VODCategory
|
||||
|
||||
response = []
|
||||
|
||||
# Filter categories based on user's M3U accounts
|
||||
if user.user_level == 0:
|
||||
# For regular users, get categories from their accessible M3U accounts
|
||||
if user.channel_profiles.count() > 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
# Get M3U accounts accessible through user's profiles
|
||||
from apps.m3u.models import M3UAccount
|
||||
m3u_accounts = M3UAccount.objects.filter(
|
||||
is_active=True,
|
||||
profiles__in=channel_profiles
|
||||
).distinct()
|
||||
else:
|
||||
m3u_accounts = []
|
||||
|
||||
categories = VODCategory.objects.filter(
|
||||
m3u_account__in=m3u_accounts
|
||||
).distinct()
|
||||
else:
|
||||
# Admins can see all categories
|
||||
categories = VODCategory.objects.filter(
|
||||
m3u_account__is_active=True
|
||||
).distinct()
|
||||
|
||||
for category in categories:
|
||||
response.append({
|
||||
"category_id": str(category.id),
|
||||
"category_name": category.name,
|
||||
"parent_id": 0,
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def xc_get_vod_streams(request, user, category_id=None):
|
||||
"""Get VOD streams (movies) for XtreamCodes API"""
|
||||
from apps.vod.models import VOD
|
||||
|
||||
streams = []
|
||||
|
||||
# Build filters based on user access
|
||||
filters = {"type": "movie", "m3u_account__is_active": True}
|
||||
|
||||
if user.user_level == 0:
|
||||
# For regular users, filter by accessible M3U accounts
|
||||
if user.channel_profiles.count() > 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
from apps.m3u.models import M3UAccount
|
||||
m3u_accounts = M3UAccount.objects.filter(
|
||||
is_active=True,
|
||||
profiles__in=channel_profiles
|
||||
).distinct()
|
||||
filters["m3u_account__in"] = m3u_accounts
|
||||
else:
|
||||
return [] # No accessible accounts
|
||||
|
||||
if category_id:
|
||||
filters["category_id"] = category_id
|
||||
|
||||
vods = VOD.objects.filter(**filters).select_related('category', 'logo', 'm3u_account')
|
||||
|
||||
for vod in vods:
|
||||
streams.append({
|
||||
"num": vod.id,
|
||||
"name": vod.name,
|
||||
"stream_type": "movie",
|
||||
"stream_id": vod.id,
|
||||
"stream_icon": (
|
||||
None if not vod.logo
|
||||
else request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[vod.logo.id])
|
||||
)
|
||||
),
|
||||
"rating": vod.rating or "0",
|
||||
"rating_5based": float(vod.rating or 0) / 2 if vod.rating else 0,
|
||||
"added": int(time.time()), # TODO: use actual created date
|
||||
"is_adult": 0,
|
||||
"category_id": str(vod.category.id) if vod.category else "0",
|
||||
"container_extension": vod.container_extension or "mp4",
|
||||
"custom_sid": None,
|
||||
"direct_source": vod.url,
|
||||
})
|
||||
|
||||
return streams
|
||||
|
||||
|
||||
def xc_get_series_categories(user):
|
||||
"""Get series categories for XtreamCodes API"""
|
||||
from apps.vod.models import VODCategory
|
||||
|
||||
response = []
|
||||
|
||||
# Similar filtering as VOD categories but for series
|
||||
if user.user_level == 0:
|
||||
if user.channel_profiles.count() > 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
from apps.m3u.models import M3UAccount
|
||||
m3u_accounts = M3UAccount.objects.filter(
|
||||
is_active=True,
|
||||
profiles__in=channel_profiles
|
||||
).distinct()
|
||||
else:
|
||||
m3u_accounts = []
|
||||
|
||||
categories = VODCategory.objects.filter(
|
||||
m3u_account__in=m3u_accounts,
|
||||
series__isnull=False # Only categories that have series
|
||||
).distinct()
|
||||
else:
|
||||
categories = VODCategory.objects.filter(
|
||||
m3u_account__is_active=True,
|
||||
series__isnull=False
|
||||
).distinct()
|
||||
|
||||
for category in categories:
|
||||
response.append({
|
||||
"category_id": str(category.id),
|
||||
"category_name": category.name,
|
||||
"parent_id": 0,
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def xc_get_series(request, user, category_id=None):
|
||||
"""Get series list for XtreamCodes API"""
|
||||
from apps.vod.models import Series
|
||||
|
||||
series_list = []
|
||||
|
||||
# Build filters based on user access
|
||||
filters = {"m3u_account__is_active": True}
|
||||
|
||||
if user.user_level == 0:
|
||||
if user.channel_profiles.count() > 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
from apps.m3u.models import M3UAccount
|
||||
m3u_accounts = M3UAccount.objects.filter(
|
||||
is_active=True,
|
||||
profiles__in=channel_profiles
|
||||
).distinct()
|
||||
filters["m3u_account__in"] = m3u_accounts
|
||||
else:
|
||||
return []
|
||||
|
||||
if category_id:
|
||||
filters["category_id"] = category_id
|
||||
|
||||
series = Series.objects.filter(**filters).select_related('category', 'logo', 'm3u_account')
|
||||
|
||||
for serie in series:
|
||||
series_list.append({
|
||||
"num": serie.id,
|
||||
"name": serie.name,
|
||||
"series_id": serie.id,
|
||||
"cover": (
|
||||
None if not serie.logo
|
||||
else request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[serie.logo.id])
|
||||
)
|
||||
),
|
||||
"plot": serie.description or "",
|
||||
"cast": "",
|
||||
"director": "",
|
||||
"genre": serie.genre or "",
|
||||
"release_date": str(serie.year) if serie.year else "",
|
||||
"last_modified": int(time.time()),
|
||||
"rating": serie.rating or "0",
|
||||
"rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0,
|
||||
"backdrop_path": [],
|
||||
"youtube_trailer": "",
|
||||
"episode_run_time": "",
|
||||
"category_id": str(serie.category.id) if serie.category else "0",
|
||||
})
|
||||
|
||||
return series_list
|
||||
|
||||
|
||||
def xc_get_series_info(request, user, series_id):
|
||||
"""Get detailed series information including episodes"""
|
||||
from apps.vod.models import Series, VOD
|
||||
|
||||
if not series_id:
|
||||
raise Http404()
|
||||
|
||||
# Get series with user access filtering
|
||||
filters = {"id": series_id, "m3u_account__is_active": True}
|
||||
|
||||
if user.user_level == 0:
|
||||
if user.channel_profiles.count() > 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
from apps.m3u.models import M3UAccount
|
||||
m3u_accounts = M3UAccount.objects.filter(
|
||||
is_active=True,
|
||||
profiles__in=channel_profiles
|
||||
).distinct()
|
||||
filters["m3u_account__in"] = m3u_accounts
|
||||
else:
|
||||
raise Http404()
|
||||
|
||||
try:
|
||||
serie = Series.objects.get(**filters)
|
||||
except Series.DoesNotExist:
|
||||
raise Http404()
|
||||
|
||||
# Get episodes grouped by season
|
||||
episodes = VOD.objects.filter(
|
||||
series=serie,
|
||||
type="episode"
|
||||
).order_by('season_number', 'episode_number')
|
||||
|
||||
# Group episodes by season
|
||||
seasons = {}
|
||||
for episode in episodes:
|
||||
season_num = episode.season_number or 1
|
||||
if season_num not in seasons:
|
||||
seasons[season_num] = []
|
||||
|
||||
seasons[season_num].append({
|
||||
"id": episode.stream_id,
|
||||
"episode_num": episode.episode_number or 0,
|
||||
"title": episode.name,
|
||||
"container_extension": episode.container_extension or "mp4",
|
||||
"info": {
|
||||
"air_date": f"{episode.year}-01-01" if episode.year else "",
|
||||
"crew": "",
|
||||
"directed_by": "",
|
||||
"episode_num": episode.episode_number or 0,
|
||||
"id": episode.stream_id,
|
||||
"imdb_id": episode.imdb_id or "",
|
||||
"name": episode.name,
|
||||
"overview": episode.description or "",
|
||||
"production_code": "",
|
||||
"season_number": episode.season_number or 1,
|
||||
"still_path": "",
|
||||
"vote_average": float(episode.rating or 0),
|
||||
"vote_count": 0,
|
||||
"writer": "",
|
||||
"release_date": f"{episode.year}-01-01" if episode.year else "",
|
||||
"duration_secs": (episode.duration or 0) * 60,
|
||||
"duration": f"{episode.duration or 0} min",
|
||||
"video": {},
|
||||
"audio": {},
|
||||
"bitrate": 0,
|
||||
}
|
||||
})
|
||||
|
||||
# Build response
|
||||
info = {
|
||||
"seasons": list(seasons.keys()),
|
||||
"info": {
|
||||
"name": serie.name,
|
||||
"cover": (
|
||||
None if not serie.logo
|
||||
else request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[serie.logo.id])
|
||||
)
|
||||
),
|
||||
"plot": serie.description or "",
|
||||
"cast": "",
|
||||
"director": "",
|
||||
"genre": serie.genre or "",
|
||||
"release_date": str(serie.year) if serie.year else "",
|
||||
"last_modified": int(time.time()),
|
||||
"rating": serie.rating or "0",
|
||||
"rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0,
|
||||
"backdrop_path": [],
|
||||
"youtube_trailer": "",
|
||||
"episode_run_time": "",
|
||||
"category_id": str(serie.category.id) if serie.category else "0",
|
||||
},
|
||||
"episodes": dict(seasons)
|
||||
}
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def xc_get_vod_info(request, user, vod_id):
|
||||
"""Get detailed VOD (movie) information"""
|
||||
from apps.vod.models import VOD
|
||||
|
||||
if not vod_id:
|
||||
raise Http404()
|
||||
|
||||
# Get VOD with user access filtering
|
||||
filters = {"id": vod_id, "type": "movie", "m3u_account__is_active": True}
|
||||
|
||||
if user.user_level == 0:
|
||||
if user.channel_profiles.count() > 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
from apps.m3u.models import M3UAccount
|
||||
m3u_accounts = M3UAccount.objects.filter(
|
||||
is_active=True,
|
||||
profiles__in=channel_profiles
|
||||
).distinct()
|
||||
filters["m3u_account__in"] = m3u_accounts
|
||||
else:
|
||||
raise Http404()
|
||||
|
||||
try:
|
||||
vod = VOD.objects.get(**filters)
|
||||
except VOD.DoesNotExist:
|
||||
raise Http404()
|
||||
|
||||
info = {
|
||||
"info": {
|
||||
"tmdb_id": vod.tmdb_id or "",
|
||||
"name": vod.name,
|
||||
"o_name": vod.name,
|
||||
"cover_big": (
|
||||
None if not vod.logo
|
||||
else request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[vod.logo.id])
|
||||
)
|
||||
),
|
||||
"movie_image": (
|
||||
None if not vod.logo
|
||||
else request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[vod.logo.id])
|
||||
)
|
||||
),
|
||||
"releasedate": f"{vod.year}-01-01" if vod.year else "",
|
||||
"episode_run_time": (vod.duration or 0) * 60,
|
||||
"youtube_trailer": "",
|
||||
"director": "",
|
||||
"actors": "",
|
||||
"cast": "",
|
||||
"description": vod.description or "",
|
||||
"plot": vod.description or "",
|
||||
"age": "",
|
||||
"country": "",
|
||||
"genre": vod.genre or "",
|
||||
"backdrop_path": [],
|
||||
"duration_secs": (vod.duration or 0) * 60,
|
||||
"duration": f"{vod.duration or 0} min",
|
||||
"video": {},
|
||||
"audio": {},
|
||||
"bitrate": 0,
|
||||
"rating": float(vod.rating or 0),
|
||||
},
|
||||
"movie_data": {
|
||||
"stream_id": vod.id,
|
||||
"name": vod.name,
|
||||
"added": int(time.time()),
|
||||
"category_id": str(vod.category.id) if vod.category else "0",
|
||||
"container_extension": vod.container_extension or "mp4",
|
||||
"custom_sid": "",
|
||||
"direct_source": vod.url,
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
|
|
|
|||
0
apps/proxy/vod_proxy/__init__.py
Normal file
0
apps/proxy/vod_proxy/__init__.py
Normal file
9
apps/proxy/vod_proxy/urls.py
Normal file
9
apps/proxy/vod_proxy/urls.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'vod_proxy'
|
||||
|
||||
urlpatterns = [
|
||||
path('stream/<uuid:vod_uuid>', views.stream_vod, name='stream_vod'),
|
||||
path('stream/<uuid:vod_uuid>/position', views.update_position, name='update_position'),
|
||||
]
|
||||
194
apps/proxy/vod_proxy/views.py
Normal file
194
apps/proxy/vod_proxy/views.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import time
|
||||
import random
|
||||
import logging
|
||||
import requests
|
||||
from django.http import StreamingHttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.decorators import api_view
|
||||
|
||||
from apps.vod.models import VOD, VODConnection
|
||||
|
||||
from apps.m3u.models import M3UAccountProfile
|
||||
from dispatcharr.utils import network_access_allowed, get_client_ip
|
||||
from core.models import UserAgent, CoreSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(["GET"])
|
||||
def stream_vod(request, vod_uuid):
|
||||
"""Stream VOD content with connection tracking and range support"""
|
||||
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
|
||||
# Get VOD object
|
||||
vod = get_object_or_404(VOD, uuid=vod_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: {vod.name}")
|
||||
|
||||
try:
|
||||
# Get available M3U profile for connection management
|
||||
m3u_account = vod.m3u_account
|
||||
available_profile = None
|
||||
|
||||
for profile in m3u_account.profiles.filter(is_active=True):
|
||||
current_connections = VODConnection.objects.filter(m3u_profile=profile).count()
|
||||
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
|
||||
connection = VODConnection.objects.create(
|
||||
vod=vod,
|
||||
m3u_profile=available_profile,
|
||||
client_id=client_id,
|
||||
client_ip=client_ip,
|
||||
user_agent=client_user_agent
|
||||
)
|
||||
|
||||
# Get user agent for upstream request
|
||||
try:
|
||||
user_agent_obj = m3u_account.get_user_agent()
|
||||
upstream_user_agent = user_agent_obj.user_agent
|
||||
except:
|
||||
default_ua_id = CoreSettings.get_default_user_agent_id()
|
||||
user_agent_obj = UserAgent.objects.get(id=default_ua_id)
|
||||
upstream_user_agent = user_agent_obj.user_agent
|
||||
|
||||
# Handle range requests for seeking
|
||||
range_header = request.META.get('HTTP_RANGE')
|
||||
headers = {
|
||||
'User-Agent': upstream_user_agent,
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
|
||||
if range_header:
|
||||
headers['Range'] = range_header
|
||||
logger.debug(f"[{client_id}] Range request: {range_header}")
|
||||
|
||||
# Stream the VOD content
|
||||
try:
|
||||
response = requests.get(
|
||||
vod.url,
|
||||
headers=headers,
|
||||
stream=True,
|
||||
timeout=(10, 60)
|
||||
)
|
||||
|
||||
if response.status_code not in [200, 206]:
|
||||
logger.error(f"[{client_id}] Upstream error: {response.status_code}")
|
||||
connection.delete()
|
||||
return JsonResponse(
|
||||
{"error": f"Upstream server error: {response.status_code}"},
|
||||
status=response.status_code
|
||||
)
|
||||
|
||||
# Determine content type
|
||||
content_type = response.headers.get('Content-Type', 'video/mp4')
|
||||
content_length = response.headers.get('Content-Length')
|
||||
content_range = response.headers.get('Content-Range')
|
||||
|
||||
# Create streaming response
|
||||
def stream_generator():
|
||||
bytes_sent = 0
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
bytes_sent += len(chunk)
|
||||
yield chunk
|
||||
|
||||
# Update connection activity periodically
|
||||
if bytes_sent % (8192 * 10) == 0: # Every ~80KB
|
||||
try:
|
||||
connection.update_activity(bytes_sent=len(chunk))
|
||||
except VODConnection.DoesNotExist:
|
||||
# Connection was cleaned up, stop streaming
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{client_id}] Streaming error: {e}")
|
||||
finally:
|
||||
# Clean up connection when streaming ends
|
||||
try:
|
||||
connection.delete()
|
||||
logger.info(f"[{client_id}] Connection cleaned up")
|
||||
except VODConnection.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Build response with appropriate headers
|
||||
streaming_response = StreamingHttpResponse(
|
||||
stream_generator(),
|
||||
content_type=content_type,
|
||||
status=response.status_code
|
||||
)
|
||||
|
||||
# Copy important headers
|
||||
if content_length:
|
||||
streaming_response['Content-Length'] = content_length
|
||||
if content_range:
|
||||
streaming_response['Content-Range'] = content_range
|
||||
|
||||
# Add CORS and caching headers
|
||||
streaming_response['Accept-Ranges'] = 'bytes'
|
||||
streaming_response['Access-Control-Allow-Origin'] = '*'
|
||||
streaming_response['Cache-Control'] = 'no-cache'
|
||||
|
||||
logger.info(f"[{client_id}] Started streaming VOD: {vod.name}")
|
||||
return streaming_response
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[{client_id}] Request error: {e}")
|
||||
connection.delete()
|
||||
return JsonResponse(
|
||||
{"error": "Failed to connect to upstream server"},
|
||||
status=502
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{client_id}] Unexpected error: {e}")
|
||||
return JsonResponse(
|
||||
{"error": "Internal server error"},
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(["POST"])
|
||||
def update_position(request, vod_uuid):
|
||||
"""Update playback position for a VOD"""
|
||||
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
|
||||
client_id = request.data.get('client_id')
|
||||
position = request.data.get('position', 0)
|
||||
|
||||
if not client_id:
|
||||
return JsonResponse({"error": "Client ID required"}, status=400)
|
||||
|
||||
try:
|
||||
vod = get_object_or_404(VOD, uuid=vod_uuid)
|
||||
connection = VODConnection.objects.get(vod=vod, client_id=client_id)
|
||||
connection.update_activity(position=position)
|
||||
|
||||
return JsonResponse({"status": "success"})
|
||||
|
||||
except VODConnection.DoesNotExist:
|
||||
return JsonResponse({"error": "Connection not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error(f"Position update error: {e}")
|
||||
return JsonResponse({"error": "Internal server error"}, status=500)
|
||||
0
apps/vod/__init__.py
Normal file
0
apps/vod/__init__.py
Normal file
39
apps/vod/admin.py
Normal file
39
apps/vod/admin.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from django.contrib import admin
|
||||
from .models import VOD, Series, VODCategory, VODConnection
|
||||
|
||||
|
||||
@admin.register(VODCategory)
|
||||
class VODCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'm3u_account', 'created_at']
|
||||
list_filter = ['m3u_account', 'created_at']
|
||||
search_fields = ['name']
|
||||
|
||||
|
||||
@admin.register(Series)
|
||||
class SeriesAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'year', 'genre', 'm3u_account', 'created_at']
|
||||
list_filter = ['m3u_account', 'category', 'year', 'created_at']
|
||||
search_fields = ['name', 'description', 'series_id']
|
||||
readonly_fields = ['uuid', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
@admin.register(VOD)
|
||||
class VODAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'type', 'series', 'season_number', 'episode_number', 'year', 'm3u_account']
|
||||
list_filter = ['type', 'm3u_account', 'category', 'year', 'created_at']
|
||||
search_fields = ['name', 'description', 'stream_id']
|
||||
readonly_fields = ['uuid', 'created_at', 'updated_at']
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('series', 'm3u_account', 'category')
|
||||
|
||||
|
||||
@admin.register(VODConnection)
|
||||
class VODConnectionAdmin(admin.ModelAdmin):
|
||||
list_display = ['vod', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds']
|
||||
list_filter = ['connected_at', 'last_activity']
|
||||
search_fields = ['client_ip', 'client_id', 'vod__name']
|
||||
readonly_fields = ['connected_at']
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('vod', 'm3u_profile')
|
||||
154
apps/vod/api_views.py
Normal file
154
apps/vod/api_views.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.shortcuts import get_object_or_404
|
||||
import django_filters
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
permission_classes_by_action,
|
||||
)
|
||||
from .models import VOD, Series, VODCategory, VODConnection
|
||||
from .serializers import (
|
||||
VODSerializer,
|
||||
SeriesSerializer,
|
||||
VODCategorySerializer,
|
||||
VODConnectionSerializer
|
||||
)
|
||||
|
||||
|
||||
class VODFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(lookup_expr="icontains")
|
||||
type = django_filters.ChoiceFilter(choices=VOD.TYPE_CHOICES)
|
||||
category = django_filters.CharFilter(field_name="category__name", lookup_expr="icontains")
|
||||
series = django_filters.NumberFilter(field_name="series__id")
|
||||
m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
|
||||
year = django_filters.NumberFilter()
|
||||
year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte")
|
||||
year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte")
|
||||
|
||||
class Meta:
|
||||
model = VOD
|
||||
fields = ['name', 'type', 'category', 'series', 'm3u_account', 'year']
|
||||
|
||||
|
||||
class VODViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for VOD content (Movies and Episodes)"""
|
||||
queryset = VOD.objects.all()
|
||||
serializer_class = VODSerializer
|
||||
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_class = VODFilter
|
||||
search_fields = ['name', 'description', 'genre']
|
||||
ordering_fields = ['name', 'year', 'created_at', 'season_number', 'episode_number']
|
||||
ordering = ['name']
|
||||
|
||||
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 VOD.objects.select_related(
|
||||
'series', 'category', 'logo', 'm3u_account'
|
||||
).filter(m3u_account__is_active=True)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def movies(self, request):
|
||||
"""Get only movie content"""
|
||||
movies = self.get_queryset().filter(type='movie')
|
||||
movies = self.filter_queryset(movies)
|
||||
|
||||
page = self.paginate_queryset(movies)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(movies, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def episodes(self, request):
|
||||
"""Get only episode content"""
|
||||
episodes = self.get_queryset().filter(type='episode')
|
||||
episodes = self.filter_queryset(episodes)
|
||||
|
||||
page = self.paginate_queryset(episodes)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(episodes, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for Series management"""
|
||||
queryset = Series.objects.all()
|
||||
serializer_class = SeriesSerializer
|
||||
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
search_fields = ['name', 'description', 'genre']
|
||||
ordering_fields = ['name', 'year', 'created_at']
|
||||
ordering = ['name']
|
||||
|
||||
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 Series.objects.select_related(
|
||||
'category', 'logo', 'm3u_account'
|
||||
).prefetch_related('episodes').filter(m3u_account__is_active=True)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def episodes(self, request, pk=None):
|
||||
"""Get episodes for a specific series"""
|
||||
series = self.get_object()
|
||||
episodes = series.episodes.all().order_by('season_number', 'episode_number')
|
||||
|
||||
page = self.paginate_queryset(episodes)
|
||||
if page is not None:
|
||||
serializer = VODSerializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = VODSerializer(episodes, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for VOD Categories"""
|
||||
queryset = VODCategory.objects.all()
|
||||
serializer_class = VODCategorySerializer
|
||||
|
||||
filter_backends = [SearchFilter, OrderingFilter]
|
||||
search_fields = ['name']
|
||||
ordering = ['name']
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
|
||||
class VODConnectionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for monitoring VOD connections"""
|
||||
queryset = VODConnection.objects.all()
|
||||
serializer_class = VODConnectionSerializer
|
||||
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||
ordering = ['-connected_at']
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
return VODConnection.objects.select_related('vod', 'm3u_profile')
|
||||
12
apps/vod/apps.py
Normal file
12
apps/vod/apps.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class VODConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.vod'
|
||||
verbose_name = 'Video on Demand'
|
||||
|
||||
def ready(self):
|
||||
"""Initialize VOD app when Django is ready"""
|
||||
# Import models to ensure they're registered
|
||||
from . import models
|
||||
121
apps/vod/migrations/0001_initial.py
Normal file
121
apps/vod/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-02 15:33
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'),
|
||||
('m3u', '0012_alter_m3uaccount_refresh_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Series',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('year', models.IntegerField(blank=True, null=True)),
|
||||
('rating', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('genre', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)),
|
||||
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
|
||||
('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')),
|
||||
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series', to='m3u.m3uaccount')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Series',
|
||||
'verbose_name_plural': 'Series',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VODCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vod_categories', to='m3u.m3uaccount')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VOD Category',
|
||||
'verbose_name_plural': 'VOD Categories',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VOD',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('year', models.IntegerField(blank=True, null=True)),
|
||||
('rating', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('genre', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)),
|
||||
('type', models.CharField(choices=[('movie', 'Movie'), ('episode', 'Episode')], default='movie', max_length=10)),
|
||||
('season_number', models.IntegerField(blank=True, null=True)),
|
||||
('episode_number', models.IntegerField(blank=True, null=True)),
|
||||
('url', models.URLField(max_length=2048)),
|
||||
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
|
||||
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
|
||||
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
|
||||
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
|
||||
('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')),
|
||||
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vods', to='m3u.m3uaccount')),
|
||||
('series', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VOD',
|
||||
'verbose_name_plural': 'VODs',
|
||||
'ordering': ['name', 'season_number', 'episode_number'],
|
||||
'unique_together': {('stream_id', 'm3u_account')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='series',
|
||||
name='category',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='series',
|
||||
unique_together={('series_id', 'm3u_account')},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VODConnection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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')),
|
||||
('m3u_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vod_connections', to='m3u.m3uaccountprofile')),
|
||||
('vod', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connections', to='vod.vod')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VOD Connection',
|
||||
'verbose_name_plural': 'VOD Connections',
|
||||
'unique_together': {('vod', 'client_id')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/vod/migrations/__init__.py
Normal file
0
apps/vod/migrations/__init__.py
Normal file
155
apps/vod/models.py
Normal file
155
apps/vod/models.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.channels.models import Logo
|
||||
import uuid
|
||||
|
||||
|
||||
class VODCategory(models.Model):
|
||||
"""Categories for organizing VODs (e.g., Action, Comedy, Drama)"""
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='vod_categories',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "VOD Category"
|
||||
verbose_name_plural = "VOD Categories"
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Series(models.Model):
|
||||
"""Series information for TV shows"""
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
year = models.IntegerField(blank=True, null=True)
|
||||
rating = models.CharField(max_length=10, blank=True, null=True)
|
||||
genre = models.CharField(max_length=255, blank=True, null=True)
|
||||
logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='series'
|
||||
)
|
||||
series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider")
|
||||
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata")
|
||||
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata")
|
||||
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Series"
|
||||
verbose_name_plural = "Series"
|
||||
ordering = ['name']
|
||||
unique_together = ['series_id', 'm3u_account']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.year or 'Unknown'})"
|
||||
|
||||
|
||||
class VOD(models.Model):
|
||||
"""Video on Demand content (Movies and Episodes)"""
|
||||
TYPE_CHOICES = [
|
||||
('movie', 'Movie'),
|
||||
('episode', 'Episode'),
|
||||
]
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
year = models.IntegerField(blank=True, null=True)
|
||||
rating = models.CharField(max_length=10, blank=True, null=True)
|
||||
genre = models.CharField(max_length=255, blank=True, null=True)
|
||||
duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes")
|
||||
type = models.CharField(max_length=10, choices=TYPE_CHOICES, default='movie')
|
||||
|
||||
# Episode specific fields
|
||||
series = models.ForeignKey(Series, on_delete=models.CASCADE, null=True, blank=True, related_name='episodes')
|
||||
season_number = models.IntegerField(blank=True, null=True)
|
||||
episode_number = models.IntegerField(blank=True, null=True)
|
||||
|
||||
# Streaming information
|
||||
url = models.URLField(max_length=2048)
|
||||
logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
|
||||
# M3U relationship
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='vods'
|
||||
)
|
||||
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)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "VOD"
|
||||
verbose_name_plural = "VODs"
|
||||
ordering = ['name', 'season_number', 'episode_number']
|
||||
unique_together = ['stream_id', 'm3u_account']
|
||||
|
||||
def __str__(self):
|
||||
if self.type == 'episode' and self.series:
|
||||
season_ep = f"S{self.season_number:02d}E{self.episode_number:02d}" if self.season_number and self.episode_number else ""
|
||||
return f"{self.series.name} {season_ep} - {self.name}"
|
||||
return f"{self.name} ({self.year or 'Unknown'})"
|
||||
|
||||
def get_stream_url(self):
|
||||
"""Generate the proxied stream URL for this VOD"""
|
||||
return f"/proxy/vod/stream/{self.uuid}"
|
||||
|
||||
|
||||
class VODConnection(models.Model):
|
||||
"""Track active VOD connections for connection limit management"""
|
||||
vod = models.ForeignKey(VOD, on_delete=models.CASCADE, related_name='connections')
|
||||
m3u_profile = models.ForeignKey(
|
||||
'm3u.M3UAccountProfile',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='vod_connections'
|
||||
)
|
||||
client_id = models.CharField(max_length=255)
|
||||
client_ip = models.GenericIPAddressField()
|
||||
user_agent = models.TextField(blank=True, null=True)
|
||||
connected_at = models.DateTimeField(auto_now_add=True)
|
||||
last_activity = models.DateTimeField(auto_now=True)
|
||||
bytes_sent = models.BigIntegerField(default=0)
|
||||
position_seconds = models.IntegerField(default=0, help_text="Current playback position")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "VOD Connection"
|
||||
verbose_name_plural = "VOD Connections"
|
||||
unique_together = ['vod', 'client_id']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vod.name} - {self.client_ip} ({self.client_id})"
|
||||
|
||||
def update_activity(self, bytes_sent=0, position=0):
|
||||
"""Update connection activity"""
|
||||
self.last_activity = timezone.now()
|
||||
if bytes_sent:
|
||||
self.bytes_sent += bytes_sent
|
||||
if position:
|
||||
self.position_seconds = position
|
||||
self.save(update_fields=['last_activity', 'bytes_sent', 'position_seconds'])
|
||||
47
apps/vod/serializers.py
Normal file
47
apps/vod/serializers.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from rest_framework import serializers
|
||||
from .models import VOD, Series, VODCategory, VODConnection
|
||||
from apps.channels.serializers import LogoSerializer
|
||||
from apps.m3u.serializers import M3UAccountSerializer
|
||||
|
||||
|
||||
class VODCategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = VODCategory
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class SeriesSerializer(serializers.ModelSerializer):
|
||||
logo = LogoSerializer(read_only=True)
|
||||
category = VODCategorySerializer(read_only=True)
|
||||
m3u_account = M3UAccountSerializer(read_only=True)
|
||||
episode_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Series
|
||||
fields = '__all__'
|
||||
|
||||
def get_episode_count(self, obj):
|
||||
return obj.episodes.count()
|
||||
|
||||
|
||||
class VODSerializer(serializers.ModelSerializer):
|
||||
logo = LogoSerializer(read_only=True)
|
||||
category = VODCategorySerializer(read_only=True)
|
||||
series = SeriesSerializer(read_only=True)
|
||||
m3u_account = M3UAccountSerializer(read_only=True)
|
||||
stream_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = VOD
|
||||
fields = '__all__'
|
||||
|
||||
def get_stream_url(self, obj):
|
||||
return obj.get_stream_url()
|
||||
|
||||
|
||||
class VODConnectionSerializer(serializers.ModelSerializer):
|
||||
vod = VODSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VODConnection
|
||||
fields = '__all__'
|
||||
268
apps/vod/tasks.py
Normal file
268
apps/vod/tasks.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import logging
|
||||
import requests
|
||||
import json
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import VOD, Series, VODCategory, VODConnection
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.channels.models import Logo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def refresh_vod_content(self, account_id):
|
||||
"""Refresh VOD content from XtreamCodes API"""
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
if account.account_type != M3UAccount.Types.XC:
|
||||
logger.warning(f"Account {account_id} is not XtreamCodes type")
|
||||
return
|
||||
|
||||
# Get movies and series
|
||||
refresh_movies(account)
|
||||
refresh_series(account)
|
||||
|
||||
logger.info(f"Successfully refreshed VOD content for account {account_id}")
|
||||
|
||||
except M3UAccount.DoesNotExist:
|
||||
logger.error(f"M3U Account {account_id} not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing VOD content for account {account_id}: {e}")
|
||||
|
||||
|
||||
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/update categories
|
||||
for cat_data in categories_data:
|
||||
VODCategory.objects.get_or_create(
|
||||
name=cat_data['category_name'],
|
||||
m3u_account=account,
|
||||
defaults={'name': cat_data['category_name']}
|
||||
)
|
||||
|
||||
# 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
|
||||
if movie_data.get('category_id'):
|
||||
try:
|
||||
category = VODCategory.objects.get(
|
||||
name__icontains=movie_data.get('category_name', ''),
|
||||
m3u_account=account
|
||||
)
|
||||
except VODCategory.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Create/update movie
|
||||
stream_url = f"{account.server_url}/movie/{account.username}/{account.password}/{movie_data['stream_id']}.{movie_data.get('container_extension', 'mp4')}"
|
||||
|
||||
vod_data = {
|
||||
'name': movie_data['name'],
|
||||
'type': 'movie',
|
||||
'url': stream_url,
|
||||
'category': category,
|
||||
'year': movie_data.get('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': json.dumps(movie_data) if movie_data else None
|
||||
}
|
||||
|
||||
vod, created = VOD.objects.update_or_create(
|
||||
stream_id=movie_data['stream_id'],
|
||||
m3u_account=account,
|
||||
defaults=vod_data
|
||||
)
|
||||
|
||||
# Handle logo
|
||||
if movie_data.get('stream_icon'):
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=movie_data['stream_icon'],
|
||||
defaults={'name': movie_data['name']}
|
||||
)
|
||||
vod.logo = logo
|
||||
vod.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/update series categories
|
||||
for cat_data in categories_data:
|
||||
VODCategory.objects.get_or_create(
|
||||
name=cat_data['category_name'],
|
||||
m3u_account=account,
|
||||
defaults={'name': cat_data['category_name']}
|
||||
)
|
||||
|
||||
# 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
|
||||
if series_item.get('category_id'):
|
||||
try:
|
||||
category = VODCategory.objects.get(
|
||||
name__icontains=series_item.get('category_name', ''),
|
||||
m3u_account=account
|
||||
)
|
||||
except VODCategory.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Create/update series
|
||||
series_data_dict = {
|
||||
'name': series_item['name'],
|
||||
'description': series_item.get('plot'),
|
||||
'year': series_item.get('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': json.dumps(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()
|
||||
|
||||
# Get series episodes
|
||||
refresh_series_episodes(account, series, series_item['series_id'])
|
||||
|
||||
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')}"
|
||||
|
||||
episode_dict = {
|
||||
'name': episode_data.get('title', f"Episode {episode_data.get('episode_num', '')}"),
|
||||
'type': 'episode',
|
||||
'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_data.get('plot'),
|
||||
'year': episode_data.get('air_date', '').split('-')[0] if episode_data.get('air_date') else None,
|
||||
'rating': episode_data.get('rating'),
|
||||
'duration': episode_data.get('duration_secs', 0) // 60 if episode_data.get('duration_secs') else None,
|
||||
'container_extension': episode_data.get('container_extension'),
|
||||
'tmdb_id': episode_data.get('tmdb_id'),
|
||||
'imdb_id': episode_data.get('imdb_id'),
|
||||
'custom_properties': json.dumps(episode_data) if episode_data else None
|
||||
}
|
||||
|
||||
VOD.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
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing episodes for series {series_id}: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_inactive_vod_connections():
|
||||
"""Clean up inactive VOD connections"""
|
||||
cutoff_time = timezone.now() - timedelta(minutes=30)
|
||||
inactive_connections = VODConnection.objects.filter(last_activity__lt=cutoff_time)
|
||||
|
||||
count = inactive_connections.count()
|
||||
if count > 0:
|
||||
inactive_connections.delete()
|
||||
logger.info(f"Cleaned up {count} inactive VOD connections")
|
||||
|
||||
return count
|
||||
15
apps/vod/urls.py
Normal file
15
apps/vod/urls.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import VODViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet
|
||||
|
||||
app_name = 'vod'
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'vods', VODViewSet)
|
||||
router.register(r'series', SeriesViewSet)
|
||||
router.register(r'categories', VODCategoryViewSet)
|
||||
router.register(r'connections', VODConnectionViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('api/', include(router.urls)),
|
||||
]
|
||||
|
|
@ -28,6 +28,7 @@ INSTALLED_APPS = [
|
|||
"apps.output",
|
||||
"apps.proxy.apps.ProxyConfig",
|
||||
"apps.proxy.ts_proxy",
|
||||
"apps.vod.apps.VODConfig",
|
||||
"core",
|
||||
"daphne",
|
||||
"drf_yasg",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ urlpatterns = [
|
|||
path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
|
||||
# Optionally, serve the raw Swagger JSON
|
||||
path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"),
|
||||
# VOD
|
||||
path("api/vod/", include("apps.vod.urls")),
|
||||
path("proxy/vod/", include("apps.proxy.vod_proxy.urls")),
|
||||
# Catch-all routes should always be last
|
||||
path("", TemplateView.as_view(template_name="index.html")), # React entry point
|
||||
path("<path:unused_path>", TemplateView.as_view(template_name="index.html")),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue