Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
View File
+19
View File
@@ -0,0 +1,19 @@
from django.contrib import admin
from .models import EPGSource, ProgramData
@admin.register(EPGSource)
class EPGSourceAdmin(admin.ModelAdmin):
list_display = ['name', 'source_type', 'is_active']
list_filter = ['source_type', 'is_active']
search_fields = ['name']
@admin.register(ProgramData)
class ProgramAdmin(admin.ModelAdmin):
list_display = ['title', 'get_epg_tvg_id', 'start_time', 'end_time']
list_filter = ['epg__tvg_id', 'tvg_id']
search_fields = ['title', 'epg__name']
def get_epg_tvg_id(self, obj):
return obj.epg.tvg_id if obj.epg else ''
get_epg_tvg_id.short_description = 'Channel TVG ID'
get_epg_tvg_id.admin_order_field = 'epg__tvg_id'
+18
View File
@@ -0,0 +1,18 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet, CurrentProgramsAPIView
app_name = 'epg'
router = DefaultRouter()
router.register(r'sources', EPGSourceViewSet, basename='epg-source')
router.register(r'programs', ProgramViewSet, basename='program')
router.register(r'epgdata', EPGDataViewSet, basename='epgdata')
urlpatterns = [
path('grid/', EPGGridAPIView.as_view(), name='epg_grid'),
path('import/', EPGImportAPIView.as_view(), name='epg_import'),
path('current-programs/', CurrentProgramsAPIView.as_view(), name='current_programs'),
]
urlpatterns += router.urls
+553
View File
@@ -0,0 +1,553 @@
import logging, os
from rest_framework import viewsets, status, serializers
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import action
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
from django.utils import timezone
from datetime import timedelta
from .models import EPGSource, ProgramData, EPGData
from .serializers import (
ProgramDataSerializer,
ProgramDetailSerializer,
EPGSourceSerializer,
EPGDataSerializer,
)
from .tasks import refresh_epg_data
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
permission_classes_by_method,
)
logger = logging.getLogger(__name__)
# ─────────────────────────────
# 1) EPG Source API (CRUD)
# ─────────────────────────────
class EPGSourceViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows EPG sources to be viewed or edited.
"""
queryset = EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).all()
serializer_class = EPGSourceSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def get_queryset(self):
from django.db.models import Exists, OuterRef
from apps.channels.models import Channel
return EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).annotate(
has_channels=Exists(
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
)
)
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG sources.")
return super().list(request, *args, **kwargs)
@action(detail=False, methods=["post"])
def upload(self, request):
if "file" not in request.FILES:
return Response(
{"error": "No file uploaded"}, status=status.HTTP_400_BAD_REQUEST
)
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join("/data/uploads/epgs", file_name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
new_obj_data = request.data.copy()
new_obj_data["file_path"] = file_path
serializer = self.get_serializer(data=new_obj_data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def partial_update(self, request, *args, **kwargs):
"""Handle partial updates with special logic for is_active field"""
instance = self.get_object()
# Check if we're toggling is_active
if (
"is_active" in request.data
and instance.is_active != request.data["is_active"]
):
# Set appropriate status based on new is_active value
if request.data["is_active"]:
request.data["status"] = "idle"
else:
request.data["status"] = "disabled"
# Continue with regular partial update
return super().partial_update(request, *args, **kwargs)
# ─────────────────────────────
# 2) Program API (CRUD)
# ─────────────────────────────
class ProgramViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for EPG programs"""
queryset = ProgramData.objects.select_related("epg").all()
serializer_class = ProgramDataSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def get_serializer_class(self):
if self.action == 'retrieve':
return ProgramDetailSerializer
return ProgramDataSerializer
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return Response(serializer.data)
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG programs.")
return super().list(request, *args, **kwargs)
# ─────────────────────────────
# 3) EPG Grid View
# ─────────────────────────────
class EPGGridAPIView(APIView):
"""Returns all programs airing in the next 24 hours including currently running ones and recent ones"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@extend_schema(
description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
responses={200: ProgramDataSerializer(many=True)},
)
def get(self, request, format=None):
# Use current time instead of midnight
now = timezone.now()
one_hour_ago = now - timedelta(hours=1)
twenty_four_hours_later = now + timedelta(hours=24)
logger.debug(
f"EPGGridAPIView: Querying programs between {one_hour_ago} and {twenty_four_hours_later}."
)
programs = ProgramData.objects.filter(
end_time__gt=one_hour_ago,
start_time__lt=twenty_four_hours_later,
)
# Generate dummy programs for channels that have no EPG data OR dummy EPG sources
from apps.channels.models import Channel
from apps.epg.models import EPGSource
from django.db.models import Q
# Get channels with no EPG data at all (standard dummy)
channels_without_epg = Channel.objects.filter(Q(epg_data__isnull=True))
# Get channels with custom dummy EPG sources (generate on-demand with patterns)
channels_with_custom_dummy = Channel.objects.filter(
epg_data__epg_source__source_type='dummy'
).select_related('epg_data__epg_source').distinct()
# Log what we found
without_count = channels_without_epg.count()
custom_count = channels_with_custom_dummy.count()
if without_count > 0:
channel_names = [f"{ch.name} (ID: {ch.id})" for ch in channels_without_epg]
logger.debug(
f"EPGGridAPIView: Channels needing standard dummy EPG: {', '.join(channel_names)}"
)
if custom_count > 0:
channel_names = [f"{ch.name} (ID: {ch.id})" for ch in channels_with_custom_dummy]
logger.debug(
f"EPGGridAPIView: Channels needing custom dummy EPG: {', '.join(channel_names)}"
)
logger.debug(
f"EPGGridAPIView: Found {without_count} channels needing standard dummy, {custom_count} needing custom dummy EPG."
)
# Serialize the regular programs using .values() to bypass DRF overhead
programs_qs = programs.values(
'id', 'start_time', 'end_time', 'title', 'sub_title',
'description', 'tvg_id', 'custom_properties',
)
serialized_programs = []
for p in programs_qs:
cp = p['custom_properties'] or {}
premiere_text = cp.get('premiere_text', '')
serialized_programs.append({
'id': p['id'],
'start_time': p['start_time'],
'end_time': p['end_time'],
'title': p['title'],
'sub_title': p['sub_title'],
'description': p['description'],
'tvg_id': p['tvg_id'],
'season': cp.get('season'),
'episode': cp.get('episode'),
'is_new': bool(cp.get('new')),
'is_live': bool(cp.get('live')),
'is_premiere': bool(cp.get('premiere')),
'is_finale': bool(premiere_text and 'finale' in premiere_text.lower()),
})
logger.debug(
f"EPGGridAPIView: Found {len(serialized_programs)} program(s), including recently ended, currently running, and upcoming shows."
)
# Humorous program descriptions based on time of day - same as in output/views.py
time_descriptions = {
(0, 4): [
"Late Night with {channel} - Where insomniacs unite!",
"The 'Why Am I Still Awake?' Show on {channel}",
"Counting Sheep - A {channel} production for the sleepless",
],
(4, 8): [
"Dawn Patrol - Rise and shine with {channel}!",
"Early Bird Special - Coffee not included",
"Morning Zombies - Before coffee viewing on {channel}",
],
(8, 12): [
"Mid-Morning Meetings - Pretend you're paying attention while watching {channel}",
"The 'I Should Be Working' Hour on {channel}",
"Productivity Killer - {channel}'s daytime programming",
],
(12, 16): [
"Lunchtime Laziness with {channel}",
"The Afternoon Slump - Brought to you by {channel}",
"Post-Lunch Food Coma Theater on {channel}",
],
(16, 20): [
"Rush Hour - {channel}'s alternative to traffic",
"The 'What's For Dinner?' Debate on {channel}",
"Evening Escapism - {channel}'s remedy for reality",
],
(20, 24): [
"Prime Time Placeholder - {channel}'s finest not-programming",
"The 'Netflix Was Too Complicated' Show on {channel}",
"Family Argument Avoider - Courtesy of {channel}",
],
}
# Generate and append dummy programs
dummy_programs = []
# Import the function from output.views
from apps.output.views import generate_dummy_programs as gen_dummy_progs
# Handle channels with CUSTOM dummy EPG sources (with patterns)
for channel in channels_with_custom_dummy:
# For dummy EPGs, ALWAYS use channel UUID to ensure unique programs per channel
# This prevents multiple channels assigned to the same dummy EPG from showing identical data
# Each channel gets its own unique program data even if they share the same EPG source
dummy_tvg_id = str(channel.uuid)
try:
# Get the custom dummy EPG source
epg_source = channel.epg_data.epg_source if channel.epg_data else None
logger.debug(f"Generating custom dummy programs for channel: {channel.name} (ID: {channel.id})")
# Determine which name to parse based on custom properties
name_to_parse = channel.name
if epg_source and epg_source.custom_properties:
custom_props = epg_source.custom_properties
name_source = custom_props.get('name_source')
if name_source == 'stream':
# Get the stream index (1-based from user, convert to 0-based)
stream_index = custom_props.get('stream_index', 1) - 1
# Get streams ordered by channelstream order
channel_streams = channel.streams.all().order_by('channelstream__order')
if channel_streams.exists() and 0 <= stream_index < channel_streams.count():
stream = list(channel_streams)[stream_index]
name_to_parse = stream.name
logger.debug(f"Using stream name for parsing: {name_to_parse} (stream index: {stream_index})")
else:
logger.warning(f"Stream index {stream_index} not found for channel {channel.name}, falling back to channel name")
elif name_source == 'channel':
logger.debug(f"Using channel name for parsing: {name_to_parse}")
# Generate programs using custom patterns from the dummy EPG source
# Use the same tvg_id that will be set in the program data
generated = gen_dummy_progs(
channel_id=dummy_tvg_id,
channel_name=name_to_parse,
num_days=1,
program_length_hours=4,
epg_source=epg_source
)
# Custom dummy should always return data (either from patterns or fallback)
if generated:
logger.debug(f"Generated {len(generated)} custom dummy programs for {channel.name}")
# Convert generated programs to API format
for program in generated:
prog_custom = program.get('custom_properties') or {}
dummy_program = {
"id": f"dummy-custom-{channel.id}-{program['start_time'].hour}",
"epg": {"tvg_id": dummy_tvg_id, "name": channel.name},
"start_time": program['start_time'].isoformat(),
"end_time": program['end_time'].isoformat(),
"title": program['title'],
"description": program['description'],
"tvg_id": dummy_tvg_id,
"sub_title": program.get('sub_title'),
"custom_properties": prog_custom if prog_custom else None,
"season": None,
"episode": None,
"is_new": prog_custom.get('new', False),
"is_live": bool(prog_custom.get('live')),
"is_premiere": False,
"is_finale": False,
}
dummy_programs.append(dummy_program)
else:
logger.warning(f"No programs generated for custom dummy EPG channel: {channel.name}")
except Exception as e:
logger.error(
f"Error creating custom dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
)
# Handle channels with NO EPG data (standard dummy with humorous descriptions)
for channel in channels_without_epg:
# For channels with no EPG, use UUID to ensure uniqueness (matches frontend logic)
# The frontend uses: tvgRecord?.tvg_id ?? channel.uuid
# Since there's no EPG data, it will fall back to UUID
dummy_tvg_id = str(channel.uuid)
try:
logger.debug(f"Generating standard dummy programs for channel: {channel.name} (ID: {channel.id})")
# Create programs every 4 hours for the next 24 hours with humorous descriptions
for hour_offset in range(0, 24, 4):
# Use timedelta for time arithmetic instead of replace() to avoid hour overflow
start_time = now + timedelta(hours=hour_offset)
# Set minutes/seconds to zero for clean time blocks
start_time = start_time.replace(minute=0, second=0, microsecond=0)
end_time = start_time + timedelta(hours=4)
# Get the hour for selecting a description
hour = start_time.hour
day = 0 # Use 0 as we're only doing 1 day
# Find the appropriate time slot for description
for time_range, descriptions in time_descriptions.items():
start_range, end_range = time_range
if start_range <= hour < end_range:
# Pick a description using the sum of the hour and day as seed
# This makes it somewhat random but consistent for the same timeslot
description = descriptions[
(hour + day) % len(descriptions)
].format(channel=channel.name)
break
else:
# Fallback description if somehow no range matches
description = f"Placeholder program for {channel.name} - EPG data went on vacation"
# Create a dummy program in the same format as regular programs
dummy_program = {
"id": f"dummy-standard-{channel.id}-{hour_offset}",
"epg": {"tvg_id": dummy_tvg_id, "name": channel.name},
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"title": f"{channel.name}",
"description": description,
"tvg_id": dummy_tvg_id,
"sub_title": None,
"custom_properties": None,
"season": None,
"episode": None,
"is_new": False,
"is_live": False,
"is_premiere": False,
"is_finale": False,
}
dummy_programs.append(dummy_program)
except Exception as e:
logger.error(
f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
)
# Combine regular and dummy programs
all_programs = list(serialized_programs) + dummy_programs
logger.debug(
f"EPGGridAPIView: Returning {len(all_programs)} total programs (including {len(dummy_programs)} dummy programs)."
)
return Response({"data": all_programs}, status=status.HTTP_200_OK)
# ─────────────────────────────
# 4) EPG Import View
# ─────────────────────────────
class EPGImportAPIView(APIView):
"""Triggers an EPG data refresh"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@extend_schema(
description="Triggers an EPG data refresh for the given source.",
request=inline_serializer(
name="EPGImportRequest",
fields={
"id": serializers.IntegerField(help_text="ID of the EPG source to refresh."),
},
),
)
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
epg_id = request.data.get("id", None)
# Check if this is a dummy EPG source
try:
from .models import EPGSource
epg_source = EPGSource.objects.get(id=epg_id)
if epg_source.source_type == 'dummy':
logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}")
return Response(
{"success": False, "message": "Dummy EPG sources do not require refreshing."},
status=status.HTTP_400_BAD_REQUEST,
)
except EPGSource.DoesNotExist:
pass # Let the task handle the missing source
refresh_epg_data.delay(epg_id) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
return Response(
{"success": True, "message": "EPG data refresh initiated."},
status=status.HTTP_202_ACCEPTED,
)
# ─────────────────────────────
# 5) EPG Data View
# ─────────────────────────────
class EPGDataViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint that allows EPGData objects to be viewed.
"""
queryset = EPGData.objects.all()
serializer_class = EPGDataSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
# ─────────────────────────────
# 6) Current Programs API
# ─────────────────────────────
class CurrentProgramsAPIView(APIView):
"""
Lightweight endpoint that returns currently playing programs for specified channel IDs.
Accepts POST with JSON body containing channel_ids array, or null/empty to fetch all channels.
"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@extend_schema(
description="Get currently playing programs for specified channels or all channels",
request=inline_serializer(
name="CurrentProgramsRequest",
fields={
"channel_uuids": serializers.ListField(
child=serializers.CharField(),
required=False,
allow_null=True,
help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.",
),
},
),
responses={200: ProgramDataSerializer(many=True)},
)
def post(self, request, format=None):
# Import Channel model
from apps.channels.models import Channel
# Build query for channels with EPG data
query = Channel.objects.filter(epg_data__isnull=False)
channel_uuids = request.data.get('channel_uuids', None)
if channel_uuids is not None:
if not isinstance(channel_uuids, list):
return Response(
{"error": "channel_uuids must be an array of strings or null"},
status=status.HTTP_400_BAD_REQUEST
)
query = query.filter(uuid__in=channel_uuids)
# Get channels with EPG data
channels = query.select_related('epg_data')
# Get current time
now = timezone.now()
# Build list of current programs
current_programs = []
for channel in channels:
# Query for current program
program = ProgramData.objects.select_related("epg").filter(
epg=channel.epg_data,
start_time__lte=now,
end_time__gt=now
).first()
if program:
program_data = ProgramDataSerializer(program).data
program_data['channel_uuid'] = str(channel.uuid)
current_programs.append(program_data)
return Response(current_programs, status=status.HTTP_200_OK)
+10
View File
@@ -0,0 +1,10 @@
from django.apps import AppConfig
class EpgConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.epg'
verbose_name = "EPG Management"
def ready(self):
# Import signals to ensure they get registered
import apps.epg.signals
+47
View File
@@ -0,0 +1,47 @@
# Generated by Django 5.1.6 on 2025-03-05 22:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='EPGData',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('name', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='EPGSource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('source_type', models.CharField(choices=[('xmltv', 'XMLTV URL'), ('schedules_direct', 'Schedules Direct API')], max_length=20)),
('url', models.URLField(blank=True, null=True)),
('api_key', models.CharField(blank=True, max_length=255, null=True)),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='ProgramData',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_time', models.DateTimeField()),
('end_time', models.DateTimeField()),
('title', models.CharField(max_length=255)),
('sub_title', models.CharField(blank=True, max_length=255, null=True)),
('description', models.TextField(blank=True, null=True)),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('epg', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='programs', to='epg.epgdata')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-19 16:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='file_path',
field=models.CharField(blank=True, max_length=1024, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-25 19:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0002_epgsource_file_path'),
]
operations = [
migrations.AlterField(
model_name='epgdata',
name='tvg_id',
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-03-26 12:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0003_alter_epgdata_tvg_id'),
]
operations = [
migrations.AddField(
model_name='epgdata',
name='epg_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='epgs', to='epg.epgsource'),
),
migrations.AlterField(
model_name='epgdata',
name='tvg_id',
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.1.6 on 2025-03-27 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'),
]
operations = [
migrations.AddField(
model_name='programdata',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
migrations.AlterUniqueTogether(
name='epgdata',
unique_together={('tvg_id', 'epg_source')},
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.1.6 on 2025-03-29 17:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('django_celery_beat', '0019_alter_periodictasks_options'),
('epg', '0005_programdata_custom_properties_and_more'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='refresh_interval',
field=models.IntegerField(default=24),
),
migrations.AddField(
model_name='epgsource',
name='refresh_task',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_celery_beat.periodictask'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0006_epgsource_refresh_interval_epgsource_refresh_task'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='status',
field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success')], default='idle', max_length=20),
),
migrations.AddField(
model_name='epgsource',
name='last_error',
field=models.TextField(blank=True, null=True),
),
]
@@ -0,0 +1,52 @@
from django.db import migrations
import json
def create_default_refresh_tasks(apps, schema_editor):
"""
Creates a PeriodicTask for each existing EPGSource that doesn't have one.
"""
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
EPGSource = apps.get_model("epg", "EPGSource")
default_interval, _ = IntervalSchedule.objects.get_or_create(
every=24,
period="hours",
)
for account in EPGSource.objects.all():
if account.refresh_task:
continue
task_name = f"epg_source-refresh-{account.id}"
refresh_task = PeriodicTask.objects.create(
name=task_name,
interval=default_interval,
task="apps.epg.tasks.refresh_epg_data",
kwargs=json.dumps({"account_id": account.id}),
)
account.refresh_task = refresh_task
account.save(update_fields=["refresh_task"])
def reverse_migration(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
EPGSource = apps.get_model("epg", "EPGSource")
for account in EPGSource.objects.all():
IntervalSchedule.objects.all().delete()
PeriodicTask.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("epg", "0006_epgsource_refresh_interval_epgsource_refresh_task"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.RunPython(create_default_refresh_tasks, reverse_migration),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-04-07 16:29
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0007_populate_periodic_tasks'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='created_at',
field=models.DateTimeField(default=django.utils.timezone.now, help_text='Time when this source was created'),
),
migrations.AddField(
model_name='epgsource',
name='updated_at',
field=models.DateTimeField(default=django.utils.timezone.now, help_text='Time when this source was last updated'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-04-07 16:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0008_epgsource_created_at_epgsource_updated_at'),
]
operations = [
migrations.AlterField(
model_name='epgsource',
name='created_at',
field=models.DateTimeField(auto_now_add=True, help_text='Time when this source was created'),
),
migrations.AlterField(
model_name='epgsource',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Time when this source was last updated'),
),
]
@@ -0,0 +1,14 @@
# Generated by Django 5.1.6 on 2025-05-03 21:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('epg', '0007_epgsource_status_epgsource_last_error'),
('epg', '0009_alter_epgsource_created_at_and_more'),
]
operations = [
]
@@ -0,0 +1,42 @@
# Generated by Django 5.1.6 on 2025-05-04 21:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0010_merge_20250503_2147'),
]
operations = [
# Change updated_at field
migrations.AlterField(
model_name='epgsource',
name='updated_at',
field=models.DateTimeField(blank=True, help_text='Time when this source was last successfully refreshed', null=True),
),
# Add new last_message field
migrations.AddField(
model_name='epgsource',
name='last_message',
field=models.TextField(blank=True, help_text='Last status message, including success results or error information', null=True),
),
# Copy data from last_error to last_message
migrations.RunPython(
code=lambda apps, schema_editor: apps.get_model('epg', 'EPGSource').objects.all().update(
last_message=models.F('last_error')
),
reverse_code=lambda apps, schema_editor: apps.get_model('epg', 'EPGSource').objects.all().update(
last_error=models.F('last_message')
),
),
# Remove the old field
migrations.RemoveField(
model_name='epgsource',
name='last_error',
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-15 01:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0011_update_epgsource_fields'),
]
operations = [
migrations.AlterField(
model_name='epgsource',
name='status',
field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success'), ('disabled', 'Disabled')], default='idle', max_length=20),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-21 19:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0012_alter_epgsource_status'),
]
operations = [
migrations.AlterField(
model_name='epgsource',
name='refresh_interval',
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-26 15:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0013_alter_epgsource_refresh_interval'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='extracted_file_path',
field=models.CharField(blank=True, help_text='Path to extracted XML file after decompression', max_length=1024, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-02 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0014_epgsource_extracted_file_path'),
]
operations = [
migrations.AlterField(
model_name='programdata',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-16 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0015_alter_programdata_custom_properties'),
]
operations = [
migrations.AddField(
model_name='epgdata',
name='icon_url',
field=models.URLField(blank=True, max_length=500, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-24 21:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0016_epgdata_icon_url'),
]
operations = [
migrations.AlterField(
model_name='epgsource',
name='url',
field=models.URLField(blank=True, max_length=1000, null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-10-17 17:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0017_alter_epgsource_url'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, help_text='Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)', null=True),
),
migrations.AlterField(
model_name='epgsource',
name='source_type',
field=models.CharField(choices=[('xmltv', 'XMLTV URL'), ('schedules_direct', 'Schedules Direct API'), ('dummy', 'Custom Dummy EPG')], max_length=20),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-22 21:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0018_epgsource_custom_properties_and_more'),
]
operations = [
migrations.AlterField(
model_name='programdata',
name='sub_title',
field=models.TextField(blank=True, null=True),
),
]
@@ -0,0 +1,119 @@
# Generated migration to replace {time} placeholders with {starttime}
import re
from django.db import migrations
def migrate_time_placeholders(apps, schema_editor):
"""
Replace {time} with {starttime} and {time24} with {starttime24}
in all dummy EPG source custom_properties templates.
"""
EPGSource = apps.get_model('epg', 'EPGSource')
# Fields that contain templates with placeholders
template_fields = [
'title_template',
'description_template',
'upcoming_title_template',
'upcoming_description_template',
'ended_title_template',
'ended_description_template',
'channel_logo_url',
'program_poster_url',
]
# Get all dummy EPG sources
dummy_sources = EPGSource.objects.filter(source_type='dummy')
updated_count = 0
for source in dummy_sources:
if not source.custom_properties:
continue
modified = False
custom_props = source.custom_properties.copy()
for field in template_fields:
if field in custom_props and custom_props[field]:
original_value = custom_props[field]
# Replace {time24} first (before {time}) to avoid double replacement
# e.g., {time24} shouldn't become {starttime24} via {time} -> {starttime}
new_value = original_value
new_value = re.sub(r'\{time24\}', '{starttime24}', new_value)
new_value = re.sub(r'\{time\}', '{starttime}', new_value)
if new_value != original_value:
custom_props[field] = new_value
modified = True
if modified:
source.custom_properties = custom_props
source.save(update_fields=['custom_properties'])
updated_count += 1
if updated_count > 0:
print(f"Migration complete: Updated {updated_count} dummy EPG source(s) with new placeholder names.")
else:
print("No dummy EPG sources needed placeholder updates.")
def reverse_migration(apps, schema_editor):
"""
Reverse the migration by replacing {starttime} back to {time}.
"""
EPGSource = apps.get_model('epg', 'EPGSource')
template_fields = [
'title_template',
'description_template',
'upcoming_title_template',
'upcoming_description_template',
'ended_title_template',
'ended_description_template',
'channel_logo_url',
'program_poster_url',
]
dummy_sources = EPGSource.objects.filter(source_type='dummy')
updated_count = 0
for source in dummy_sources:
if not source.custom_properties:
continue
modified = False
custom_props = source.custom_properties.copy()
for field in template_fields:
if field in custom_props and custom_props[field]:
original_value = custom_props[field]
# Reverse the replacements
new_value = original_value
new_value = re.sub(r'\{starttime24\}', '{time24}', new_value)
new_value = re.sub(r'\{starttime\}', '{time}', new_value)
if new_value != original_value:
custom_props[field] = new_value
modified = True
if modified:
source.custom_properties = custom_props
source.save(update_fields=['custom_properties'])
updated_count += 1
if updated_count > 0:
print(f"Reverse migration complete: Reverted {updated_count} dummy EPG source(s) to old placeholder names.")
class Migration(migrations.Migration):
dependencies = [
('epg', '0019_alter_programdata_sub_title'),
]
operations = [
migrations.RunPython(migrate_time_placeholders, reverse_migration),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-12-05 15:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0020_migrate_time_to_starttime_placeholders'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='priority',
field=models.PositiveIntegerField(default=0, help_text='Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel.'),
),
]
View File
+168
View File
@@ -0,0 +1,168 @@
from django.db import models
from django.utils import timezone
from django_celery_beat.models import PeriodicTask
from django.conf import settings
import os
class EPGSource(models.Model):
SOURCE_TYPE_CHOICES = [
('xmltv', 'XMLTV URL'),
('schedules_direct', 'Schedules Direct API'),
('dummy', 'Custom Dummy EPG'),
]
STATUS_IDLE = 'idle'
STATUS_FETCHING = 'fetching'
STATUS_PARSING = 'parsing'
STATUS_ERROR = 'error'
STATUS_SUCCESS = 'success'
STATUS_DISABLED = 'disabled'
STATUS_CHOICES = [
(STATUS_IDLE, 'Idle'),
(STATUS_FETCHING, 'Fetching'),
(STATUS_PARSING, 'Parsing'),
(STATUS_ERROR, 'Error'),
(STATUS_SUCCESS, 'Success'),
(STATUS_DISABLED, 'Disabled'),
]
name = models.CharField(max_length=255, unique=True)
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES)
url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV
api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct
is_active = models.BooleanField(default=True)
file_path = models.CharField(max_length=1024, blank=True, null=True)
extracted_file_path = models.CharField(max_length=1024, blank=True, null=True,
help_text="Path to extracted XML file after decompression")
refresh_interval = models.IntegerField(default=0)
refresh_task = models.ForeignKey(
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
)
custom_properties = models.JSONField(
default=dict,
blank=True,
null=True,
help_text="Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)"
)
priority = models.PositiveIntegerField(
default=0,
help_text="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default=STATUS_IDLE
)
last_message = models.TextField(
null=True,
blank=True,
help_text="Last status message, including success results or error information"
)
created_at = models.DateTimeField(
auto_now_add=True,
help_text="Time when this source was created"
)
updated_at = models.DateTimeField(
null=True, blank=True,
help_text="Time when this source was last successfully refreshed"
)
def __str__(self):
return self.name
def get_cache_file(self):
import mimetypes
# Use a temporary extension for initial download
# The actual extension will be determined after content inspection
file_ext = ".tmp"
# If file_path is already set and contains an extension, use that
# This handles cases where we've already detected the proper type
if self.file_path and os.path.exists(self.file_path):
_, existing_ext = os.path.splitext(self.file_path)
if existing_ext:
file_ext = existing_ext
else:
# Try to detect the MIME type and map to extension
mime_type, _ = mimetypes.guess_type(self.file_path)
if mime_type:
if mime_type == 'application/gzip' or mime_type == 'application/x-gzip':
file_ext = '.gz'
elif mime_type == 'application/zip':
file_ext = '.zip'
elif mime_type == 'application/xml' or mime_type == 'text/xml':
file_ext = '.xml'
# For files without mime type detection, try peeking at content
else:
try:
with open(self.file_path, 'rb') as f:
header = f.read(4)
# Check for gzip magic number (1f 8b)
if header[:2] == b'\x1f\x8b':
file_ext = '.gz'
# Check for zip magic number (PK..)
elif header[:2] == b'PK':
file_ext = '.zip'
# Check for XML
elif header[:5] == b'<?xml' or header[:5] == b'<tv>':
file_ext = '.xml'
except Exception as e:
# If we can't read the file, just keep the default extension
pass
filename = f"{self.id}{file_ext}"
# Build full path in MEDIA_ROOT/cached_epg
cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg")
# Create directory if it doesn't exist
os.makedirs(cache_dir, exist_ok=True)
cache = os.path.join(cache_dir, filename)
return cache
def save(self, *args, **kwargs):
# Prevent auto_now behavior by handling updated_at manually
if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']:
# Don't modify updated_at for regular updates
kwargs.setdefault('update_fields', [])
if 'updated_at' in kwargs['update_fields']:
kwargs['update_fields'].remove('updated_at')
super().save(*args, **kwargs)
class EPGData(models.Model):
# Removed the Channel foreign key. We now just store the original tvg_id
# and a name (which might simply be the tvg_id if no real channel exists).
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
name = models.CharField(max_length=255)
icon_url = models.URLField(max_length=500, null=True, blank=True)
epg_source = models.ForeignKey(
EPGSource,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="epgs",
)
class Meta:
unique_together = ('tvg_id', 'epg_source')
def __str__(self):
return f"EPG Data for {self.name}"
class ProgramData(models.Model):
# Each programme is associated with an EPGData record.
epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs")
start_time = models.DateTimeField()
end_time = models.DateTimeField()
title = models.CharField(max_length=255)
sub_title = models.TextField(blank=True, null=True)
description = models.TextField(blank=True, null=True)
tvg_id = models.CharField(max_length=255, null=True, blank=True)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
def __str__(self):
return f"{self.title} ({self.start_time} - {self.end_time})"
+172
View File
@@ -0,0 +1,172 @@
from core.utils import validate_flexible_url
from rest_framework import serializers
from .models import EPGSource, EPGData, ProgramData
from apps.channels.models import Channel
class EPGSourceSerializer(serializers.ModelSerializer):
epg_data_count = serializers.SerializerMethodField()
has_channels = serializers.BooleanField(read_only=True, default=False)
read_only_fields = ['created_at', 'updated_at']
url = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
validators=[validate_flexible_url]
)
cron_expression = serializers.CharField(required=False, allow_blank=True, default='')
class Meta:
model = EPGSource
fields = [
'id',
'name',
'source_type',
'url',
'api_key',
'is_active',
'file_path',
'refresh_interval',
'cron_expression',
'priority',
'status',
'last_message',
'created_at',
'updated_at',
'custom_properties',
'epg_data_count',
'has_channels',
]
def get_epg_data_count(self, obj):
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
return obj.epgs.count()
def to_representation(self, instance):
data = super().to_representation(instance)
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
# But first check if we have a transient _cron_expression (from create/update before signal runs)
cron_expr = ''
if hasattr(instance, '_cron_expression'):
cron_expr = instance._cron_expression
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
data['cron_expression'] = cron_expr
return data
def update(self, instance, validated_data):
# Pop cron_expression before it reaches model fields
# If not present (partial update), preserve the existing cron from the PeriodicTask
if 'cron_expression' in validated_data:
cron_expr = validated_data.pop('cron_expression')
else:
cron_expr = ''
if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
instance._cron_expression = cron_expr
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
def create(self, validated_data):
cron_expr = validated_data.pop('cron_expression', '')
instance = EPGSource(**validated_data)
instance._cron_expression = cron_expr
instance.save()
return instance
class ProgramDataSerializer(serializers.ModelSerializer):
class Meta:
model = ProgramData
fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description', 'tvg_id']
def to_representation(self, obj):
data = super().to_representation(obj)
cp = obj.custom_properties or {}
data['season'] = cp.get('season')
data['episode'] = cp.get('episode')
data['is_new'] = bool(cp.get('new'))
data['is_live'] = bool(cp.get('live'))
data['is_premiere'] = bool(cp.get('premiere'))
premiere_text = cp.get('premiere_text', '')
data['is_finale'] = bool(premiere_text and 'finale' in premiere_text.lower())
return data
class ProgramDetailSerializer(ProgramDataSerializer):
"""Rich serializer for program detail view — extends slim serializer with full custom_properties."""
def to_representation(self, obj):
data = super().to_representation(obj)
cp = obj.custom_properties or {}
# Categories
data['categories'] = cp.get('categories') or []
# Content rating
data['rating'] = cp.get('rating')
data['rating_system'] = cp.get('rating_system')
# Star ratings
data['star_ratings'] = cp.get('star_ratings') or []
# Credits — flatten from XMLTV structure
credits = cp.get('credits') or {}
data['credits'] = {
'actors': credits.get('actor') or [],
'directors': credits.get('director') or [],
'writers': credits.get('writer') or [],
'producers': credits.get('producer') or [],
'presenters': credits.get('presenter') or [],
}
# Video/audio quality
video = cp.get('video') or {}
data['video_quality'] = video.get('quality')
data['aspect_ratio'] = video.get('aspect')
audio = cp.get('audio') or {}
data['stereo'] = audio.get('stereo')
# Previously shown (rerun)
data['is_previously_shown'] = bool(cp.get('previously_shown'))
# Geographic/language
data['country'] = cp.get('country')
data['language'] = cp.get('language')
# Dates
data['production_date'] = cp.get('date')
previously_shown = cp.get('previously_shown_details') or {}
data['original_air_date'] = previously_shown.get('start')
# External IDs
data['imdb_id'] = cp.get('imdb.com_id')
data['tmdb_id'] = cp.get('themoviedb.org_id')
data['tvdb_id'] = cp.get('thetvdb.com_id')
# Images
data['icon'] = cp.get('icon')
data['images'] = cp.get('images') or []
return data
class EPGDataSerializer(serializers.ModelSerializer):
"""
Only returns the tvg_id and the 'name' field from EPGData.
We assume 'name' is effectively the channel name.
"""
read_only_fields = ['epg_source']
class Meta:
model = EPGData
fields = [
'id',
'tvg_id',
'name',
'icon_url',
'epg_source',
]
+206
View File
@@ -0,0 +1,206 @@
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import EPGSource, EPGData
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
from core.utils import is_protected_path, send_websocket_update
import json
import logging
import os
logger = logging.getLogger(__name__)
@receiver(post_save, sender=EPGSource)
def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs):
# Trigger refresh only if the source is newly created, active, and not a dummy EPG
if created and instance.is_active and instance.source_type != 'dummy':
refresh_epg_data.delay(instance.id)
@receiver(post_save, sender=EPGSource)
def create_dummy_epg_data(sender, instance, created, **kwargs):
"""
Automatically create EPGData for dummy EPG sources when they are created.
This allows channels to be assigned to dummy EPGs immediately without
requiring a refresh first.
"""
if instance.source_type == 'dummy':
# Ensure dummy EPGs always have idle status and no status message
if instance.status != EPGSource.STATUS_IDLE or instance.last_message:
instance.status = EPGSource.STATUS_IDLE
instance.last_message = None
instance.save(update_fields=['status', 'last_message'])
# Create a URL-friendly tvg_id from the dummy EPG name
# Replace spaces and special characters with underscores
friendly_tvg_id = instance.name.replace(' ', '_').replace('-', '_')
# Remove any characters that aren't alphanumeric or underscores
friendly_tvg_id = ''.join(c for c in friendly_tvg_id if c.isalnum() or c == '_')
# Convert to lowercase for consistency
friendly_tvg_id = friendly_tvg_id.lower()
# Prefix with 'dummy_' to make it clear this is a dummy EPG
friendly_tvg_id = f"dummy_{friendly_tvg_id}"
# Create or update the EPGData record
epg_data, data_created = EPGData.objects.get_or_create(
tvg_id=friendly_tvg_id,
epg_source=instance,
defaults={
'name': instance.name,
'icon_url': None
}
)
# Update name if it changed and record already existed
if not data_created and epg_data.name != instance.name:
epg_data.name = instance.name
epg_data.save(update_fields=['name'])
if data_created:
logger.info(f"Auto-created EPGData for dummy EPG source: {instance.name} (ID: {instance.id})")
# Send websocket update to notify frontend that EPG data has been created
# This allows the channel form to immediately show the new dummy EPG without refreshing
send_websocket_update('updates', 'update', {
'type': 'epg_data_created',
'source_id': instance.id,
'source_name': instance.name,
'epg_data_id': epg_data.id
})
else:
logger.debug(f"EPGData already exists for dummy EPG source: {instance.name} (ID: {instance.id})")
@receiver(post_save, sender=EPGSource)
def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs):
"""
Create or update a Celery Beat periodic task when an EPGSource is created/updated.
Skip creating tasks for dummy EPG sources as they don't need refreshing.
Supports both interval-based and cron-based scheduling via the shared utility.
"""
# Skip task creation for dummy EPGs
if instance.source_type == 'dummy':
# If there's an existing task, disable it
if instance.refresh_task:
instance.refresh_task.enabled = False
instance.refresh_task.save(update_fields=['enabled'])
return
# Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message
# updates from the refresh task itself). We only need to reschedule when schedule-relevant
# fields change or when _cron_expression was explicitly set by the serializer.
SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'}
if (
not created
and update_fields is not None
and not (set(update_fields) & SCHEDULE_FIELDS)
and not hasattr(instance, '_cron_expression')
):
return
task_name = f"epg_source-refresh-{instance.id}"
should_be_enabled = instance.is_active
# Read cron_expression from transient attribute set by the serializer.
# If not set (e.g. save came from a task updating status/last_message),
# preserve the existing crontab so we don't accidentally revert to interval.
if hasattr(instance, "_cron_expression"):
cron_expr = instance._cron_expression
else:
cron_expr = ""
try:
existing_task = instance.refresh_task
if existing_task and existing_task.crontab:
ct = existing_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
except Exception:
pass
task = create_or_update_periodic_task(
task_name=task_name,
celery_task_path="apps.epg.tasks.refresh_epg_data",
kwargs={"source_id": instance.id},
interval_hours=int(instance.refresh_interval),
cron_expression=cron_expr,
enabled=should_be_enabled,
)
if instance.refresh_task != task:
instance.refresh_task = task
instance.save(update_fields=["refresh_task"])
@receiver(post_delete, sender=EPGSource)
def delete_refresh_task(sender, instance, **kwargs):
"""
Delete the associated Celery Beat periodic task when an EPGSource is deleted.
"""
try:
# First try the foreign key relationship to find the task ID
task = None
if instance.refresh_task:
logger.info(f"Found task via foreign key: {instance.refresh_task.id} for EPGSource {instance.id}")
task = instance.refresh_task
# Store task ID before deletion if we need to bypass the helper function
if task:
delete_epg_refresh_task_by_id(instance.id)
else:
# Otherwise use the helper function
delete_epg_refresh_task_by_id(instance.id)
except Exception as e:
logger.error(f"Error in delete_refresh_task signal handler: {str(e)}", exc_info=True)
@receiver(pre_save, sender=EPGSource)
def update_status_on_active_change(sender, instance, **kwargs):
"""
When an EPGSource's is_active field changes, update the status accordingly.
For dummy EPGs, always ensure status is idle and no status message.
"""
# Dummy EPGs should always be idle with no status message
if instance.source_type == 'dummy':
instance.status = EPGSource.STATUS_IDLE
instance.last_message = None
return
if instance.pk: # Only for existing records, not new ones
try:
# Get the current record from the database
old_instance = EPGSource.objects.get(pk=instance.pk)
# If is_active changed, update the status
if old_instance.is_active != instance.is_active:
if instance.is_active:
# When activating, set status to idle
instance.status = 'idle'
else:
# When deactivating, set status to disabled
instance.status = 'disabled'
except EPGSource.DoesNotExist:
# New record, will use default status
pass
@receiver(post_delete, sender=EPGSource)
def delete_cached_files(sender, instance, **kwargs):
"""
Delete cached files associated with an EPGSource when it's deleted.
Only deletes files that aren't in protected directories.
"""
# Check and delete the main file path if not protected
if instance.file_path and os.path.exists(instance.file_path):
if is_protected_path(instance.file_path):
logger.info(f"Skipping deletion of protected file: {instance.file_path}")
else:
try:
os.remove(instance.file_path)
logger.info(f"Deleted cached file: {instance.file_path}")
except OSError as e:
logger.error(f"Error deleting cached file {instance.file_path}: {e}")
# Check and delete the extracted file path if it exists, is different from main path, and not protected
if instance.extracted_file_path and os.path.exists(instance.extracted_file_path) and instance.extracted_file_path != instance.file_path:
if is_protected_path(instance.extracted_file_path):
logger.info(f"Skipping deletion of protected extracted file: {instance.extracted_file_path}")
else:
try:
os.remove(instance.extracted_file_path)
logger.info(f"Deleted extracted file: {instance.extracted_file_path}")
except OSError as e:
logger.error(f"Error deleting extracted file {instance.extracted_file_path}: {e}")
+2336
View File
File diff suppressed because it is too large Load Diff
View File
+195
View File
@@ -0,0 +1,195 @@
import os
import tempfile
from django.test import TestCase
from apps.epg.tasks import (
_NAMED_ENTITY_RE,
_detect_xml_encoding,
_replace_html_entity,
_resolve_html_entities,
)
class ReplaceHtmlEntityTests(TestCase):
"""Tests for the regex callback that resolves individual HTML entities."""
def _sub(self, text):
return _NAMED_ENTITY_RE.sub(_replace_html_entity, text)
def test_french_accented(self):
self.assertEqual(self._sub("Cha&icirc;ne T&eacute;l&eacute;"), "Chaîne Télé")
def test_german_umlauts(self):
self.assertEqual(self._sub("M&uuml;nchen &Uuml;bersicht &szlig;"), "München Übersicht ß")
def test_spanish(self):
self.assertEqual(self._sub("Espa&ntilde;a &iquest;Qu&eacute;?"), "España ¿Qué?")
def test_portuguese(self):
self.assertEqual(self._sub("Comunica&ccedil;&atilde;o"), "Comunicação")
def test_scandinavian(self):
self.assertEqual(self._sub("Norsk &oslash; &aring; &aelig;"), "Norsk ø å æ")
def test_greek_letters(self):
self.assertEqual(self._sub("&alpha;&beta;&gamma;"), "αβγ")
def test_currency_and_symbols(self):
self.assertEqual(self._sub("&copy; &euro; &pound; &yen;"), "© € £ ¥")
def test_preserves_xml_amp(self):
self.assertEqual(self._sub("A &amp; B"), "A &amp; B")
def test_preserves_xml_lt_gt(self):
self.assertEqual(self._sub("&lt;tag&gt;"), "&lt;tag&gt;")
def test_preserves_xml_quot_apos(self):
self.assertEqual(self._sub("&quot;hello&apos;"), "&quot;hello&apos;")
def test_preserves_uppercase_xml_entities(self):
"""&AMP;, &LT;, &GT;, &QUOT; resolve to XML-special chars; must not be replaced."""
self.assertEqual(self._sub("&AMP;"), "&AMP;")
self.assertEqual(self._sub("&LT;"), "&LT;")
self.assertEqual(self._sub("&GT;"), "&GT;")
self.assertEqual(self._sub("&QUOT;"), "&QUOT;")
def test_partial_entity_match_preserved(self):
"""html.unescape can partially match &amp inside &ampersand; — must not corrupt."""
self.assertEqual(self._sub("&ampersand;"), "&ampersand;")
def test_mixed_html_and_xml_entities(self):
self.assertEqual(
self._sub("R&eacute;sum&eacute; &amp; Co &lt;test&gt;"),
"Résumé &amp; Co &lt;test&gt;",
)
def test_plain_ascii_unchanged(self):
self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text")
def test_direct_utf8_unchanged(self):
self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ")
def test_unknown_entity_preserved(self):
self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;")
class ResolveHtmlEntitiesFileTests(TestCase):
"""Tests for the file-level preprocessing function."""
def _make_file(self, content):
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
return path
def test_resolves_entities_in_file(self):
path = self._make_file(
'<?xml version="1.0"?>\n<tv><channel><display-name>T&eacute;l&eacute;</display-name></channel></tv>'
)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("Télé", content)
self.assertNotIn("&eacute;", content)
def test_preserves_xml_entities_in_file(self):
path = self._make_file("<tv><desc>A &amp; B &lt;C&gt;</desc></tv>")
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("&amp;", content)
self.assertIn("&lt;", content)
self.assertIn("&gt;", content)
def test_no_temp_file_left_on_success(self):
path = self._make_file("<tv>test</tv>")
_resolve_html_entities(path)
self.assertFalse(os.path.exists(path + ".entity_tmp"))
def test_plain_file_unchanged(self):
original = '<?xml version="1.0"?>\n<tv><channel><display-name>Plain</display-name></channel></tv>'
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
def test_utf8_content_preserved(self):
original = "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("日本語テレビ", content)
def test_iso_8859_1_encoding(self):
"""Files declaring ISO-8859-1 should be read in that encoding."""
xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>Cha&icirc;ne</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("Cha\u00eene", content)
self.assertNotIn("&icirc;", content)
def test_detect_encoding_utf8_default(self):
"""Headers without an encoding declaration default to UTF-8."""
self.assertEqual(_detect_xml_encoding(b'<?xml version="1.0"?>'), "utf-8")
def test_detect_encoding_iso_8859_1(self):
"""Encoding is read from the XML declaration."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="ISO-8859-1"?>'),
"ISO-8859-1",
)
def test_detect_encoding_single_quotes(self):
"""Encoding detection works with single-quoted attributes."""
self.assertEqual(
_detect_xml_encoding(b"<?xml version='1.0' encoding='windows-1252'?>"),
"windows-1252",
)
def test_detect_encoding_unknown_falls_back(self):
"""Unrecognized encoding falls back to UTF-8."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="x-fake-codec"?>'),
"utf-8",
)
def test_iso_8859_1_with_entities_roundtrip(self):
"""ISO-8859-1 file with entities: resolved without corrupting existing accented chars."""
# Mix of direct ISO-8859-1 chars and HTML entities
xml_str = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>D\xe9j\xe0 &eacute;mission</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml_str.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved")
self.assertIn("\xe9mission", content, "Entity should be resolved")
self.assertNotIn("&eacute;", content)
def test_mismatched_encoding_leaves_file_untouched(self):
"""File declaring UTF-8 but containing Latin-1 bytes is left alone."""
# \xe9 is valid ISO-8859-1 but invalid as a standalone UTF-8 byte
raw = b'<?xml version="1.0" encoding="UTF-8"?>\n<tv><channel><display-name>\xe9</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(raw)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
original_bytes = raw # save for comparison
_resolve_html_entities(path)
with open(path, "rb") as f:
result_bytes = f.read()
self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error")
+655
View File
@@ -0,0 +1,655 @@
from django.test import TestCase
from django.utils import timezone
from apps.epg.models import EPGData, EPGSource, ProgramData
from apps.epg.serializers import ProgramDataSerializer, ProgramDetailSerializer
from apps.epg.utils import extract_season_episode, extract_season_episode_from_description
class ProgramDataSerializerTests(TestCase):
"""Tests for ProgramDataSerializer season/episode extraction from custom_properties."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_season_and_episode_from_custom_properties(self):
"""Season and episode should be extracted from custom_properties."""
program = self._create_program(
custom_properties={"season": 3, "episode": 5}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 3)
self.assertEqual(data["episode"], 5)
def test_season_only_from_custom_properties(self):
"""Season should be returned even when episode is absent."""
program = self._create_program(custom_properties={"season": 2})
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 2)
self.assertIsNone(data["episode"])
def test_episode_only_from_custom_properties(self):
"""Episode should be returned even when season is absent."""
program = self._create_program(custom_properties={"episode": 10})
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertEqual(data["episode"], 10)
def test_season_episode_null_when_custom_properties_is_none(self):
"""Both should be None when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_season_episode_null_when_custom_properties_is_empty(self):
"""Both should be None when custom_properties is an empty dict."""
program = self._create_program(custom_properties={})
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_season_episode_null_when_keys_absent(self):
"""Both should be None when custom_properties has other keys but no season/episode."""
program = self._create_program(
custom_properties={"categories": ["Drama"], "rating": "TV-14"}
)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_sub_title_included_in_serialized_data(self):
"""sub_title field should be present in serialized output."""
program = self._create_program(sub_title="The Pilot")
data = ProgramDataSerializer(program).data
self.assertEqual(data["sub_title"], "The Pilot")
def test_sub_title_null_when_not_set(self):
"""sub_title should be None when not set."""
program = self._create_program()
data = ProgramDataSerializer(program).data
self.assertIsNone(data["sub_title"])
def test_all_expected_fields_present(self):
"""Serialized output should contain all expected fields."""
program = self._create_program(
sub_title="Episode Title",
custom_properties={"season": 1, "episode": 1},
)
data = ProgramDataSerializer(program).data
expected_fields = {
"id", "start_time", "end_time", "title", "sub_title",
"description", "tvg_id", "season", "episode",
"is_new", "is_live", "is_premiere", "is_finale",
}
self.assertEqual(set(data.keys()), expected_fields)
def test_season_episode_from_onscreen_episode(self):
"""Season and episode should be parsed from onscreen_episode string."""
program = self._create_program(
custom_properties={"onscreen_episode": "S12 E6"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 12)
self.assertEqual(data["episode"], 6)
def test_onscreen_episode_no_space(self):
"""Should parse onscreen_episode without space between S and E."""
program = self._create_program(
custom_properties={"onscreen_episode": "S3E21"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 3)
self.assertEqual(data["episode"], 21)
def test_onscreen_episode_with_part(self):
"""Should parse season/episode even when part info follows."""
program = self._create_program(
custom_properties={"onscreen_episode": "S8 E8 P2/2"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 8)
self.assertEqual(data["episode"], 8)
def test_direct_season_episode_takes_priority_over_onscreen(self):
"""Direct season/episode keys should take priority over onscreen parsing."""
program = self._create_program(
custom_properties={
"season": 1, "episode": 2,
"onscreen_episode": "S99 E99",
}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 1)
self.assertEqual(data["episode"], 2)
def test_onscreen_episode_invalid_format(self):
"""Should return None for onscreen_episode that doesn't match S/E pattern."""
program = self._create_program(
custom_properties={"onscreen_episode": "Episode 5"}
)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_bulk_serialization_with_mixed_data(self):
"""Serializer should handle a mix of programs with and without metadata."""
p1 = self._create_program(
title="Show A",
sub_title="Ep 1",
custom_properties={"season": 1, "episode": 1},
)
p2 = self._create_program(
title="Movie B",
custom_properties=None,
)
p3 = self._create_program(
title="Show C",
custom_properties={},
)
data = ProgramDataSerializer([p1, p2, p3], many=True).data
self.assertEqual(len(data), 3)
self.assertEqual(data[0]["season"], 1)
self.assertEqual(data[0]["episode"], 1)
self.assertIsNone(data[1]["season"])
self.assertIsNone(data[1]["episode"])
self.assertIsNone(data[2]["season"])
self.assertIsNone(data[2]["episode"])
def test_is_new_true_when_flag_set(self):
"""is_new should be True when custom_properties has 'new' flag."""
program = self._create_program(custom_properties={"new": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_new"])
def test_is_live_true_when_flag_set(self):
"""is_live should be True when custom_properties has 'live' flag."""
program = self._create_program(custom_properties={"live": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_live"])
def test_is_premiere_true_when_flag_set(self):
"""is_premiere should be True when custom_properties has 'premiere' flag."""
program = self._create_program(custom_properties={"premiere": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_premiere"])
def test_flags_false_when_not_set(self):
"""All boolean flags should be False when not in custom_properties."""
program = self._create_program(custom_properties={"season": 1})
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_flags_false_when_custom_properties_none(self):
"""All boolean flags should be False when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_flags_false_when_custom_properties_empty(self):
"""All boolean flags should be False when custom_properties is empty."""
program = self._create_program(custom_properties={})
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_multiple_flags_set(self):
"""Multiple flags can be true simultaneously."""
program = self._create_program(
custom_properties={"new": True, "live": True, "premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_new"])
self.assertTrue(data["is_live"])
self.assertTrue(data["is_premiere"])
def test_flags_with_season_episode(self):
"""Flags should work alongside season/episode data."""
program = self._create_program(
custom_properties={"season": 5, "episode": 1, "new": True, "premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 5)
self.assertEqual(data["episode"], 1)
self.assertTrue(data["is_new"])
self.assertFalse(data["is_live"])
self.assertTrue(data["is_premiere"])
def test_is_finale_from_premiere_text_season_finale(self):
"""is_finale should be True when premiere_text contains 'Season Finale'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Season Finale"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_from_premiere_text_series_finale(self):
"""is_finale should be True when premiere_text contains 'Series Finale'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Series Finale"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_case_insensitive(self):
"""is_finale detection should be case-insensitive."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "SEASON FINALE"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_false_for_premiere_text(self):
"""is_finale should be False when premiere_text is 'Season Premiere'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Season Premiere"}
)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
def test_is_finale_false_when_no_premiere_text(self):
"""is_finale should be False when premiere_text is absent."""
program = self._create_program(
custom_properties={"premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
def test_is_finale_false_when_custom_properties_none(self):
"""is_finale should be False when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
class ExtractSeasonEpisodeHelperTests(TestCase):
"""Tests for the shared extract_season_episode helper function."""
def test_both_present(self):
season, episode = extract_season_episode({"season": 3, "episode": 5})
self.assertEqual(season, 3)
self.assertEqual(episode, 5)
def test_fallback_to_onscreen(self):
season, episode = extract_season_episode({"onscreen_episode": "S12 E6"})
self.assertEqual(season, 12)
self.assertEqual(episode, 6)
def test_direct_values_override_onscreen(self):
season, episode = extract_season_episode({
"season": 1, "episode": 2, "onscreen_episode": "S99 E99"
})
self.assertEqual(season, 1)
self.assertEqual(episode, 2)
def test_empty_dict(self):
season, episode = extract_season_episode({})
self.assertIsNone(season)
self.assertIsNone(episode)
def test_partial_with_onscreen_fill(self):
"""Direct season + onscreen episode fills the gap."""
season, episode = extract_season_episode({
"season": 5, "onscreen_episode": "S5E10"
})
self.assertEqual(season, 5)
self.assertEqual(episode, 10)
def test_description_fallback_s_e_format(self):
"""S01E01 in description should be used as third-tier fallback."""
season, episode = extract_season_episode({}, description="S2 E31 The Episode Title")
self.assertEqual(season, 2)
self.assertEqual(episode, 31)
def test_description_fallback_season_episode_format(self):
season, episode = extract_season_episode({}, description="Season 3 Episode 12 Some Title")
self.assertEqual(season, 3)
self.assertEqual(episode, 12)
def test_description_fallback_nxnn_format(self):
season, episode = extract_season_episode({}, description="5x03 Episode Name")
self.assertEqual(season, 5)
self.assertEqual(episode, 3)
def test_description_not_used_when_cp_has_both(self):
"""Description fallback should not override existing custom_properties values."""
season, episode = extract_season_episode(
{"season": 1, "episode": 2}, description="S99 E99 Fake"
)
self.assertEqual(season, 1)
self.assertEqual(episode, 2)
def test_description_not_used_when_onscreen_provides_both(self):
season, episode = extract_season_episode(
{"onscreen_episode": "S3E5"}, description="S99 E99 Fake"
)
self.assertEqual(season, 3)
self.assertEqual(episode, 5)
def test_description_fills_gap_after_partial_onscreen(self):
"""If onscreen provides only season, description can fill episode."""
# onscreen_episode "S5" doesn't match the S/E pattern, so no values from onscreen
# description provides both
season, episode = extract_season_episode(
{"season": 5}, description="S5 E10 Title"
)
self.assertEqual(season, 5)
self.assertEqual(episode, 10)
def test_description_none_is_safe(self):
season, episode = extract_season_episode({}, description=None)
self.assertIsNone(season)
self.assertIsNone(episode)
def test_description_empty_string_is_safe(self):
season, episode = extract_season_episode({}, description="")
self.assertIsNone(season)
self.assertIsNone(episode)
class ExtractSeasonEpisodeFromDescriptionTests(TestCase):
"""Tests for extract_season_episode_from_description() in tasks.py."""
def test_s_e_compact(self):
s, e, cleaned = extract_season_episode_from_description("S2E31 The Kevin Episode")
self.assertEqual(s, 2)
self.assertEqual(e, 31)
self.assertEqual(cleaned, "The Kevin Episode")
def test_s_e_with_space(self):
s, e, cleaned = extract_season_episode_from_description("S2 E31 The Kevin Episode")
self.assertEqual(s, 2)
self.assertEqual(e, 31)
self.assertEqual(cleaned, "The Kevin Episode")
def test_season_episode_words(self):
s, e, cleaned = extract_season_episode_from_description("Season 3 Episode 12 Title Here")
self.assertEqual(s, 3)
self.assertEqual(e, 12)
self.assertEqual(cleaned, "Title Here")
def test_nxnn_format(self):
s, e, cleaned = extract_season_episode_from_description("5x03 Episode Name")
self.assertEqual(s, 5)
self.assertEqual(e, 3)
self.assertEqual(cleaned, "Episode Name")
def test_leading_dash(self):
s, e, cleaned = extract_season_episode_from_description("- S1E5 Title")
self.assertEqual(s, 1)
self.assertEqual(e, 5)
self.assertEqual(cleaned, "Title")
def test_case_insensitive(self):
s, e, cleaned = extract_season_episode_from_description("s10e20 Lower Case")
self.assertEqual(s, 10)
self.assertEqual(e, 20)
self.assertEqual(cleaned, "Lower Case")
def test_no_match_returns_original(self):
s, e, cleaned = extract_season_episode_from_description("Just a normal description")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "Just a normal description")
def test_none_input(self):
s, e, cleaned = extract_season_episode_from_description(None)
self.assertIsNone(s)
self.assertIsNone(e)
self.assertIsNone(cleaned)
def test_empty_string(self):
s, e, cleaned = extract_season_episode_from_description("")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "")
def test_mid_string_s_e_not_matched(self):
"""S/E in middle of description should NOT be matched (anchored to start)."""
s, e, cleaned = extract_season_episode_from_description("Some intro text S1E5 title")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "Some intro text S1E5 title")
class ProgramDataSerializerDescriptionFallbackTests(TestCase):
"""Integration tests: serializer uses description fallback for S/E."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_se_from_description_when_no_cp(self):
program = self._create_program(
custom_properties={},
description="S2 E5 The Episode Title",
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 2)
self.assertEqual(data["episode"], 5)
def test_se_from_description_not_used_when_cp_has_values(self):
program = self._create_program(
custom_properties={"season": 1, "episode": 1},
description="S99 E99 Fake",
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 1)
self.assertEqual(data["episode"], 1)
class ProgramDetailSerializerTests(TestCase):
"""Tests for ProgramDetailSerializer — rich field extraction from custom_properties."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_all_detail_fields_present(self):
"""Detail serializer should include all expected fields."""
program = self._create_program(custom_properties={"season": 1, "episode": 1})
data = ProgramDetailSerializer(program).data
expected_fields = {
"id", "start_time", "end_time", "title", "sub_title", "description", "tvg_id",
"season", "episode", "is_new", "is_live", "is_premiere", "is_finale",
"categories", "rating", "rating_system", "star_ratings",
"credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown",
"country", "language", "production_date", "original_air_date",
"imdb_id", "tmdb_id", "tvdb_id", "icon", "images",
}
self.assertEqual(set(data.keys()), expected_fields)
def test_categories_extraction(self):
program = self._create_program(
custom_properties={"categories": ["Drama", "Thriller"]}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["categories"], ["Drama", "Thriller"])
def test_categories_empty_when_absent(self):
program = self._create_program(custom_properties={})
data = ProgramDetailSerializer(program).data
self.assertEqual(data["categories"], [])
def test_rating_extraction(self):
program = self._create_program(
custom_properties={"rating": "TV-14", "rating_system": "VCHIP"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["rating"], "TV-14")
self.assertEqual(data["rating_system"], "VCHIP")
def test_star_ratings_extraction(self):
program = self._create_program(
custom_properties={"star_ratings": [{"value": "8.5/10", "system": "IMDB"}]}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(len(data["star_ratings"]), 1)
self.assertEqual(data["star_ratings"][0]["value"], "8.5/10")
def test_credits_extraction(self):
program = self._create_program(
custom_properties={
"credits": {
"actor": [{"name": "Bryan Cranston", "role": "Walter White"}],
"director": ["Rian Johnson"],
"writer": ["Moira Walley-Beckett"],
}
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(len(data["credits"]["actors"]), 1)
self.assertEqual(data["credits"]["actors"][0]["name"], "Bryan Cranston")
self.assertEqual(data["credits"]["directors"], ["Rian Johnson"])
self.assertEqual(data["credits"]["writers"], ["Moira Walley-Beckett"])
def test_credits_empty_when_absent(self):
program = self._create_program(custom_properties={})
data = ProgramDetailSerializer(program).data
self.assertEqual(data["credits"]["actors"], [])
self.assertEqual(data["credits"]["directors"], [])
def test_video_quality_extraction(self):
program = self._create_program(
custom_properties={"video": {"quality": "HDTV", "aspect": "16:9"}}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["video_quality"], "HDTV")
self.assertEqual(data["aspect_ratio"], "16:9")
def test_audio_extraction(self):
program = self._create_program(
custom_properties={"audio": {"stereo": "Dolby Digital"}}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["stereo"], "Dolby Digital")
def test_geographic_fields(self):
program = self._create_program(
custom_properties={"country": "US", "language": "en"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["country"], "US")
self.assertEqual(data["language"], "en")
def test_original_air_date(self):
program = self._create_program(
custom_properties={
"previously_shown_details": {"start": "2013-09-15"}
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["original_air_date"], "2013-09-15")
def test_external_ids(self):
program = self._create_program(
custom_properties={
"imdb.com_id": "tt0903747",
"themoviedb.org_id": "1396",
"thetvdb.com_id": "81189",
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["imdb_id"], "tt0903747")
self.assertEqual(data["tmdb_id"], "1396")
self.assertEqual(data["tvdb_id"], "81189")
def test_images_extraction(self):
program = self._create_program(
custom_properties={
"icon": "https://example.com/icon.png",
"images": [{"url": "https://example.com/poster.jpg", "type": "poster"}],
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["icon"], "https://example.com/icon.png")
self.assertEqual(len(data["images"]), 1)
def test_null_custom_properties_returns_safe_defaults(self):
"""All enriched fields should be null/empty when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDetailSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
self.assertEqual(data["categories"], [])
self.assertIsNone(data["rating"])
self.assertEqual(data["star_ratings"], [])
self.assertEqual(data["credits"]["actors"], [])
self.assertIsNone(data["video_quality"])
self.assertIsNone(data["country"])
self.assertIsNone(data["imdb_id"])
self.assertEqual(data["images"], [])
def test_season_episode_uses_shared_helper(self):
"""Detail serializer should use the same onscreen_episode fallback."""
program = self._create_program(
custom_properties={"onscreen_episode": "S5E14"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["season"], 5)
self.assertEqual(data["episode"], 14)
def test_status_flags_match_slim_serializer(self):
"""Status flags should produce identical results as ProgramDataSerializer."""
program = self._create_program(
custom_properties={
"new": True, "live": True, "premiere": True,
"premiere_text": "Season Finale",
}
)
slim = ProgramDataSerializer(program).data
detail = ProgramDetailSerializer(program).data
self.assertEqual(slim["is_new"], detail["is_new"])
self.assertEqual(slim["is_live"], detail["is_live"])
self.assertEqual(slim["is_premiere"], detail["is_premiere"])
self.assertEqual(slim["is_finale"], detail["is_finale"])
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import EPGDashboardView, epg_view
app_name = 'epg_dashboard'
urlpatterns = [
path('dashboard/', EPGDashboardView.as_view(), name='epg_dashboard'),
path('guide/', epg_view, name='epg_guide'),
]
+61
View File
@@ -0,0 +1,61 @@
"""
Shared EPG utilities — season/episode extraction.
These live here (rather than in serializers.py or tasks.py) to avoid circular imports:
serializers → tasks and channels/tasks → serializers both need these functions.
"""
import re
# Matches patterns like "S12 E6", "S3E21", "S8 E8 P2/2"
_ONSCREEN_RE = re.compile(r'S(\d+)\s*E(\d+)', re.IGNORECASE)
# Ordered patterns for extracting season/episode from the start of description text.
# Only used as a fallback when <episode-num> XML elements don't provide S/E.
_DESC_SE_PATTERNS = [
# S01E01, S01 E01, S1E1, S1 E1
re.compile(r'^[\s\-:]*S(\d+)\s*E(\d+)[\s\-:.]*', re.IGNORECASE),
# Season 1 Episode 1, Season1 Episode1, Season1Episode1
re.compile(r'^[\s\-:]*Season\s*(\d+)\s*Episode\s*(\d+)[\s\-:.]*', re.IGNORECASE),
# 1x01 format (requires 2+ digit episode to avoid false positives)
re.compile(r'^[\s\-:]*(\d+)x(\d{2,})[\s\-:.]*'),
]
def extract_season_episode_from_description(desc):
"""
Extract season/episode from the beginning of description text.
Returns (season, episode, cleaned_desc).
Returns (None, None, desc) if no pattern matches.
"""
if not desc:
return None, None, desc
for pattern in _DESC_SE_PATTERNS:
match = pattern.match(desc)
if match:
season = int(match.group(1))
episode = int(match.group(2))
cleaned = desc[match.end():].strip()
return season, episode, cleaned
return None, None, desc
def extract_season_episode(cp, description=None):
"""Extract season/episode from custom_properties with onscreen_episode and description fallbacks."""
season = cp.get('season')
episode = cp.get('episode')
if (season is None or episode is None) and cp.get('onscreen_episode'):
match = _ONSCREEN_RE.search(cp['onscreen_episode'])
if match:
if season is None:
season = int(match.group(1))
if episode is None:
episode = int(match.group(2))
# Third fallback: extract S/E from description text
if (season is None or episode is None) and description:
d_season, d_episode, _ = extract_season_episode_from_description(description)
if season is None:
season = d_season
if episode is None:
episode = d_episode
return season, episode
+66
View File
@@ -0,0 +1,66 @@
from django.views import View
from django.shortcuts import render
from django.http import JsonResponse
from rest_framework.parsers import JSONParser
from .models import EPGSource, ProgramData # Updated: import ProgramData instead of Program
from .serializers import EPGSourceSerializer
from django.utils import timezone
from datetime import timedelta
def epg_view(request):
"""
Renders the TV guide using programmes from the next 12 hours,
grouped by channel (via EPGData).
"""
now = timezone.now()
end_time = now + timedelta(hours=12)
print(f"[EPG VIEW] Now: {now} | End Time: {end_time}")
# Query ProgramData within the time range
programmes = ProgramData.objects.filter(
start_time__gte=now,
start_time__lte=end_time
).order_by('start_time')
print(f"[EPG VIEW] Found {programmes.count()} programme(s) between now and end_time.")
# Group programmes by channel (retrieved via the EPGData parent)
channels = {}
for prog in programmes:
# Assume that the EPGData instance (prog.epg) has a link to a Channel.
channel = prog.epg.channel if prog.epg and prog.epg.channel else None
if not channel:
continue
channels.setdefault(channel, []).append(prog)
if not channels:
print("[EPG VIEW] No channels with programmes found.")
else:
for channel, progs in channels.items():
print(f"[EPG VIEW] Channel: {channel} has {len(progs)} programme(s).")
context = {
'channels': channels,
'now': now,
'end_time': end_time,
}
return render(request, 'epg/tvguide.html', context)
class EPGDashboardView(View):
def get(self, request, *args, **kwargs):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
sources = EPGSource.objects.all()
serializer = EPGSourceSerializer(sources, many=True)
return JsonResponse({'data': serializer.data}, safe=False)
return render(request, 'epg/epg.html', {'epg_sources': EPGSource.objects.all()})
def post(self, request, *args, **kwargs):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
data = JSONParser().parse(request)
serializer = EPGSourceSerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse({'success': True, 'data': serializer.data}, status=201)
return JsonResponse({'success': False, 'errors': serializer.errors}, status=400)
return JsonResponse({'success': False, 'error': 'Invalid request.'}, status=400)