Initial backend commit for vod

This commit is contained in:
SergeantPanda 2025-08-02 10:42:36 -05:00
parent 1c47b7f84a
commit 84aa631196
17 changed files with 1393 additions and 1 deletions

View file

@ -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

View file

@ -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

View file

View 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'),
]

View 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
View file

39
apps/vod/admin.py Normal file
View 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
View 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
View 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

View 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')},
},
),
]

View file

155
apps/vod/models.py Normal file
View 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
View 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
View 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
View 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)),
]

View file

@ -28,6 +28,7 @@ INSTALLED_APPS = [
"apps.output",
"apps.proxy.apps.ProxyConfig",
"apps.proxy.ts_proxy",
"apps.vod.apps.VODConfig",
"core",
"daphne",
"drf_yasg",

View file

@ -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")),