mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 09:06:06 +00:00
Add a new task to fetch program artwork from Schedules Direct and auto-apply logos to channels based on EPG data. Enhance the existing logic to handle poster backfill and update program records with fetched artwork. Readd the WebSocket handler to manage IP lookup events and adjust the EPGsTable component to display relevant credentials based on the source type.
866 lines
32 KiB
Python
866 lines
32 KiB
Python
import json
|
|
from datetime import datetime
|
|
|
|
from rest_framework import serializers
|
|
from .models import (
|
|
Stream,
|
|
Channel,
|
|
ChannelGroup,
|
|
ChannelOverride,
|
|
ChannelStream,
|
|
ChannelGroupM3UAccount,
|
|
Logo,
|
|
ChannelProfile,
|
|
ChannelProfileMembership,
|
|
Recording,
|
|
RecurringRecordingRule,
|
|
)
|
|
from apps.epg.serializers import EPGDataSerializer
|
|
from core.models import StreamProfile
|
|
from apps.epg.models import EPGData
|
|
from django.db import connection, transaction
|
|
from django.urls import reverse
|
|
from rest_framework import serializers
|
|
from django.utils import timezone
|
|
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
|
|
|
|
|
class LogoSerializer(serializers.ModelSerializer):
|
|
cache_url = serializers.SerializerMethodField()
|
|
channel_count = serializers.SerializerMethodField()
|
|
is_used = serializers.SerializerMethodField()
|
|
channel_names = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Logo
|
|
fields = ["id", "name", "url", "cache_url", "channel_count", "is_used", "channel_names"]
|
|
|
|
def validate_url(self, value):
|
|
"""Validate that the URL is unique for creation or update"""
|
|
if self.instance and self.instance.url == value:
|
|
return value
|
|
|
|
if Logo.objects.filter(url=value).exists():
|
|
raise serializers.ValidationError("A logo with this URL already exists.")
|
|
|
|
return value
|
|
|
|
def create(self, validated_data):
|
|
"""Handle logo creation with proper URL validation"""
|
|
return Logo.objects.create(**validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
"""Handle logo updates"""
|
|
for attr, value in validated_data.items():
|
|
setattr(instance, attr, value)
|
|
instance.save()
|
|
return instance
|
|
|
|
def get_cache_url(self, obj):
|
|
# Cache-busting: append a short hash of the logo's source URL so the browser
|
|
# fetches fresh when the logo changes (e.g., M3U logo replaced by SD logo).
|
|
# The backend ignores the 'v' parameter — it's purely for browser cache invalidation.
|
|
# See SD integration PR notes for context on why this was added.
|
|
import hashlib
|
|
url_hash = hashlib.md5((obj.url or '').encode()).hexdigest()[:8]
|
|
base_path = reverse("api:channels:logo-cache", args=[obj.id])
|
|
cache_url = f"{base_path}?v={url_hash}"
|
|
request = self.context.get("request")
|
|
if request:
|
|
return build_absolute_uri_with_port(request, cache_url)
|
|
return cache_url
|
|
|
|
def get_channel_count(self, obj):
|
|
"""Get the number of channels using this logo"""
|
|
# `channel_count` is provided as an annotation in LogoViewSet.get_queryset().
|
|
# Fall back to a query only when serializing a single un-annotated Logo
|
|
# (e.g. nested inside ChannelSerializer.get_logo()).
|
|
annotated = getattr(obj, "channel_count", None)
|
|
if annotated is not None:
|
|
return annotated
|
|
return obj.channels.count()
|
|
|
|
def get_is_used(self, obj):
|
|
"""Check if this logo is used by any channels"""
|
|
return self.get_channel_count(obj) > 0
|
|
|
|
def get_channel_names(self, obj):
|
|
"""Get the names of channels using this logo (limited to first 5)"""
|
|
names = []
|
|
|
|
# When LogoViewSet.get_queryset() prefetches `channels`, iterating
|
|
# obj.channels.all() reuses the cached set; slicing happens in Python.
|
|
channels = list(obj.channels.all()[:5])
|
|
for channel in channels:
|
|
names.append(f"Channel: {channel.name}")
|
|
|
|
total_count = self.get_channel_count(obj)
|
|
if total_count > 5:
|
|
names.append(f"...and {total_count - 5} more")
|
|
|
|
return names
|
|
|
|
|
|
#
|
|
# Stream
|
|
#
|
|
class StreamSerializer(serializers.ModelSerializer):
|
|
url = serializers.CharField(
|
|
required=False,
|
|
allow_blank=True,
|
|
allow_null=True,
|
|
validators=[validate_flexible_url]
|
|
)
|
|
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=StreamProfile.objects.all(),
|
|
source="stream_profile",
|
|
allow_null=True,
|
|
required=False,
|
|
)
|
|
read_only_fields = ["is_custom", "m3u_account", "stream_hash", "stream_id", "stream_chno"]
|
|
|
|
class Meta:
|
|
model = Stream
|
|
fields = [
|
|
"id",
|
|
"name",
|
|
"url",
|
|
"m3u_account", # Uncomment if using M3U fields
|
|
"logo_url",
|
|
"tvg_id",
|
|
"local_file",
|
|
"current_viewers",
|
|
"updated_at",
|
|
"last_seen",
|
|
"is_stale",
|
|
"is_adult",
|
|
"stream_profile_id",
|
|
"is_custom",
|
|
"channel_group",
|
|
"stream_hash",
|
|
"stream_stats",
|
|
"stream_stats_updated_at",
|
|
"stream_id",
|
|
"stream_chno",
|
|
]
|
|
|
|
def get_fields(self):
|
|
fields = super().get_fields()
|
|
|
|
# Unable to edit specific properties if this stream was created from an M3U account
|
|
if (
|
|
self.instance
|
|
and getattr(self.instance, "m3u_account", None)
|
|
and not self.instance.is_custom
|
|
):
|
|
fields["id"].read_only = True
|
|
fields["name"].read_only = True
|
|
fields["url"].read_only = True
|
|
fields["m3u_account"].read_only = True
|
|
fields["tvg_id"].read_only = True
|
|
fields["channel_group"].read_only = True
|
|
|
|
return fields
|
|
|
|
|
|
class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
|
m3u_accounts = serializers.IntegerField(source="m3u_accounts.id", read_only=True)
|
|
enabled = serializers.BooleanField()
|
|
auto_channel_sync = serializers.BooleanField(default=False)
|
|
auto_sync_channel_start = serializers.FloatField(
|
|
allow_null=True, required=False, min_value=1
|
|
)
|
|
auto_sync_channel_end = serializers.FloatField(
|
|
allow_null=True, required=False, min_value=1
|
|
)
|
|
custom_properties = serializers.JSONField(required=False)
|
|
# Provider stream count for this group+account. Lets users size an
|
|
# optional end-range without first running a blind sync.
|
|
stream_count = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = ChannelGroupM3UAccount
|
|
fields = [
|
|
"m3u_accounts",
|
|
"channel_group",
|
|
"enabled",
|
|
"auto_channel_sync",
|
|
"auto_sync_channel_start",
|
|
"auto_sync_channel_end",
|
|
"custom_properties",
|
|
"is_stale",
|
|
"last_seen",
|
|
"stream_count",
|
|
]
|
|
|
|
def get_stream_count(self, obj):
|
|
"""
|
|
Return the number of streams for this (m3u_account, channel_group)
|
|
pair. A parent serializer (e.g. M3UAccountSerializer) may seed
|
|
``context["stream_counts"]`` with a pre-aggregated dict keyed by
|
|
``(m3u_account_id, channel_group_id)`` to avoid one COUNT per row;
|
|
when present, it is used as the source of truth. The per-row
|
|
COUNT fallback is correct for stand-alone serialization (rare,
|
|
low-volume) and exists so direct ChannelGroupM3UAccount queries
|
|
do not require callers to know the seeding pattern.
|
|
"""
|
|
counts = self.context.get("stream_counts")
|
|
if counts is not None:
|
|
return counts.get((obj.m3u_account_id, obj.channel_group_id), 0)
|
|
from apps.channels.models import Stream
|
|
|
|
return Stream.objects.filter(
|
|
m3u_account_id=obj.m3u_account_id,
|
|
channel_group_id=obj.channel_group_id,
|
|
).count()
|
|
|
|
def to_representation(self, instance):
|
|
data = super().to_representation(instance)
|
|
|
|
custom_props = instance.custom_properties or {}
|
|
|
|
return data
|
|
|
|
def to_internal_value(self, data):
|
|
# Accept both dict and JSON string for custom_properties (for backward compatibility)
|
|
val = data.get("custom_properties")
|
|
if isinstance(val, str):
|
|
try:
|
|
data["custom_properties"] = json.loads(val)
|
|
except Exception:
|
|
pass
|
|
|
|
return super().to_internal_value(data)
|
|
|
|
def validate(self, attrs):
|
|
# Partial PATCHes only carry submitted fields; fill missing
|
|
# start/end from the instance so the validator catches a PATCH
|
|
# that lowers end past the existing start.
|
|
start = attrs.get("auto_sync_channel_start")
|
|
end = attrs.get("auto_sync_channel_end")
|
|
if start is None and self.instance is not None:
|
|
start = self.instance.auto_sync_channel_start
|
|
if end is None and self.instance is not None:
|
|
end = self.instance.auto_sync_channel_end
|
|
if start is not None and end is not None and end < start:
|
|
raise serializers.ValidationError(
|
|
{
|
|
"auto_sync_channel_end": (
|
|
"End must be greater than or equal to start."
|
|
)
|
|
}
|
|
)
|
|
return super().validate(attrs)
|
|
|
|
#
|
|
# Channel Group
|
|
#
|
|
class ChannelGroupSerializer(serializers.ModelSerializer):
|
|
channel_count = serializers.SerializerMethodField()
|
|
m3u_account_count = serializers.SerializerMethodField()
|
|
m3u_accounts = ChannelGroupM3UAccountSerializer(
|
|
many=True,
|
|
read_only=True
|
|
)
|
|
|
|
class Meta:
|
|
model = ChannelGroup
|
|
fields = ["id", "name", "channel_count", "m3u_account_count", "m3u_accounts"]
|
|
|
|
def get_channel_count(self, obj):
|
|
# Use the queryset annotation when available (list path); fall back
|
|
# to a live query for retrieve/create/update where it isn't set.
|
|
v = getattr(obj, 'channel_count', None)
|
|
return v if v is not None else obj.channels.count()
|
|
|
|
def get_m3u_account_count(self, obj):
|
|
v = getattr(obj, 'm3u_account_count', None)
|
|
return v if v is not None else obj.m3u_accounts.count()
|
|
|
|
|
|
class ChannelProfileSerializer(serializers.ModelSerializer):
|
|
channels = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = ChannelProfile
|
|
fields = ["id", "name", "channels"]
|
|
|
|
def get_channels(self, obj):
|
|
# Use prefetched attr when available, fall back to a direct query.
|
|
memberships = getattr(obj, 'enabled_memberships', None)
|
|
if memberships is not None:
|
|
return [m.channel_id for m in memberships]
|
|
return list(
|
|
ChannelProfileMembership.objects.filter(
|
|
channel_profile=obj, enabled=True
|
|
).values_list('channel_id', flat=True)
|
|
)
|
|
|
|
|
|
class ChannelProfileMembershipSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = ChannelProfileMembership
|
|
fields = ["channel", "enabled"]
|
|
|
|
|
|
class ChanneProfilelMembershipUpdateSerializer(serializers.Serializer):
|
|
channel_id = serializers.IntegerField() # Ensure channel_id is an integer
|
|
enabled = serializers.BooleanField()
|
|
|
|
|
|
class BulkChannelProfileMembershipSerializer(serializers.Serializer):
|
|
channels = serializers.ListField(
|
|
child=ChanneProfilelMembershipUpdateSerializer(), # Use the nested serializer
|
|
allow_empty=False,
|
|
)
|
|
|
|
def validate_channels(self, value):
|
|
if not value:
|
|
raise serializers.ValidationError("At least one channel must be provided.")
|
|
return value
|
|
|
|
|
|
#
|
|
# Channel override
|
|
#
|
|
# Nullable per-field overrides resolved over the parent Channel in read
|
|
# paths. Embedded in ChannelSerializer so clients can upsert/clear in the
|
|
# same PATCH that targets direct channel fields.
|
|
class ChannelOverrideSerializer(serializers.ModelSerializer):
|
|
# HDHR clients reject negative GuideNumber and zero is not a real
|
|
# provider value, so reject both at the API boundary.
|
|
channel_number = serializers.FloatField(
|
|
allow_null=True, required=False, min_value=0.0001
|
|
)
|
|
channel_group_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=ChannelGroup.objects.all(),
|
|
source="channel_group",
|
|
allow_null=True,
|
|
required=False,
|
|
)
|
|
logo_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=Logo.objects.all(),
|
|
source="logo",
|
|
allow_null=True,
|
|
required=False,
|
|
)
|
|
epg_data_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=EPGData.objects.all(),
|
|
source="epg_data",
|
|
allow_null=True,
|
|
required=False,
|
|
)
|
|
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=StreamProfile.objects.all(),
|
|
source="stream_profile",
|
|
allow_null=True,
|
|
required=False,
|
|
)
|
|
|
|
class Meta:
|
|
model = ChannelOverride
|
|
fields = [
|
|
"name",
|
|
"channel_number",
|
|
"channel_group_id",
|
|
"logo_id",
|
|
"tvg_id",
|
|
"tvc_guide_stationid",
|
|
"epg_data_id",
|
|
"stream_profile_id",
|
|
]
|
|
extra_kwargs = {
|
|
"name": {"allow_null": True, "required": False},
|
|
"tvg_id": {"allow_null": True, "required": False},
|
|
"tvc_guide_stationid": {"allow_null": True, "required": False},
|
|
}
|
|
|
|
|
|
#
|
|
# Channel
|
|
#
|
|
class ChannelSerializer(serializers.ModelSerializer):
|
|
# Show nested group data, or ID
|
|
# Ensure channel_number is explicitly typed as FloatField and properly validated
|
|
channel_number = serializers.FloatField(
|
|
allow_null=True,
|
|
required=False,
|
|
error_messages={"invalid": "Channel number must be a valid decimal number."},
|
|
)
|
|
channel_group_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=ChannelGroup.objects.all(), source="channel_group", required=False
|
|
)
|
|
epg_data_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=EPGData.objects.all(),
|
|
source="epg_data",
|
|
required=False,
|
|
allow_null=True,
|
|
)
|
|
|
|
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=StreamProfile.objects.all(),
|
|
source="stream_profile",
|
|
allow_null=True,
|
|
required=False,
|
|
)
|
|
|
|
streams = serializers.PrimaryKeyRelatedField(
|
|
queryset=Stream.objects.all(), many=True, required=False
|
|
)
|
|
|
|
logo_id = serializers.PrimaryKeyRelatedField(
|
|
queryset=Logo.objects.all(),
|
|
source="logo",
|
|
allow_null=True,
|
|
required=False,
|
|
)
|
|
|
|
auto_created_by_name = serializers.SerializerMethodField()
|
|
override = ChannelOverrideSerializer(
|
|
required=False,
|
|
allow_null=True,
|
|
help_text=(
|
|
"Per-field overrides for an auto-created channel. "
|
|
'Send {"override": {"name": "ESPN"}} to upsert the listed '
|
|
'fields, {"override": {"name": null}} to clear specific fields '
|
|
'while leaving others, or {"override": null} to delete the '
|
|
"override row entirely. Omitting the key leaves any existing "
|
|
"override unchanged. Only valid for auto_created=True channels. "
|
|
"Duplicate channel_number values across channels are permitted; "
|
|
"downstream client behavior on duplicates varies by client."
|
|
),
|
|
)
|
|
source_stream = serializers.SerializerMethodField()
|
|
# Effective fields coalesce override over channel column. Consumers
|
|
# display these; raw fields remain in the response so the edit form
|
|
# can show them as "Provider: X" subtext.
|
|
effective_name = serializers.SerializerMethodField()
|
|
effective_channel_number = serializers.SerializerMethodField()
|
|
effective_channel_group_id = serializers.SerializerMethodField()
|
|
effective_logo_id = serializers.SerializerMethodField()
|
|
effective_tvg_id = serializers.SerializerMethodField()
|
|
effective_tvc_guide_stationid = serializers.SerializerMethodField()
|
|
effective_epg_data_id = serializers.SerializerMethodField()
|
|
effective_stream_profile_id = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Channel
|
|
fields = [
|
|
"id",
|
|
"channel_number",
|
|
"name",
|
|
"channel_group_id",
|
|
"tvg_id",
|
|
"tvc_guide_stationid",
|
|
"epg_data_id",
|
|
"streams",
|
|
"stream_profile_id",
|
|
"uuid",
|
|
"logo_id",
|
|
"user_level",
|
|
"is_adult",
|
|
"hidden_from_output",
|
|
"auto_created",
|
|
"auto_created_by",
|
|
"auto_created_by_name",
|
|
"override",
|
|
"source_stream",
|
|
"effective_name",
|
|
"effective_channel_number",
|
|
"effective_channel_group_id",
|
|
"effective_logo_id",
|
|
"effective_tvg_id",
|
|
"effective_tvc_guide_stationid",
|
|
"effective_epg_data_id",
|
|
"effective_stream_profile_id",
|
|
]
|
|
|
|
def _effective_value(self, obj, field_name):
|
|
override = getattr(obj, "_channel_override_cache", None)
|
|
if override is None:
|
|
try:
|
|
override = obj.override
|
|
except ChannelOverride.DoesNotExist:
|
|
override = None
|
|
obj._channel_override_cache = override
|
|
if override is not None:
|
|
value = getattr(override, field_name, None)
|
|
if value is not None:
|
|
return value
|
|
return getattr(obj, field_name, None)
|
|
|
|
def get_effective_name(self, obj):
|
|
return self._effective_value(obj, "name")
|
|
|
|
def get_effective_channel_number(self, obj):
|
|
return self._effective_value(obj, "channel_number")
|
|
|
|
def get_effective_channel_group_id(self, obj):
|
|
return self._effective_value(obj, "channel_group_id")
|
|
|
|
def get_effective_logo_id(self, obj):
|
|
return self._effective_value(obj, "logo_id")
|
|
|
|
def get_effective_tvg_id(self, obj):
|
|
return self._effective_value(obj, "tvg_id")
|
|
|
|
def get_effective_tvc_guide_stationid(self, obj):
|
|
return self._effective_value(obj, "tvc_guide_stationid")
|
|
|
|
def get_effective_epg_data_id(self, obj):
|
|
return self._effective_value(obj, "epg_data_id")
|
|
|
|
def get_effective_stream_profile_id(self, obj):
|
|
return self._effective_value(obj, "stream_profile_id")
|
|
|
|
def get_source_stream(self, obj):
|
|
"""
|
|
Return the originating provider stream for an auto-created channel.
|
|
|
|
Surfaces the provider stream's name and owning M3U account so the
|
|
frontend can render "Auto-created from: <provider> / <stream name>"
|
|
in the channel edit form. Returns None for manual channels.
|
|
"""
|
|
if not self.context.get("include_source_stream", False):
|
|
return None
|
|
if not obj.auto_created:
|
|
return None
|
|
# Viewset prefetches `channelstream_set` ordered by `order`, so
|
|
# `.all()[0]` reuses the cache and returns the lowest-order entry.
|
|
prefetched_list = list(obj.channelstream_set.all())
|
|
if not prefetched_list:
|
|
return None
|
|
cs = prefetched_list[0]
|
|
if not cs.stream:
|
|
return None
|
|
stream = cs.stream
|
|
return {
|
|
"id": stream.id,
|
|
"name": stream.name,
|
|
"account_id": stream.m3u_account_id,
|
|
"account_name": getattr(stream.m3u_account, "name", None),
|
|
}
|
|
|
|
def to_representation(self, instance):
|
|
include_streams = self.context.get("include_streams", False)
|
|
|
|
if include_streams:
|
|
self.fields["streams"] = serializers.SerializerMethodField()
|
|
return super().to_representation(instance)
|
|
else:
|
|
# Read from the prefetched channelstream_set (ordered by the
|
|
# viewset's Prefetch); chaining .order_by() rebuilds the
|
|
# queryset and fires one SELECT per row in list responses.
|
|
representation = super().to_representation(instance)
|
|
if "streams" in representation:
|
|
representation["streams"] = [
|
|
cs.stream_id for cs in instance.channelstream_set.all()
|
|
]
|
|
return representation
|
|
|
|
def get_logo(self, obj):
|
|
return LogoSerializer(obj.logo).data
|
|
|
|
def get_streams(self, obj):
|
|
"""Retrieve ordered stream IDs for GET requests."""
|
|
return StreamSerializer(
|
|
obj.streams.all().order_by("channelstream__order"), many=True
|
|
).data
|
|
|
|
def create(self, validated_data):
|
|
streams = validated_data.pop("streams", [])
|
|
override_data = validated_data.pop("override", None)
|
|
channel_number = validated_data.pop(
|
|
"channel_number", Channel.get_next_available_channel_number()
|
|
)
|
|
validated_data["channel_number"] = channel_number
|
|
|
|
# Auto-assign Default Group if no channel_group is specified
|
|
if "channel_group" not in validated_data or validated_data.get("channel_group") is None:
|
|
from apps.channels.models import ChannelGroup
|
|
default_group, _ = ChannelGroup.objects.get_or_create(name="Default Group")
|
|
validated_data["channel_group"] = default_group
|
|
|
|
# Atomic wrapper keeps the channel insert and its override row
|
|
# in the same transaction so a failure on either rolls both back.
|
|
with transaction.atomic():
|
|
channel = Channel.objects.create(**validated_data)
|
|
|
|
# Add streams in the specified order
|
|
for index, stream in enumerate(streams):
|
|
ChannelStream.objects.create(
|
|
channel=channel, stream_id=stream.id, order=index
|
|
)
|
|
|
|
if override_data:
|
|
# Manual channels (auto_created=False) have no provider
|
|
# value to override; reject the override payload here so a
|
|
# programmatic client can't write a semantically meaningless
|
|
# row that the frontend would then surface as "Overrides
|
|
# active".
|
|
if not channel.auto_created:
|
|
raise serializers.ValidationError(
|
|
{
|
|
"override": (
|
|
"Cannot set override on a manual channel; "
|
|
"overrides only apply to auto-created channels."
|
|
)
|
|
}
|
|
)
|
|
obj = ChannelOverride.objects.create(channel=channel, **override_data)
|
|
# Drop an all-null override row; an empty override would
|
|
# falsely surface as active in the UI.
|
|
if not obj.has_any_override():
|
|
obj.delete()
|
|
|
|
return channel
|
|
|
|
def update(self, instance, validated_data):
|
|
"""
|
|
PATCH handler for Channel rows. The ``override`` key carries
|
|
per-field user overrides for auto-created channels and follows
|
|
these rules:
|
|
|
|
* key absent from payload: no change to existing overrides
|
|
* ``{"override": {"field": value}}``: upsert those fields
|
|
* ``{"override": {"field": null}}``: clear those specific fields
|
|
* ``{"override": null}``: delete the override row entirely
|
|
|
|
Key presence is what distinguishes "no change" from "delete";
|
|
an explicit null means delete. Override mutations are rejected
|
|
on manual channels (auto_created=False) since there is no
|
|
provider value to override.
|
|
"""
|
|
streams = validated_data.pop("streams", None)
|
|
has_override_key = "override" in self.initial_data
|
|
override_data = validated_data.pop("override", None)
|
|
|
|
# Block override mutations on manual channels (no provider
|
|
# value to override). Clearing is a tolerated no-op.
|
|
if (
|
|
has_override_key
|
|
and override_data is not None
|
|
and override_data != {}
|
|
and not instance.auto_created
|
|
):
|
|
raise serializers.ValidationError(
|
|
{
|
|
"override": (
|
|
"Cannot set override on a manual channel; "
|
|
"overrides only apply to auto-created channels."
|
|
)
|
|
}
|
|
)
|
|
|
|
# Atomic so a failure on the override row rolls back the
|
|
# channel update too.
|
|
with transaction.atomic():
|
|
# Skip save() when only override keys were submitted; a
|
|
# no-op UPDATE would bump updated_at and bust caches.
|
|
if validated_data:
|
|
for attr, value in validated_data.items():
|
|
setattr(instance, attr, value)
|
|
instance.save()
|
|
|
|
if has_override_key:
|
|
if override_data is None:
|
|
# Explicit null: remove the override row.
|
|
ChannelOverride.objects.filter(channel=instance).delete()
|
|
elif override_data == {}:
|
|
# Empty dict has no field intent; no-op.
|
|
pass
|
|
else:
|
|
obj, _ = ChannelOverride.objects.update_or_create(
|
|
channel=instance, defaults=override_data
|
|
)
|
|
# Drop an all-null override; would falsely surface
|
|
# as active in the UI.
|
|
if not obj.has_any_override():
|
|
obj.delete()
|
|
# Queryset writes leave the reverse-OneToOne cache stale;
|
|
# clear it so to_representation reads the new state.
|
|
try:
|
|
instance._state.fields_cache.pop("override", None)
|
|
except AttributeError:
|
|
pass
|
|
if hasattr(instance, "_channel_override_cache"):
|
|
delattr(instance, "_channel_override_cache")
|
|
|
|
if streams is not None:
|
|
# Normalize stream IDs
|
|
normalized_ids = [
|
|
stream.id if hasattr(stream, "id") else stream for stream in streams
|
|
]
|
|
|
|
# Get current mapping of stream_id -> ChannelStream
|
|
current_links = {
|
|
cs.stream_id: cs for cs in instance.channelstream_set.all()
|
|
}
|
|
|
|
# Track existing stream IDs
|
|
existing_ids = set(current_links.keys())
|
|
new_ids = set(normalized_ids)
|
|
|
|
# Delete any links not in the new list
|
|
to_remove = existing_ids - new_ids
|
|
if to_remove:
|
|
instance.channelstream_set.filter(stream_id__in=to_remove).delete()
|
|
|
|
# Update or create with new order
|
|
to_update = []
|
|
for order, stream_id in enumerate(normalized_ids):
|
|
if stream_id in current_links:
|
|
cs = current_links[stream_id]
|
|
if cs.order != order:
|
|
cs.order = order
|
|
to_update.append(cs)
|
|
else:
|
|
ChannelStream.objects.create(
|
|
channel=instance, stream_id=stream_id, order=order
|
|
)
|
|
|
|
if to_update:
|
|
ChannelStream.objects.bulk_update(to_update, ["order"])
|
|
|
|
return instance
|
|
|
|
def validate_channel_number(self, value):
|
|
"""Ensure channel_number is properly processed as a float"""
|
|
if value is None:
|
|
return value
|
|
|
|
try:
|
|
# Ensure it's processed as a float
|
|
return float(value)
|
|
except (ValueError, TypeError):
|
|
raise serializers.ValidationError(
|
|
"Channel number must be a valid decimal number."
|
|
)
|
|
|
|
def validate_stream_profile(self, value):
|
|
"""Handle special case where empty/0 values mean 'use default' (null)"""
|
|
if value == "0" or value == 0 or value == "" or value is None:
|
|
return None
|
|
return value # PrimaryKeyRelatedField will handle the conversion to object
|
|
|
|
def get_auto_created_by_name(self, obj):
|
|
"""Get the name of the M3U account that auto-created this channel."""
|
|
if obj.auto_created_by:
|
|
return obj.auto_created_by.name
|
|
return None
|
|
|
|
|
|
class RecordingSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Recording
|
|
fields = "__all__"
|
|
read_only_fields = ["task_id"]
|
|
|
|
def validate(self, data):
|
|
from core.models import CoreSettings
|
|
start_time = data.get("start_time")
|
|
end_time = data.get("end_time")
|
|
|
|
if start_time and timezone.is_naive(start_time):
|
|
start_time = timezone.make_aware(start_time, timezone.get_current_timezone())
|
|
data["start_time"] = start_time
|
|
if end_time and timezone.is_naive(end_time):
|
|
end_time = timezone.make_aware(end_time, timezone.get_current_timezone())
|
|
data["end_time"] = end_time
|
|
|
|
# If this is an EPG-based recording (program provided), apply global pre/post offsets
|
|
try:
|
|
cp = data.get("custom_properties") or {}
|
|
is_epg_based = isinstance(cp, dict) and isinstance(cp.get("program"), (dict,))
|
|
except Exception:
|
|
is_epg_based = False
|
|
|
|
if is_epg_based and start_time and end_time:
|
|
try:
|
|
pre_min = int(CoreSettings.get_dvr_pre_offset_minutes())
|
|
except Exception:
|
|
pre_min = 0
|
|
try:
|
|
post_min = int(CoreSettings.get_dvr_post_offset_minutes())
|
|
except Exception:
|
|
post_min = 0
|
|
from datetime import timedelta
|
|
try:
|
|
if pre_min and pre_min > 0:
|
|
start_time = start_time - timedelta(minutes=pre_min)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if post_min and post_min > 0:
|
|
end_time = end_time + timedelta(minutes=post_min)
|
|
except Exception:
|
|
pass
|
|
# write back adjusted times so scheduling uses them
|
|
data["start_time"] = start_time
|
|
data["end_time"] = end_time
|
|
|
|
now = timezone.now() # timezone-aware current time
|
|
|
|
if end_time < now:
|
|
raise serializers.ValidationError("End time must be in the future.")
|
|
|
|
if start_time < now:
|
|
# Optional: Adjust start_time if it's in the past but end_time is in the future
|
|
data["start_time"] = now # or: timezone.now() + timedelta(seconds=1)
|
|
if end_time <= data["start_time"]:
|
|
raise serializers.ValidationError("End time must be after start time.")
|
|
|
|
return data
|
|
|
|
|
|
class RecurringRecordingRuleSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = RecurringRecordingRule
|
|
fields = "__all__"
|
|
read_only_fields = ["created_at", "updated_at"]
|
|
|
|
def validate_days_of_week(self, value):
|
|
if not value:
|
|
raise serializers.ValidationError("Select at least one day of the week")
|
|
cleaned = []
|
|
for entry in value:
|
|
try:
|
|
iv = int(entry)
|
|
except (TypeError, ValueError):
|
|
raise serializers.ValidationError("Days of week must be integers 0-6")
|
|
if iv < 0 or iv > 6:
|
|
raise serializers.ValidationError("Days of week must be between 0 (Monday) and 6 (Sunday)")
|
|
cleaned.append(iv)
|
|
return sorted(set(cleaned))
|
|
|
|
def validate(self, attrs):
|
|
start = attrs.get("start_time") or getattr(self.instance, "start_time", None)
|
|
end = attrs.get("end_time") or getattr(self.instance, "end_time", None)
|
|
start_date = attrs.get("start_date") if "start_date" in attrs else getattr(self.instance, "start_date", None)
|
|
end_date = attrs.get("end_date") if "end_date" in attrs else getattr(self.instance, "end_date", None)
|
|
if start_date is None:
|
|
existing_start = getattr(self.instance, "start_date", None)
|
|
if existing_start is None:
|
|
raise serializers.ValidationError("Start date is required")
|
|
if start_date and end_date and end_date < start_date:
|
|
raise serializers.ValidationError("End date must be on or after start date")
|
|
if end_date is None:
|
|
existing_end = getattr(self.instance, "end_date", None)
|
|
if existing_end is None:
|
|
raise serializers.ValidationError("End date is required")
|
|
if start and end and start_date and end_date:
|
|
start_dt = datetime.combine(start_date, start)
|
|
end_dt = datetime.combine(end_date, end)
|
|
if end_dt <= start_dt:
|
|
raise serializers.ValidationError("End datetime must be after start datetime")
|
|
elif start and end and end == start:
|
|
raise serializers.ValidationError("End time must be different from start time")
|
|
# Normalize empty strings to None for dates
|
|
if attrs.get("end_date") == "":
|
|
attrs["end_date"] = None
|
|
if attrs.get("start_date") == "":
|
|
attrs["start_date"] = None
|
|
return super().validate(attrs)
|
|
|
|
def create(self, validated_data):
|
|
return super().create(validated_data)
|