Separate Movies, Series and Episodes into their own tables

This commit is contained in:
SergeantPanda 2025-08-02 13:32:44 -05:00
parent 0b5906538e
commit 0585fc1dfe
11 changed files with 344 additions and 203 deletions

View file

@ -1042,12 +1042,12 @@ def xc_get_vod_categories(user):
def xc_get_vod_streams(request, user, category_id=None):
"""Get VOD streams (movies) for XtreamCodes API"""
from apps.vod.models import VOD
from apps.vod.models import Movie
streams = []
# Build filters based on user access
filters = {"type": "movie", "m3u_account__is_active": True}
filters = {"m3u_account__is_active": True}
if user.user_level == 0:
# For regular users, filter by accessible M3U accounts
@ -1065,28 +1065,28 @@ def xc_get_vod_streams(request, user, category_id=None):
if category_id:
filters["category_id"] = category_id
vods = VOD.objects.filter(**filters).select_related('category', 'logo', 'm3u_account')
movies = Movie.objects.filter(**filters).select_related('category', 'logo', 'm3u_account')
for vod in vods:
for movie in movies:
streams.append({
"num": vod.id,
"name": vod.name,
"num": movie.id,
"name": movie.name,
"stream_type": "movie",
"stream_id": vod.id,
"stream_id": movie.id,
"stream_icon": (
None if not vod.logo
None if not movie.logo
else request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[vod.logo.id])
reverse("api:channels:logo-cache", args=[movie.logo.id])
)
),
"rating": vod.rating or "0",
"rating_5based": float(vod.rating or 0) / 2 if vod.rating else 0,
"rating": movie.rating or "0",
"rating_5based": float(movie.rating or 0) / 2 if movie.rating else 0,
"added": int(time.time()), # TODO: use actual created date
"is_adult": 0,
"category_id": str(vod.category.id) if vod.category else "0",
"container_extension": vod.container_extension or "mp4",
"category_id": str(movie.category.id) if movie.category else "0",
"container_extension": movie.container_extension or "mp4",
"custom_sid": None,
"direct_source": vod.url,
"direct_source": movie.url,
})
return streams
@ -1186,7 +1186,7 @@ def xc_get_series(request, user, category_id=None):
def xc_get_series_info(request, user, series_id):
"""Get detailed series information including episodes"""
from apps.vod.models import Series, VOD
from apps.vod.models import Series, Episode
if not series_id:
raise Http404()
@ -1212,9 +1212,8 @@ def xc_get_series_info(request, user, series_id):
raise Http404()
# Get episodes grouped by season
episodes = VOD.objects.filter(
series=serie,
type="episode"
episodes = Episode.objects.filter(
series=serie
).order_by('season_number', 'episode_number')
# Group episodes by season
@ -1285,13 +1284,13 @@ def xc_get_series_info(request, user, series_id):
def xc_get_vod_info(request, user, vod_id):
"""Get detailed VOD (movie) information"""
from apps.vod.models import VOD
from apps.vod.models import Movie
if not vod_id:
raise Http404()
# Get VOD with user access filtering
filters = {"id": vod_id, "type": "movie", "m3u_account__is_active": True}
# Get Movie with user access filtering
filters = {"id": vod_id, "m3u_account__is_active": True}
if user.user_level == 0:
if user.channel_profiles.count() > 0:
@ -1306,55 +1305,56 @@ def xc_get_vod_info(request, user, vod_id):
raise Http404()
try:
vod = VOD.objects.get(**filters)
except VOD.DoesNotExist:
movie = Movie.objects.get(**filters)
except Movie.DoesNotExist:
raise Http404()
info = {
"info": {
"tmdb_id": vod.tmdb_id or "",
"name": vod.name,
"o_name": vod.name,
"tmdb_id": movie.tmdb_id or "",
"name": movie.name,
"o_name": movie.name,
"cover_big": (
None if not vod.logo
None if not movie.logo
else request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[vod.logo.id])
reverse("api:channels:logo-cache", args=[movie.logo.id])
)
),
"movie_image": (
None if not vod.logo
None if not movie.logo
else request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[vod.logo.id])
reverse("api:channels:logo-cache", args=[movie.logo.id])
)
),
"releasedate": f"{vod.year}-01-01" if vod.year else "",
"episode_run_time": (vod.duration or 0) * 60,
"releasedate": f"{movie.year}-01-01" if movie.year else "",
"episode_run_time": (movie.duration or 0) * 60,
"youtube_trailer": "",
"director": "",
"actors": "",
"cast": "",
"description": vod.description or "",
"plot": vod.description or "",
"description": movie.description or "",
"plot": movie.description or "",
"age": "",
"country": "",
"genre": vod.genre or "",
"genre": movie.genre or "",
"backdrop_path": [],
"duration_secs": (vod.duration or 0) * 60,
"duration": f"{vod.duration or 0} min",
"duration_secs": (movie.duration or 0) * 60,
"duration": f"{movie.duration or 0} min",
"video": {},
"audio": {},
"bitrate": 0,
"rating": float(vod.rating or 0),
"rating": float(movie.rating or 0),
},
"movie_data": {
"stream_id": vod.id,
"name": vod.name,
"stream_id": movie.id,
"name": movie.name,
"added": int(time.time()),
"category_id": str(vod.category.id) if vod.category else "0",
"container_extension": vod.container_extension or "mp4",
"category_id": str(movie.category.id) if movie.category else "0",
"container_extension": movie.container_extension or "mp4",
"custom_sid": "",
"direct_source": vod.url,
"direct_source": movie.url,
}
}
return info
return info

