diff --git a/apps/accounts/migrations/0003_alter_user_custom_properties.py b/apps/accounts/migrations/0003_alter_user_custom_properties.py
new file mode 100644
index 00000000..20411f75
--- /dev/null
+++ b/apps/accounts/migrations/0003_alter_user_custom_properties.py
@@ -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 = [
+ ('accounts', '0002_remove_user_channel_groups_user_channel_profiles_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='user',
+ name='custom_properties',
+ field=models.JSONField(blank=True, default=dict, null=True),
+ ),
+ ]
diff --git a/apps/accounts/migrations/0004_auto_20250902_0931.py b/apps/accounts/migrations/0004_auto_20250902_0931.py
new file mode 100644
index 00000000..b082b69e
--- /dev/null
+++ b/apps/accounts/migrations/0004_auto_20250902_0931.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.2.4 on 2025-09-02 14:31
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0003_alter_user_custom_properties'),
+ ]
+
+ operations = [
+ ]
diff --git a/apps/accounts/models.py b/apps/accounts/models.py
index cbaa0f5e..da5e36bc 100644
--- a/apps/accounts/models.py
+++ b/apps/accounts/models.py
@@ -21,7 +21,7 @@ class User(AbstractUser):
related_name="users",
)
user_level = models.IntegerField(default=UserLevel.STREAMER)
- custom_properties = models.TextField(null=True, blank=True)
+ custom_properties = models.JSONField(default=dict, blank=True, null=True)
def __str__(self):
return self.username
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index e96f4d2a..3b91d42c 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -537,9 +537,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
name = stream.name
# Check if client provided a channel_number; if not, auto-assign one.
- stream_custom_props = (
- json.loads(stream.custom_properties) if stream.custom_properties else {}
- )
+ stream_custom_props = stream.custom_properties or {}
channel_number = None
if "tvg-chno" in stream_custom_props:
@@ -734,9 +732,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
channel_group = stream.channel_group
- stream_custom_props = (
- json.loads(stream.custom_properties) if stream.custom_properties else {}
- )
+ stream_custom_props = stream.custom_properties or {}
channel_number = None
if "tvg-chno" in stream_custom_props:
diff --git a/apps/channels/migrations/0025_alter_channelgroupm3uaccount_custom_properties_and_more.py b/apps/channels/migrations/0025_alter_channelgroupm3uaccount_custom_properties_and_more.py
new file mode 100644
index 00000000..980682cb
--- /dev/null
+++ b/apps/channels/migrations/0025_alter_channelgroupm3uaccount_custom_properties_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.2.4 on 2025-09-02 14:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dispatcharr_channels', '0024_alter_channelgroupm3uaccount_channel_group'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='channelgroupm3uaccount',
+ name='custom_properties',
+ field=models.JSONField(blank=True, default=dict, null=True),
+ ),
+ migrations.AlterField(
+ model_name='recording',
+ name='custom_properties',
+ field=models.JSONField(blank=True, default=dict, null=True),
+ ),
+ migrations.AlterField(
+ model_name='stream',
+ name='custom_properties',
+ field=models.JSONField(blank=True, default=dict, null=True),
+ ),
+ ]
diff --git a/apps/channels/models.py b/apps/channels/models.py
index 13cf0f54..0a4f72e7 100644
--- a/apps/channels/models.py
+++ b/apps/channels/models.py
@@ -94,7 +94,7 @@ class Stream(models.Model):
db_index=True,
)
last_seen = models.DateTimeField(db_index=True, default=datetime.now)
- custom_properties = models.TextField(null=True, blank=True)
+ custom_properties = models.JSONField(default=dict, blank=True, null=True)
# Stream statistics fields
stream_stats = models.JSONField(
@@ -565,7 +565,7 @@ class ChannelGroupM3UAccount(models.Model):
m3u_account = models.ForeignKey(
M3UAccount, on_delete=models.CASCADE, related_name="channel_group"
)
- custom_properties = models.TextField(null=True, blank=True)
+ custom_properties = models.JSONField(default=dict, blank=True, null=True)
enabled = models.BooleanField(default=True)
auto_channel_sync = models.BooleanField(
default=False,
@@ -599,7 +599,7 @@ class Recording(models.Model):
start_time = models.DateTimeField()
end_time = models.DateTimeField()
task_id = models.CharField(max_length=255, null=True, blank=True)
- custom_properties = models.TextField(null=True, blank=True)
+ custom_properties = models.JSONField(default=dict, blank=True, null=True)
def __str__(self):
return f"{self.channel.name} - {self.start_time} to {self.end_time}"
diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py
index 0b29353e..d7da52a5 100644
--- a/apps/channels/serializers.py
+++ b/apps/channels/serializers.py
@@ -211,17 +211,12 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
def to_representation(self, instance):
data = super().to_representation(instance)
- custom_props = {}
- if instance.custom_properties:
- try:
- custom_props = json.loads(instance.custom_properties)
- except (json.JSONDecodeError, TypeError):
- custom_props = {}
+ custom_props = instance.custom_properties or {}
return data
def to_internal_value(self, data):
- # Accept both dict and JSON string for custom_properties
+ # Accept both dict and JSON string for custom_properties (for backward compatibility)
val = data.get("custom_properties")
if isinstance(val, str):
try:
diff --git a/apps/epg/migrations/0015_alter_programdata_custom_properties.py b/apps/epg/migrations/0015_alter_programdata_custom_properties.py
new file mode 100644
index 00000000..f33aa97f
--- /dev/null
+++ b/apps/epg/migrations/0015_alter_programdata_custom_properties.py
@@ -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),
+ ),
+ ]
diff --git a/apps/epg/models.py b/apps/epg/models.py
index 8abfb26f..22f2bd28 100644
--- a/apps/epg/models.py
+++ b/apps/epg/models.py
@@ -150,7 +150,7 @@ class ProgramData(models.Model):
sub_title = models.CharField(max_length=255, 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.TextField(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})"
diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py
index 087d9fba..eaea634e 100644
--- a/apps/epg/tasks.py
+++ b/apps/epg/tasks.py
@@ -1234,10 +1234,7 @@ def parse_programs_for_tvg_id(epg_id):
if custom_props:
logger.trace(f"Number of custom properties: {len(custom_props)}")
- try:
- custom_properties_json = json.dumps(custom_props)
- except Exception as e:
- logger.error(f"Error serializing custom properties to JSON: {e}", exc_info=True)
+ custom_properties_json = custom_props
programs_to_create.append(ProgramData(
epg=epg,
diff --git a/apps/m3u/admin.py b/apps/m3u/admin.py
index dd5986eb..1b77decc 100644
--- a/apps/m3u/admin.py
+++ b/apps/m3u/admin.py
@@ -43,11 +43,8 @@ class M3UAccountAdmin(admin.ModelAdmin):
def vod_enabled_display(self, obj):
"""Display whether VOD is enabled for this account"""
if obj.custom_properties:
- try:
- custom_props = json.loads(obj.custom_properties)
- return "Yes" if custom_props.get('enable_vod', False) else "No"
- except (json.JSONDecodeError, TypeError):
- pass
+ custom_props = obj.custom_properties or {}
+ return "Yes" if custom_props.get('enable_vod', False) else "No"
return "No"
vod_enabled_display.short_description = "VOD Enabled"
vod_enabled_display.boolean = True
diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py
index 65fb1c0a..da975fd9 100644
--- a/apps/m3u/api_views.py
+++ b/apps/m3u/api_views.py
@@ -100,11 +100,8 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
# Check current VOD setting
if instance.custom_properties:
- try:
- custom_props = json.loads(instance.custom_properties)
- old_vod_enabled = custom_props.get("enable_vod", False)
- except (json.JSONDecodeError, TypeError):
- pass
+ custom_props = instance.custom_properties or {}
+ old_vod_enabled = custom_props.get("enable_vod", False)
# Handle file upload first, if any
file_path = None
@@ -187,11 +184,8 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
# Check if VOD is enabled
vod_enabled = False
if account.custom_properties:
- try:
- custom_props = json.loads(account.custom_properties)
- vod_enabled = custom_props.get("enable_vod", False)
- except (json.JSONDecodeError, TypeError):
- pass
+ custom_props = account.custom_properties or {}
+ vod_enabled = custom_props.get("enable_vod", False)
if not vod_enabled:
return Response(
@@ -236,11 +230,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
"enabled": enabled,
"auto_channel_sync": auto_sync,
"auto_sync_channel_start": sync_start,
- "custom_properties": (
- custom_properties
- if isinstance(custom_properties, str)
- else json.dumps(custom_properties)
- ),
+ "custom_properties": custom_properties,
},
)
@@ -255,11 +245,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
m3u_account=account,
defaults={
"enabled": enabled,
- "custom_properties": (
- custom_properties
- if isinstance(custom_properties, str)
- else json.dumps(custom_properties)
- ),
+ "custom_properties": custom_properties,
},
)
diff --git a/apps/m3u/forms.py b/apps/m3u/forms.py
index dc29188a..cf6586c3 100644
--- a/apps/m3u/forms.py
+++ b/apps/m3u/forms.py
@@ -28,12 +28,8 @@ class M3UAccountForm(forms.ModelForm):
# Set initial value for enable_vod from custom_properties
if self.instance and self.instance.custom_properties:
- try:
- import json
- custom_props = json.loads(self.instance.custom_properties)
- self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
- except (json.JSONDecodeError, TypeError):
- pass
+ custom_props = self.instance.custom_properties or {}
+ self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
def save(self, commit=True):
instance = super().save(commit=False)
@@ -42,17 +38,11 @@ class M3UAccountForm(forms.ModelForm):
enable_vod = self.cleaned_data.get('enable_vod', False)
# Parse existing custom_properties
- custom_props = {}
- if instance.custom_properties:
- try:
- import json
- custom_props = json.loads(instance.custom_properties)
- except (json.JSONDecodeError, TypeError):
- custom_props = {}
+ custom_props = instance.custom_properties or {}
# Update VOD preference
custom_props['enable_vod'] = enable_vod
- instance.custom_properties = json.dumps(custom_props)
+ instance.custom_properties = custom_props
if commit:
instance.save()
diff --git a/apps/m3u/migrations/0017_alter_m3uaccount_custom_properties_and_more.py b/apps/m3u/migrations/0017_alter_m3uaccount_custom_properties_and_more.py
new file mode 100644
index 00000000..28122ab9
--- /dev/null
+++ b/apps/m3u/migrations/0017_alter_m3uaccount_custom_properties_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.4 on 2025-09-02 14:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('m3u', '0016_m3uaccount_priority'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='m3uaccount',
+ name='custom_properties',
+ field=models.JSONField(blank=True, default=dict, null=True),
+ ),
+ migrations.AlterField(
+ model_name='m3ufilter',
+ name='custom_properties',
+ field=models.JSONField(blank=True, default=dict, null=True),
+ ),
+ ]
diff --git a/apps/m3u/models.py b/apps/m3u/models.py
index cfcc3646..abeb3c06 100644
--- a/apps/m3u/models.py
+++ b/apps/m3u/models.py
@@ -85,7 +85,7 @@ class M3UAccount(models.Model):
account_type = models.CharField(choices=Types.choices, default=Types.STADNARD)
username = models.CharField(max_length=255, null=True, blank=True)
password = models.CharField(max_length=255, null=True, blank=True)
- custom_properties = models.TextField(null=True, blank=True)
+ custom_properties = models.JSONField(default=dict, blank=True, null=True)
refresh_interval = models.IntegerField(default=0)
refresh_task = models.ForeignKey(
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
@@ -184,7 +184,7 @@ class M3UFilter(models.Model):
help_text="If True, matching items are excluded; if False, only matches are included.",
)
order = models.PositiveIntegerField(default=0)
- custom_properties = models.TextField(null=True, blank=True)
+ custom_properties = models.JSONField(default=dict, blank=True, null=True)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == "group" else stream_name
diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py
index 1a62080b..6e5882a7 100644
--- a/apps/m3u/serializers.py
+++ b/apps/m3u/serializers.py
@@ -129,12 +129,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
data = super().to_representation(instance)
# Parse custom_properties to get VOD preference
- custom_props = {}
- if instance.custom_properties:
- try:
- custom_props = json.loads(instance.custom_properties)
- except (json.JSONDecodeError, TypeError):
- custom_props = {}
+ custom_props = instance.custom_properties or {}
data["enable_vod"] = custom_props.get("enable_vod", False)
return data
@@ -144,17 +139,12 @@ class M3UAccountSerializer(serializers.ModelSerializer):
enable_vod = validated_data.pop("enable_vod", None)
if enable_vod is not None:
- # Parse existing custom_properties
- custom_props = {}
- if instance.custom_properties:
- try:
- custom_props = json.loads(instance.custom_properties)
- except (json.JSONDecodeError, TypeError):
- custom_props = {}
+ # Get existing custom_properties
+ custom_props = instance.custom_properties or {}
# Update VOD preference
custom_props["enable_vod"] = enable_vod
- validated_data["custom_properties"] = json.dumps(custom_props)
+ validated_data["custom_properties"] = custom_props
# Pop out channel group memberships so we can handle them manually
channel_group_data = validated_data.pop("channel_group", [])
@@ -192,16 +182,11 @@ class M3UAccountSerializer(serializers.ModelSerializer):
enable_vod = validated_data.pop("enable_vod", False)
# Parse existing custom_properties or create new
- custom_props = {}
- if validated_data.get("custom_properties"):
- try:
- custom_props = json.loads(validated_data["custom_properties"])
- except (json.JSONDecodeError, TypeError):
- custom_props = {}
+ custom_props = validated_data.get("custom_properties", {})
# Set VOD preference
custom_props["enable_vod"] = enable_vod
- validated_data["custom_properties"] = json.dumps(custom_props)
+ validated_data["custom_properties"] = custom_props
return super().create(validated_data)
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index efce2350..16391eae 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -538,21 +538,13 @@ def process_groups(account, groups):
for group in group_objs:
custom_props = groups.get(group.name, {})
- custom_props_json = json.dumps(custom_props)
if group.name in existing_relationships:
# Update existing relationship if xc_id has changed (preserve other custom properties)
existing_rel = existing_relationships[group.name]
- # Parse existing custom properties
- try:
- existing_custom_props = (
- json.loads(existing_rel.custom_properties)
- if existing_rel.custom_properties
- else {}
- )
- except (json.JSONDecodeError, TypeError):
- existing_custom_props = {}
+ # Get existing custom properties (now JSONB, no need to parse)
+ existing_custom_props = existing_rel.custom_properties or {}
# Get the new xc_id from groups data
new_xc_id = custom_props.get("xc_id")
@@ -568,16 +560,40 @@ def process_groups(account, groups):
# Remove xc_id if it's no longer provided (e.g., converting from XC to standard)
del updated_custom_props["xc_id"]
- existing_rel.custom_properties = json.dumps(updated_custom_props)
+ existing_rel.custom_properties = updated_custom_props
relations_to_update.append(existing_rel)
- logger.debug(f"Updated custom properties for group '{group.name}' - account {account.id}")
+ logger.debug(f"Updated xc_id for group '{group.name}' from '{existing_xc_id}' to '{new_xc_id}' - account {account.id}")
+ else:
+ logger.debug(f"xc_id unchanged for group '{group.name}' - account {account.id}")
else:
+ # Create new relationship - but check if there's an existing relationship that might have user settings
+ # This can happen if the group was temporarily removed and is now back
+ try:
+ potential_existing = ChannelGroupM3UAccount.objects.filter(
+ m3u_account=account,
+ channel_group=group
+ ).first()
+
+ if potential_existing:
+ # Merge with existing custom properties to preserve user settings
+ existing_custom_props = potential_existing.custom_properties or {}
+
+ # Merge new properties with existing ones
+ merged_custom_props = existing_custom_props.copy()
+ merged_custom_props.update(custom_props)
+ custom_props = merged_custom_props
+ logger.debug(f"Merged custom properties for existing relationship: group '{group.name}' - account {account.id}")
+ except Exception as e:
+ logger.debug(f"Could not check for existing relationship: {str(e)}")
+ # Fall back to using just the new custom properties
+ pass
+
# Create new relationship
relations_to_create.append(
ChannelGroupM3UAccount(
channel_group=group,
m3u_account=account,
- custom_properties=custom_props_json,
+ custom_properties=custom_props,
enabled=True, # Default to enabled
)
)
@@ -768,7 +784,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
"m3u_account": account,
"channel_group_id": int(group_id),
"stream_hash": stream_hash,
- "custom_properties": json.dumps(stream),
+ "custom_properties": stream,
}
if stream_hash not in stream_hashes:
@@ -870,7 +886,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
f.regex_pattern,
(
re.IGNORECASE
- if json.loads(f.custom_properties or "{}").get(
+ if (f.custom_properties or {}).get(
"case_sensitive", True
)
== False
@@ -933,7 +949,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
"m3u_account": account,
"channel_group_id": int(groups.get(group_title)),
"stream_hash": stream_hash,
- "custom_properties": json.dumps(stream_info["attributes"]),
+ "custom_properties": stream_info["attributes"],
}
if stream_hash not in stream_hashes:
@@ -1506,31 +1522,20 @@ def sync_auto_channels(account_id, scan_start_time=None):
channel_sort_reverse = False
stream_profile_id = None
if group_relation.custom_properties:
- try:
- group_custom_props = json.loads(group_relation.custom_properties)
- force_dummy_epg = group_custom_props.get("force_dummy_epg", False)
- override_group_id = group_custom_props.get("group_override")
- name_regex_pattern = group_custom_props.get("name_regex_pattern")
- name_replace_pattern = group_custom_props.get(
- "name_replace_pattern"
- )
- name_match_regex = group_custom_props.get("name_match_regex")
- channel_profile_ids = group_custom_props.get("channel_profile_ids")
- channel_sort_order = group_custom_props.get("channel_sort_order")
- channel_sort_reverse = group_custom_props.get(
- "channel_sort_reverse", False
- )
- stream_profile_id = group_custom_props.get("stream_profile_id")
- except Exception:
- force_dummy_epg = False
- override_group_id = None
- name_regex_pattern = None
- name_replace_pattern = None
- name_match_regex = None
- channel_profile_ids = None
- channel_sort_order = None
- channel_sort_reverse = False
- stream_profile_id = None
+ group_custom_props = group_relation.custom_properties
+ force_dummy_epg = group_custom_props.get("force_dummy_epg", False)
+ override_group_id = group_custom_props.get("group_override")
+ name_regex_pattern = group_custom_props.get("name_regex_pattern")
+ name_replace_pattern = group_custom_props.get(
+ "name_replace_pattern"
+ )
+ name_match_regex = group_custom_props.get("name_match_regex")
+ channel_profile_ids = group_custom_props.get("channel_profile_ids")
+ channel_sort_order = group_custom_props.get("channel_sort_order")
+ channel_sort_reverse = group_custom_props.get(
+ "channel_sort_reverse", False
+ )
+ stream_profile_id = group_custom_props.get("stream_profile_id")
# Determine which group to use for created channels
target_group = channel_group
@@ -1730,11 +1735,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
processed_stream_ids.add(stream.id)
try:
# Parse custom properties for additional info
- stream_custom_props = (
- json.loads(stream.custom_properties)
- if stream.custom_properties
- else {}
- )
+ stream_custom_props = stream.custom_properties or {}
tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid")
# --- REGEX FIND/REPLACE LOGIC ---
@@ -2018,11 +2019,8 @@ def refresh_single_m3u_account(account_id):
# Check if VOD is enabled for this account
vod_enabled = False
if account.custom_properties:
- try:
- custom_props = json.loads(account.custom_properties)
- vod_enabled = custom_props.get('enable_vod', False)
- except (json.JSONDecodeError, TypeError):
- vod_enabled = False
+ custom_props = account.custom_properties or {}
+ vod_enabled = custom_props.get('enable_vod', False)
except M3UAccount.DoesNotExist:
# The M3U account doesn't exist, so delete the periodic task if it exists
@@ -2257,27 +2255,18 @@ def refresh_single_m3u_account(account_id):
group_id = rel.channel_group.id
# Load the custom properties with the xc_id
- try:
- custom_props = (
- json.loads(rel.custom_properties)
- if rel.custom_properties
- else {}
+ custom_props = rel.custom_properties or {}
+ if "xc_id" in custom_props:
+ filtered_groups[group_name] = {
+ "xc_id": custom_props["xc_id"],
+ "channel_group_id": group_id,
+ }
+ logger.debug(
+ f"Added group {group_name} with xc_id {custom_props['xc_id']}"
)
- if "xc_id" in custom_props:
- filtered_groups[group_name] = {
- "xc_id": custom_props["xc_id"],
- "channel_group_id": group_id,
- }
- logger.debug(
- f"Added group {group_name} with xc_id {custom_props['xc_id']}"
- )
- else:
- logger.warning(
- f"No xc_id found in custom properties for group {group_name}"
- )
- except (json.JSONDecodeError, KeyError) as e:
- logger.error(
- f"Error parsing custom properties for group {group_name}: {str(e)}"
+ else:
+ logger.warning(
+ f"No xc_id found in custom properties for group {group_name}"
)
logger.info(
diff --git a/apps/output/views.py b/apps/output/views.py
index 33e932a9..f68faff2 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -463,206 +463,202 @@ def generate_epg(request, profile_name=None, user=None):
# Process custom properties if available
if prog.custom_properties:
- try:
- custom_data = json.loads(prog.custom_properties)
+ custom_data = prog.custom_properties or {}
- # Add categories if available
- if "categories" in custom_data and custom_data["categories"]:
- for category in custom_data["categories"]:
- program_xml.append(f" {html.escape(category)}")
+ # Add categories if available
+ if "categories" in custom_data and custom_data["categories"]:
+ for category in custom_data["categories"]:
+ program_xml.append(f" {html.escape(category)}")
- # Add keywords if available
- if "keywords" in custom_data and custom_data["keywords"]:
- for keyword in custom_data["keywords"]:
- program_xml.append(f" {html.escape(keyword)}")
+ # Add keywords if available
+ if "keywords" in custom_data and custom_data["keywords"]:
+ for keyword in custom_data["keywords"]:
+ program_xml.append(f" {html.escape(keyword)}")
- # Handle episode numbering - multiple formats supported
- # Prioritize onscreen_episode over standalone episode for onscreen system
- if "onscreen_episode" in custom_data:
- program_xml.append(f' {html.escape(custom_data["onscreen_episode"])}')
- elif "episode" in custom_data:
- program_xml.append(f' E{custom_data["episode"]}')
+ # Handle episode numbering - multiple formats supported
+ # Prioritize onscreen_episode over standalone episode for onscreen system
+ if "onscreen_episode" in custom_data:
+ program_xml.append(f' {html.escape(custom_data["onscreen_episode"])}')
+ elif "episode" in custom_data:
+ program_xml.append(f' E{custom_data["episode"]}')
- # Handle dd_progid format
- if 'dd_progid' in custom_data:
- program_xml.append(f' {html.escape(custom_data["dd_progid"])}')
+ # Handle dd_progid format
+ if 'dd_progid' in custom_data:
+ program_xml.append(f' {html.escape(custom_data["dd_progid"])}')
- # Handle external database IDs
- for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']:
- if f'{system}_id' in custom_data:
- program_xml.append(f' {html.escape(custom_data[f"{system}_id"])}')
+ # Handle external database IDs
+ for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']:
+ if f'{system}_id' in custom_data:
+ program_xml.append(f' {html.escape(custom_data[f"{system}_id"])}')
- # Add season and episode numbers in xmltv_ns format if available
- if "season" in custom_data and "episode" in custom_data:
- season = (
- int(custom_data["season"]) - 1
- if str(custom_data["season"]).isdigit()
- else 0
- )
- episode = (
- int(custom_data["episode"]) - 1
- if str(custom_data["episode"]).isdigit()
- else 0
- )
- program_xml.append(f' {season}.{episode}.')
+ # Add season and episode numbers in xmltv_ns format if available
+ if "season" in custom_data and "episode" in custom_data:
+ season = (
+ int(custom_data["season"]) - 1
+ if str(custom_data["season"]).isdigit()
+ else 0
+ )
+ episode = (
+ int(custom_data["episode"]) - 1
+ if str(custom_data["episode"]).isdigit()
+ else 0
+ )
+ program_xml.append(f' {season}.{episode}.')
- # Add language information
- if "language" in custom_data:
- program_xml.append(f' {html.escape(custom_data["language"])}')
+ # Add language information
+ if "language" in custom_data:
+ program_xml.append(f' {html.escape(custom_data["language"])}')
- if "original_language" in custom_data:
- program_xml.append(f' {html.escape(custom_data["original_language"])}')
+ if "original_language" in custom_data:
+ program_xml.append(f' {html.escape(custom_data["original_language"])}')
- # Add length information
- if "length" in custom_data and isinstance(custom_data["length"], dict):
- length_value = custom_data["length"].get("value", "")
- length_units = custom_data["length"].get("units", "minutes")
- program_xml.append(f' {html.escape(str(length_value))}')
+ # Add length information
+ if "length" in custom_data and isinstance(custom_data["length"], dict):
+ length_value = custom_data["length"].get("value", "")
+ length_units = custom_data["length"].get("units", "minutes")
+ program_xml.append(f' {html.escape(str(length_value))}')
- # Add video information
- if "video" in custom_data and isinstance(custom_data["video"], dict):
- program_xml.append(" ")
+ # Add video information
+ if "video" in custom_data and isinstance(custom_data["video"], dict):
+ program_xml.append(" ")
- # Add audio information
- if "audio" in custom_data and isinstance(custom_data["audio"], dict):
- program_xml.append(" ")
+ # Add audio information
+ if "audio" in custom_data and isinstance(custom_data["audio"], dict):
+ program_xml.append(" ")
- # Add subtitles information
- if "subtitles" in custom_data and isinstance(custom_data["subtitles"], list):
- for subtitle in custom_data["subtitles"]:
- if isinstance(subtitle, dict):
- subtitle_type = subtitle.get("type", "")
- type_attr = f' type="{html.escape(subtitle_type)}"' if subtitle_type else ""
- program_xml.append(f" ")
- if "language" in subtitle:
- program_xml.append(f" {html.escape(subtitle['language'])}")
- program_xml.append(" ")
+ # Add subtitles information
+ if "subtitles" in custom_data and isinstance(custom_data["subtitles"], list):
+ for subtitle in custom_data["subtitles"]:
+ if isinstance(subtitle, dict):
+ subtitle_type = subtitle.get("type", "")
+ type_attr = f' type="{html.escape(subtitle_type)}"' if subtitle_type else ""
+ program_xml.append(f" ")
+ if "language" in subtitle:
+ program_xml.append(f" {html.escape(subtitle['language'])}")
+ program_xml.append(" ")
- # Add rating if available
- if "rating" in custom_data:
- rating_system = custom_data.get("rating_system", "TV Parental Guidelines")
- program_xml.append(f' ')
- program_xml.append(f' {html.escape(custom_data["rating"])}')
- program_xml.append(f" ")
+ # Add rating if available
+ if "rating" in custom_data:
+ rating_system = custom_data.get("rating_system", "TV Parental Guidelines")
+ program_xml.append(f' ')
+ program_xml.append(f' {html.escape(custom_data["rating"])}')
+ program_xml.append(f" ")
- # Add star ratings
- if "star_ratings" in custom_data and isinstance(custom_data["star_ratings"], list):
- for star_rating in custom_data["star_ratings"]:
- if isinstance(star_rating, dict) and "value" in star_rating:
- system_attr = f' system="{html.escape(star_rating["system"])}"' if "system" in star_rating else ""
- program_xml.append(f" ")
- program_xml.append(f" {html.escape(star_rating['value'])}")
- program_xml.append(" ")
+ # Add star ratings
+ if "star_ratings" in custom_data and isinstance(custom_data["star_ratings"], list):
+ for star_rating in custom_data["star_ratings"]:
+ if isinstance(star_rating, dict) and "value" in star_rating:
+ system_attr = f' system="{html.escape(star_rating["system"])}"' if "system" in star_rating else ""
+ program_xml.append(f" ")
+ program_xml.append(f" {html.escape(star_rating['value'])}")
+ program_xml.append(" ")
- # Add reviews
- if "reviews" in custom_data and isinstance(custom_data["reviews"], list):
- for review in custom_data["reviews"]:
- if isinstance(review, dict) and "content" in review:
- review_type = review.get("type", "text")
- attrs = [f'type="{html.escape(review_type)}"']
- if "source" in review:
- attrs.append(f'source="{html.escape(review["source"])}"')
- if "reviewer" in review:
- attrs.append(f'reviewer="{html.escape(review["reviewer"])}"')
- attr_str = " ".join(attrs)
- program_xml.append(f' {html.escape(review["content"])}')
+ # Add reviews
+ if "reviews" in custom_data and isinstance(custom_data["reviews"], list):
+ for review in custom_data["reviews"]:
+ if isinstance(review, dict) and "content" in review:
+ review_type = review.get("type", "text")
+ attrs = [f'type="{html.escape(review_type)}"']
+ if "source" in review:
+ attrs.append(f'source="{html.escape(review["source"])}"')
+ if "reviewer" in review:
+ attrs.append(f'reviewer="{html.escape(review["reviewer"])}"')
+ attr_str = " ".join(attrs)
+ program_xml.append(f' {html.escape(review["content"])}')
- # Add images
- if "images" in custom_data and isinstance(custom_data["images"], list):
- for image in custom_data["images"]:
- if isinstance(image, dict) and "url" in image:
- attrs = []
- for attr in ['type', 'size', 'orient', 'system']:
- if attr in image:
- attrs.append(f'{attr}="{html.escape(image[attr])}"')
- attr_str = " " + " ".join(attrs) if attrs else ""
- program_xml.append(f' {html.escape(image["url"])}')
+ # Add images
+ if "images" in custom_data and isinstance(custom_data["images"], list):
+ for image in custom_data["images"]:
+ if isinstance(image, dict) and "url" in image:
+ attrs = []
+ for attr in ['type', 'size', 'orient', 'system']:
+ if attr in image:
+ attrs.append(f'{attr}="{html.escape(image[attr])}"')
+ attr_str = " " + " ".join(attrs) if attrs else ""
+ program_xml.append(f' {html.escape(image["url"])}')
- # Add enhanced credits handling
- if "credits" in custom_data:
- program_xml.append(" ")
- credits = custom_data["credits"]
+ # Add enhanced credits handling
+ if "credits" in custom_data:
+ program_xml.append(" ")
+ credits = custom_data["credits"]
- # Handle different credit types
- for role in ['director', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']:
- if role in credits:
- people = credits[role]
- if isinstance(people, list):
- for person in people:
- program_xml.append(f" <{role}>{html.escape(person)}{role}>")
- else:
- program_xml.append(f" <{role}>{html.escape(people)}{role}>")
-
- # Handle actors separately to include role and guest attributes
- if "actor" in credits:
- actors = credits["actor"]
- if isinstance(actors, list):
- for actor in actors:
- if isinstance(actor, dict):
- name = actor.get("name", "")
- role_attr = f' role="{html.escape(actor["role"])}"' if "role" in actor else ""
- guest_attr = ' guest="yes"' if actor.get("guest") else ""
- program_xml.append(f" {html.escape(name)}")
- else:
- program_xml.append(f" {html.escape(actor)}")
+ # Handle different credit types
+ for role in ['director', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']:
+ if role in credits:
+ people = credits[role]
+ if isinstance(people, list):
+ for person in people:
+ program_xml.append(f" <{role}>{html.escape(person)}{role}>")
else:
- program_xml.append(f" {html.escape(actors)}")
+ program_xml.append(f" <{role}>{html.escape(people)}{role}>")
- program_xml.append(" ")
-
- # Add program date if available (full date, not just year)
- if "date" in custom_data:
- program_xml.append(f' {html.escape(custom_data["date"])}')
-
- # Add country if available
- if "country" in custom_data:
- program_xml.append(f' {html.escape(custom_data["country"])}')
-
- # Add icon if available
- if "icon" in custom_data:
- program_xml.append(f' ')
-
- # Add special flags as proper tags with enhanced handling
- if custom_data.get("previously_shown", False):
- prev_shown_details = custom_data.get("previously_shown_details", {})
- attrs = []
- if "start" in prev_shown_details:
- attrs.append(f'start="{html.escape(prev_shown_details["start"])}"')
- if "channel" in prev_shown_details:
- attrs.append(f'channel="{html.escape(prev_shown_details["channel"])}"')
- attr_str = " " + " ".join(attrs) if attrs else ""
- program_xml.append(f" ")
-
- if custom_data.get("premiere", False):
- premiere_text = custom_data.get("premiere_text", "")
- if premiere_text:
- program_xml.append(f" {html.escape(premiere_text)}")
+ # Handle actors separately to include role and guest attributes
+ if "actor" in credits:
+ actors = credits["actor"]
+ if isinstance(actors, list):
+ for actor in actors:
+ if isinstance(actor, dict):
+ name = actor.get("name", "")
+ role_attr = f' role="{html.escape(actor["role"])}"' if "role" in actor else ""
+ guest_attr = ' guest="yes"' if actor.get("guest") else ""
+ program_xml.append(f" {html.escape(name)}")
+ else:
+ program_xml.append(f" {html.escape(actor)}")
else:
- program_xml.append(" ")
+ program_xml.append(f" {html.escape(actors)}")
- if custom_data.get("last_chance", False):
- last_chance_text = custom_data.get("last_chance_text", "")
- if last_chance_text:
- program_xml.append(f" {html.escape(last_chance_text)}")
- else:
- program_xml.append(" ")
+ program_xml.append(" ")
- if custom_data.get("new", False):
- program_xml.append(" ")
+ # Add program date if available (full date, not just year)
+ if "date" in custom_data:
+ program_xml.append(f' {html.escape(custom_data["date"])}')
- if custom_data.get('live', False):
- program_xml.append(' ')
+ # Add country if available
+ if "country" in custom_data:
+ program_xml.append(f' {html.escape(custom_data["country"])}')
- except Exception as e:
- program_xml.append(f" ")
+ # Add icon if available
+ if "icon" in custom_data:
+ program_xml.append(f' ')
+
+ # Add special flags as proper tags with enhanced handling
+ if custom_data.get("previously_shown", False):
+ prev_shown_details = custom_data.get("previously_shown_details", {})
+ attrs = []
+ if "start" in prev_shown_details:
+ attrs.append(f'start="{html.escape(prev_shown_details["start"])}"')
+ if "channel" in prev_shown_details:
+ attrs.append(f'channel="{html.escape(prev_shown_details["channel"])}"')
+ attr_str = " " + " ".join(attrs) if attrs else ""
+ program_xml.append(f" ")
+
+ if custom_data.get("premiere", False):
+ premiere_text = custom_data.get("premiere_text", "")
+ if premiere_text:
+ program_xml.append(f" {html.escape(premiere_text)}")
+ else:
+ program_xml.append(" ")
+
+ if custom_data.get("last_chance", False):
+ last_chance_text = custom_data.get("last_chance_text", "")
+ if last_chance_text:
+ program_xml.append(f" {html.escape(last_chance_text)}")
+ else:
+ program_xml.append(" ")
+
+ if custom_data.get("new", False):
+ program_xml.append(" ")
+
+ if custom_data.get('live', False):
+ program_xml.append(' ')
program_xml.append(" ")
@@ -697,9 +693,7 @@ def xc_get_user(request):
return None
user = get_object_or_404(User, username=username)
- custom_properties = (
- json.loads(user.custom_properties) if user.custom_properties else {}
- )
+ custom_properties = user.custom_properties or {}
if "xc_password" not in custom_properties:
return None
@@ -1494,42 +1488,35 @@ def xc_get_vod_info(request, user, vod_id):
# Add detailed info from custom_properties if available
if movie.custom_properties:
- try:
- if isinstance(movie.custom_properties, dict):
- custom_data = movie.custom_properties
- else:
- custom_data = json.loads(movie.custom_properties)
+ custom_data = movie.custom_properties or {}
- # Extract detailed info
- #detailed_info = custom_data.get('detailed_info', {})
- detailed_info = movie_relation.custom_properties.get('detailed_info', {})
- # Update movie_data with detailed info
- movie_data.update({
- 'director': custom_data.get('director') or detailed_info.get('director', ''),
- 'actors': custom_data.get('actors') or detailed_info.get('actors', ''),
- 'country': custom_data.get('country') or detailed_info.get('country', ''),
- 'release_date': custom_data.get('release_date') or detailed_info.get('release_date') or detailed_info.get('releasedate', ''),
- 'youtube_trailer': custom_data.get('youtube_trailer') or detailed_info.get('youtube_trailer') or detailed_info.get('trailer', ''),
- 'backdrop_path': custom_data.get('backdrop_path') or detailed_info.get('backdrop_path', []),
- 'cover_big': detailed_info.get('cover_big', ''),
- 'bitrate': detailed_info.get('bitrate', 0),
- 'video': detailed_info.get('video', {}),
- 'audio': detailed_info.get('audio', {}),
- })
+ # Extract detailed info
+ #detailed_info = custom_data.get('detailed_info', {})
+ detailed_info = movie_relation.custom_properties.get('detailed_info', {})
+ # Update movie_data with detailed info
+ movie_data.update({
+ 'director': custom_data.get('director') or detailed_info.get('director', ''),
+ 'actors': custom_data.get('actors') or detailed_info.get('actors', ''),
+ 'country': custom_data.get('country') or detailed_info.get('country', ''),
+ 'release_date': custom_data.get('release_date') or detailed_info.get('release_date') or detailed_info.get('releasedate', ''),
+ 'youtube_trailer': custom_data.get('youtube_trailer') or detailed_info.get('youtube_trailer') or detailed_info.get('trailer', ''),
+ 'backdrop_path': custom_data.get('backdrop_path') or detailed_info.get('backdrop_path', []),
+ 'cover_big': detailed_info.get('cover_big', ''),
+ 'bitrate': detailed_info.get('bitrate', 0),
+ 'video': detailed_info.get('video', {}),
+ 'audio': detailed_info.get('audio', {}),
+ })
- # Override with detailed_info values where available
- for key in ['name', 'description', 'year', 'genre', 'rating', 'tmdb_id', 'imdb_id']:
- if detailed_info.get(key):
- movie_data[key] = detailed_info[key]
+ # Override with detailed_info values where available
+ for key in ['name', 'description', 'year', 'genre', 'rating', 'tmdb_id', 'imdb_id']:
+ if detailed_info.get(key):
+ movie_data[key] = detailed_info[key]
- # Handle plot vs description
- if detailed_info.get('plot'):
- movie_data['description'] = detailed_info['plot']
- elif detailed_info.get('description'):
- movie_data['description'] = detailed_info['description']
-
- except (json.JSONDecodeError, AttributeError, TypeError) as e:
- logger.warning(f"Error parsing custom_properties for movie {movie.id}: {e}")
+ # Handle plot vs description
+ if detailed_info.get('plot'):
+ movie_data['description'] = detailed_info['plot']
+ elif detailed_info.get('description'):
+ movie_data['description'] = detailed_info['description']
except Exception as e:
logger.error(f"Failed to process movie data: {e}")
@@ -1593,9 +1580,7 @@ def xc_movie_stream(request, username, password, stream_id, extension):
user = get_object_or_404(User, username=username)
- custom_properties = (
- json.loads(user.custom_properties) if user.custom_properties else {}
- )
+ custom_properties = user.custom_properties or {}
if "xc_password" not in custom_properties:
return JsonResponse({"error": "Invalid credentials"}, status=401)
@@ -1642,9 +1627,7 @@ def xc_series_stream(request, username, password, stream_id, extension):
user = get_object_or_404(User, username=username)
- custom_properties = (
- json.loads(user.custom_properties) if user.custom_properties else {}
- )
+ custom_properties = user.custom_properties or {}
if "xc_password" not in custom_properties:
return JsonResponse({"error": "Invalid credentials"}, status=401)
diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py
index 7192937d..ff815bdc 100644
--- a/apps/proxy/ts_proxy/views.py
+++ b/apps/proxy/ts_proxy/views.py
@@ -467,9 +467,7 @@ def stream_xc(request, username, password, channel_id):
extension = pathlib.Path(channel_id).suffix
channel_id = pathlib.Path(channel_id).stem
- custom_properties = (
- json.loads(user.custom_properties) if user.custom_properties else {}
- )
+ custom_properties = user.custom_properties or {}
if "xc_password" not in custom_properties:
return Response({"error": "Invalid credentials"}, status=401)
diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx
index ecc21091..619b156f 100644
--- a/frontend/src/components/forms/User.jsx
+++ b/frontend/src/components/forms/User.jsx
@@ -74,13 +74,13 @@ const User = ({ user = null, isOpen, onClose }) => {
const onSubmit = async () => {
const values = form.getValues();
- const { ...customProps } = JSON.parse(user?.custom_properties || '{}');
+ const customProps = user?.custom_properties || {};
// Always save xc_password, even if it's empty (to allow clearing)
customProps.xc_password = values.xc_password || '';
delete values.xc_password;
- values.custom_properties = JSON.stringify(customProps);
+ values.custom_properties = customProps;
// If 'All' is included, clear this and we assume access to all channels
if (values.channel_profiles.includes('0')) {
@@ -112,7 +112,7 @@ const User = ({ user = null, isOpen, onClose }) => {
useEffect(() => {
if (user?.id) {
- const customProps = JSON.parse(user.custom_properties || '{}');
+ const customProps = user.custom_properties || {};
form.setValues({
username: user.username,
diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx
index 815aed5e..97703b46 100644
--- a/frontend/src/pages/DVR.jsx
+++ b/frontend/src/pages/DVR.jsx
@@ -44,7 +44,7 @@ const RecordingCard = ({ recording }) => {
API.deleteRecording(id);
};
- const customProps = JSON.parse(recording.custom_properties || '{}');
+ const customProps = recording.custom_properties || {};
let recordingName = 'Custom Recording';
if (customProps.program) {
recordingName = customProps.program.title;