View file

@ -4,6 +4,11 @@ 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'),
# Movie streaming
path('movie/<uuid:movie_uuid>', views.stream_movie, name='stream_movie'),
path('movie/<uuid:movie_uuid>/position', views.update_movie_position, name='update_movie_position'),
# Episode streaming
path('episode/<uuid:episode_uuid>', views.stream_episode, name='stream_episode'),
path('episode/<uuid:episode_uuid>/position', views.update_episode_position, name='update_episode_position'),
]

View file

@ -5,10 +5,10 @@ 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 django.contrib.contenttypes.models import ContentType
from rest_framework.decorators import api_view
from apps.vod.models import VOD, VODConnection
from apps.vod.models import Movie, Episode, VODConnection
from apps.m3u.models import M3UAccountProfile
from dispatcharr.utils import network_access_allowed, get_client_ip
from core.models import UserAgent, CoreSettings
@ -18,25 +18,37 @@ logger = logging.getLogger(__name__)
@csrf_exempt
@api_view(["GET"])
def stream_vod(request, vod_uuid):
"""Stream VOD content with connection tracking and range support"""
def stream_movie(request, movie_uuid):
"""Stream movie content with connection tracking and range support"""
return _stream_content(request, Movie, movie_uuid, "movie")
@csrf_exempt
@api_view(["GET"])
def stream_episode(request, episode_uuid):
"""Stream episode content with connection tracking and range support"""
return _stream_content(request, Episode, episode_uuid, "episode")
def _stream_content(request, model_class, content_uuid, content_type_name):
"""Generic function to stream VOD content"""
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
# Get VOD object
vod = get_object_or_404(VOD, uuid=vod_uuid)
# Get content object
content = get_object_or_404(model_class, uuid=content_uuid)
# Generate client ID and get client info
client_id = f"vod_client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
client_ip = get_client_ip(request)
client_user_agent = request.META.get('HTTP_USER_AGENT', '')
logger.info(f"[{client_id}] VOD stream request for: {vod.name}")
logger.info(f"[{client_id}] VOD stream request for: {content.name}")
try:
# Get available M3U profile for connection management
m3u_account = vod.m3u_account
m3u_account = content.m3u_account
available_profile = None
for profile in m3u_account.profiles.filter(is_active=True):
@ -51,9 +63,11 @@ def stream_vod(request, vod_uuid):
status=503
)
# Create connection tracking record
# Create connection tracking record using generic foreign key
content_type = ContentType.objects.get_for_model(content)
connection = VODConnection.objects.create(
vod=vod,
content_type=content_type,
object_id=content.id,
m3u_profile=available_profile,
client_id=client_id,
client_ip=client_ip,
@ -83,7 +97,7 @@ def stream_vod(request, vod_uuid):
# Stream the VOD content
try:
response = requests.get(
vod.url,
content.url,
headers=headers,
stream=True,
timeout=(10, 60)
@ -98,7 +112,7 @@ def stream_vod(request, vod_uuid):
)
# Determine content type
content_type = response.headers.get('Content-Type', 'video/mp4')
content_type_header = response.headers.get('Content-Type', 'video/mp4')
content_length = response.headers.get('Content-Length')
content_range = response.headers.get('Content-Range')
@ -132,7 +146,7 @@ def stream_vod(request, vod_uuid):
# Build response with appropriate headers
streaming_response = StreamingHttpResponse(
stream_generator(),
content_type=content_type,
content_type=content_type_header,
status=response.status_code
)
@ -147,7 +161,7 @@ def stream_vod(request, vod_uuid):
streaming_response['Access-Control-Allow-Origin'] = '*'
streaming_response['Cache-Control'] = 'no-cache'
logger.info(f"[{client_id}] Started streaming VOD: {vod.name}")
logger.info(f"[{client_id}] Started streaming {content_type_name}: {content.name}")
return streaming_response
except requests.RequestException as e:
@ -168,8 +182,20 @@ def stream_vod(request, vod_uuid):
@csrf_exempt
@api_view(["POST"])
def update_position(request, vod_uuid):
"""Update playback position for a VOD"""
def update_movie_position(request, movie_uuid):
"""Update playback position for a movie"""
return _update_position(request, Movie, movie_uuid)
@csrf_exempt
@api_view(["POST"])
def update_episode_position(request, episode_uuid):
"""Update playback position for an episode"""
return _update_position(request, Episode, episode_uuid)
def _update_position(request, model_class, content_uuid):
"""Generic function to update playback position"""
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
@ -181,8 +207,13 @@ def update_position(request, vod_uuid):
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)
content = get_object_or_404(model_class, uuid=content_uuid)
content_type = ContentType.objects.get_for_model(content)
connection = VODConnection.objects.get(
content_type=content_type,
object_id=content.id,
client_id=client_id
)
connection.update_activity(position=position)
return JsonResponse({"status": "success"})

View file

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import VOD, Series, VODCategory, VODConnection
from .models import Series, VODCategory, VODConnection, Movie, Episode
@admin.register(VODCategory)
@ -17,23 +17,42 @@ class SeriesAdmin(admin.ModelAdmin):
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']
@admin.register(Movie)
class MovieAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'genre', 'duration', 'm3u_account', 'created_at']
list_filter = ['m3u_account', 'category', 'year', 'created_at']
search_fields = ['name', 'description', 'stream_id']
readonly_fields = ['uuid', 'created_at', 'updated_at']
def get_queryset(self, request):
return super().get_queryset(request).select_related('series', 'm3u_account', 'category')
return super().get_queryset(request).select_related('category', 'logo', 'm3u_account')
@admin.register(Episode)
class EpisodeAdmin(admin.ModelAdmin):
list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'm3u_account', 'created_at']
list_filter = ['m3u_account', 'series', 'season_number', 'created_at']
search_fields = ['name', 'description', 'stream_id', 'series__name']
readonly_fields = ['uuid', 'created_at', 'updated_at']
def get_queryset(self, request):
return super().get_queryset(request).select_related('series', 'm3u_account')
@admin.register(VODConnection)
class VODConnectionAdmin(admin.ModelAdmin):
list_display = ['vod', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds']
list_display = ['get_content_name', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds']
list_filter = ['connected_at', 'last_activity']
search_fields = ['client_ip', 'client_id', 'vod__name']
search_fields = ['client_ip', 'client_id']
readonly_fields = ['connected_at']
def get_content_name(self, obj):
if obj.content_object:
return obj.content_object.name
elif obj.vod:
return obj.vod.name
return "Unknown"
get_content_name.short_description = "Content"
def get_queryset(self, request):
return super().get_queryset(request).select_related('vod', 'm3u_profile')
return super().get_queryset(request).select_related('content_object', 'm3u_profile')

View file

@ -9,39 +9,38 @@ from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
)
from .models import VOD, Series, VODCategory, VODConnection
from .models import Series, VODCategory, VODConnection, Movie, Episode
from .serializers import (
VODSerializer,
MovieSerializer,
EpisodeSerializer,
SeriesSerializer,
VODCategorySerializer,
VODConnectionSerializer
)
class VODFilter(django_filters.FilterSet):
class MovieFilter(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']
model = Movie
fields = ['name', 'category', 'm3u_account', 'year']
class VODViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for VOD content (Movies and Episodes)"""
queryset = VOD.objects.all()
serializer_class = VODSerializer
class MovieViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for Movie content"""
queryset = Movie.objects.all()
serializer_class = MovieSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = VODFilter
filterset_class = MovieFilter
search_fields = ['name', 'description', 'genre']
ordering_fields = ['name', 'year', 'created_at', 'season_number', 'episode_number']
ordering_fields = ['name', 'year', 'created_at']
ordering = ['name']
def get_permissions(self):
@ -51,37 +50,44 @@ class VODViewSet(viewsets.ReadOnlyModelViewSet):
return [Authenticated()]
def get_queryset(self):
return VOD.objects.select_related(
'series', 'category', 'logo', 'm3u_account'
return Movie.objects.select_related(
'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)
class EpisodeFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr="icontains")
series = django_filters.NumberFilter(field_name="series__id")
m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
season_number = django_filters.NumberFilter()
episode_number = django_filters.NumberFilter()
serializer = self.get_serializer(movies, many=True)
return Response(serializer.data)
class Meta:
model = Episode
fields = ['name', 'series', 'm3u_account', 'season_number', 'episode_number']
@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)
class EpisodeViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for Episode content"""
queryset = Episode.objects.all()
serializer_class = EpisodeSerializer
serializer = self.get_serializer(episodes, many=True)
return Response(serializer.data)
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = EpisodeFilter
search_fields = ['name', 'description']
ordering_fields = ['name', 'season_number', 'episode_number', 'created_at']
ordering = ['series__name', 'season_number', 'episode_number']
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 Episode.objects.select_related(
'series', 'm3u_account'
).filter(m3u_account__is_active=True)
class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
@ -113,10 +119,10 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
page = self.paginate_queryset(episodes)
if page is not None:
serializer = VODSerializer(page, many=True)
serializer = EpisodeSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = VODSerializer(episodes, many=True)
serializer = EpisodeSerializer(episodes, many=True)
return Response(serializer.data)
@ -162,4 +168,4 @@ class VODConnectionViewSet(viewsets.ReadOnlyModelViewSet):
return [Authenticated()]
def get_queryset(self):
return VODConnection.objects.select_related('vod', 'm3u_profile')
return VODConnection.objects.select_related('m3u_profile')

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-02 16:59
# Generated by Django 5.2.4 on 2025-08-02 18:26
import django.db.models.deletion
import uuid
@ -10,11 +10,29 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'),
('m3u', '0012_alter_m3uaccount_refresh_interval'),
]
operations = [
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)),
('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('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'],
'unique_together': {('name', 'm3u_account', 'category_type')},
},
),
migrations.CreateModel(
name='Series',
fields=[
@ -33,32 +51,37 @@ class Migration(migrations.Migration):
('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')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
],
options={
'verbose_name': 'Series',
'verbose_name_plural': 'Series',
'ordering': ['name'],
'unique_together': {('series_id', 'm3u_account')},
},
),
migrations.CreateModel(
name='VODCategory',
name='VODConnection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vod_categories', to='m3u.m3uaccount')),
('object_id', models.PositiveIntegerField()),
('client_id', models.CharField(max_length=255)),
('client_ip', models.GenericIPAddressField()),
('user_agent', models.TextField(blank=True, null=True)),
('connected_at', models.DateTimeField(auto_now_add=True)),
('last_activity', models.DateTimeField(auto_now=True)),
('bytes_sent', models.BigIntegerField(default=0)),
('position_seconds', models.IntegerField(default=0, help_text='Current playback position')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('m3u_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vod_connections', to='m3u.m3uaccountprofile')),
],
options={
'verbose_name': 'VOD Category',
'verbose_name_plural': 'VOD Categories',
'ordering': ['name'],
'unique_together': {('name', 'm3u_account', 'category_type')},
'verbose_name': 'VOD Connection',
'verbose_name_plural': 'VOD Connections',
},
),
migrations.CreateModel(
name='VOD',
name='Episode',
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)),
@ -66,9 +89,7 @@ class Migration(migrations.Migration):
('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)),
@ -79,45 +100,44 @@ class Migration(migrations.Migration):
('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')),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='m3u.m3uaccount')),
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')),
],
options={
'verbose_name': 'VOD',
'verbose_name_plural': 'VODs',
'ordering': ['name', 'season_number', 'episode_number'],
'verbose_name': 'Episode',
'verbose_name_plural': 'Episodes',
'ordering': ['series__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',
name='Movie',
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')),
('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)),
('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='movies', to='m3u.m3uaccount')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
],
options={
'verbose_name': 'VOD Connection',
'verbose_name_plural': 'VOD Connections',
'unique_together': {('vod', 'client_id')},
'verbose_name': 'Movie',
'verbose_name_plural': 'Movies',
'ordering': ['name'],
'unique_together': {('stream_id', 'm3u_account')},
},
),
]

View file

@ -1,5 +1,7 @@
from django.db import models
from django.utils import timezone
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from apps.m3u.models import M3UAccount
from apps.channels.models import Logo
import uuid
@ -7,16 +9,16 @@ import uuid
class VODCategory(models.Model):
"""Categories for organizing VODs (e.g., Action, Comedy, Drama)"""
CATEGORY_TYPE_CHOICES = [
('movie', 'Movie'),
('series', 'Series'),
]
name = models.CharField(max_length=255, unique=True)
name = models.CharField(max_length=255)
category_type = models.CharField(
max_length=10,
choices=CATEGORY_TYPE_CHOICES,
max_length=10,
choices=CATEGORY_TYPE_CHOICES,
default='movie',
help_text="Type of content this category contains"
)
@ -72,13 +74,8 @@ class Series(models.Model):
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'),
]
class Movie(models.Model):
"""Movie content"""
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
@ -86,12 +83,6 @@ class VOD(models.Model):
rating = models.CharField(max_length=10, blank=True, null=True)
genre = models.CharField(max_length=255, blank=True, null=True)
duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes")
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)
@ -102,7 +93,7 @@ class VOD(models.Model):
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='vods'
related_name='movies'
)
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
container_extension = models.CharField(max_length=10, blank=True, null=True)
@ -118,25 +109,76 @@ class VOD(models.Model):
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "VOD"
verbose_name_plural = "VODs"
ordering = ['name', 'season_number', 'episode_number']
verbose_name = "Movie"
verbose_name_plural = "Movies"
ordering = ['name']
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}"
"""Generate the proxied stream URL for this movie"""
return f"/proxy/vod/movie/{self.uuid}"
class Episode(models.Model):
"""Episode content for TV series"""
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)
duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes")
# Episode specific fields
series = models.ForeignKey(Series, on_delete=models.CASCADE, 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)
# M3U relationship
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='episodes'
)
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
container_extension = models.CharField(max_length=10, blank=True, null=True)
# Metadata IDs
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata")
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata")
# Additional properties
custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Episode"
verbose_name_plural = "Episodes"
ordering = ['series__name', 'season_number', 'episode_number']
unique_together = ['stream_id', 'm3u_account']
def __str__(self):
season_ep = f"S{self.season_number:02d}E{self.episode_number:02d}" if self.season_number and self.episode_number else ""
return f"{self.series.name} {season_ep} - {self.name}"
def get_stream_url(self):
"""Generate the proxied stream URL for this episode"""
return f"/proxy/vod/episode/{self.uuid}"
class VODConnection(models.Model):
"""Track active VOD connections for connection limit management"""
vod = models.ForeignKey(VOD, on_delete=models.CASCADE, related_name='connections')
# Use generic foreign key to support both Movie and Episode
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
m3u_profile = models.ForeignKey(
'm3u.M3UAccountProfile',
on_delete=models.CASCADE,
@ -153,10 +195,10 @@ class VODConnection(models.Model):
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})"
content_name = getattr(self.content_object, 'name', 'Unknown') if self.content_object else 'Unknown'
return f"{content_name} - {self.client_ip} ({self.client_id})"
def update_activity(self, bytes_sent=0, position=0):
"""Update connection activity"""

View file

@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import VOD, Series, VODCategory, VODConnection
from .models import Series, VODCategory, VODConnection, Movie, Episode
from apps.channels.serializers import LogoSerializer
from apps.m3u.serializers import M3UAccountSerializer
@ -26,15 +26,28 @@ class SeriesSerializer(serializers.ModelSerializer):
return obj.episodes.count()
class VODSerializer(serializers.ModelSerializer):
class MovieSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
stream_url = serializers.SerializerMethodField()
class Meta:
model = Movie
fields = '__all__'
def get_stream_url(self, obj):
return obj.get_stream_url()
class EpisodeSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
series = SeriesSerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
stream_url = serializers.SerializerMethodField()
class Meta:
model = VOD
model = Episode
fields = '__all__'
def get_stream_url(self, obj):
@ -42,8 +55,13 @@ class VODSerializer(serializers.ModelSerializer):
class VODConnectionSerializer(serializers.ModelSerializer):
vod = VODSerializer(read_only=True)
content_name = serializers.SerializerMethodField()
class Meta:
model = VODConnection
fields = '__all__'
def get_content_name(self, obj):
if obj.content_object:
return getattr(obj.content_object, 'name', 'Unknown')
return 'Unknown'

View file

@ -5,7 +5,7 @@ import re
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
from .models import VOD, Series, VODCategory, VODConnection
from .models import Series, VODCategory, VODConnection, Movie, Episode
from apps.m3u.models import M3UAccount
from apps.channels.models import Logo
@ -161,11 +161,10 @@ def refresh_movies(account):
# Extract year from title if not provided in API
year = extract_year_from_data(movie_data, 'name')
vod_data = {
movie_data_dict = {
'name': movie_data['name'],
'type': 'movie',
'url': stream_url,
'category_id': category,
'category': category,
'year': year,
'rating': movie_data.get('rating'),
'genre': movie_data.get('genre'),
@ -176,10 +175,11 @@ def refresh_movies(account):
'custom_properties': json.dumps(movie_data) if movie_data else None
}
vod, created = VOD.objects.update_or_create(
# Use new Movie model
movie, created = Movie.objects.update_or_create(
stream_id=movie_data['stream_id'],
m3u_account=account,
defaults=vod_data
defaults=movie_data_dict
)
# Handle logo
@ -188,8 +188,8 @@ def refresh_movies(account):
url=movie_data['stream_icon'],
defaults={'name': movie_data['name']}
)
vod.logo = logo
vod.save()
movie.logo = logo
movie.save()
except Exception as e:
logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {e}")
@ -326,7 +326,6 @@ def refresh_series_episodes(account, series, series_id):
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'),
@ -341,7 +340,8 @@ def refresh_series_episodes(account, series, series_id):
'custom_properties': json.dumps(episode_data) if episode_data else None
}
VOD.objects.update_or_create(
# Use new Episode model
episode, created = Episode.objects.update_or_create(
stream_id=episode_data['id'],
m3u_account=account,
defaults=episode_dict
@ -354,7 +354,6 @@ def refresh_series_episodes(account, series, series_id):
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"""

View file

@ -1,11 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import VODViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet
from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet
app_name = 'vod'
router = DefaultRouter()
router.register(r'vods', VODViewSet)
router.register(r'movies', MovieViewSet)
router.register(r'episodes', EpisodeViewSet)
router.register(r'series', SeriesViewSet)
router.register(r'categories', VODCategoryViewSet)
router.register(r'connections', VODConnectionViewSet)

View file

@ -1703,7 +1703,7 @@ export default class API {
static async getVODs(params = {}) {
try {
const searchParams = new URLSearchParams(params);
const response = await request(`${host}/api/vod/vods/?${searchParams.toString()}`);
const response = await request(`${host}/api/vod/movies/?${searchParams.toString()}`);
return response;
} catch (e) {
errorNotification('Failed to retrieve VODs', e);