Convert custom_properties to jsonb in the backend.

This commit is contained in:
SergeantPanda 2025-09-02 09:41:51 -05:00
parent a87f7c875d
commit 6f6c28ca7c
21 changed files with 395 additions and 379 deletions

View file

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

View file

@ -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 = [
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" <category>{html.escape(category)}</category>")
# Add categories if available
if "categories" in custom_data and custom_data["categories"]:
for category in custom_data["categories"]:
program_xml.append(f" <category>{html.escape(category)}</category>")
# Add keywords if available
if "keywords" in custom_data and custom_data["keywords"]:
for keyword in custom_data["keywords"]:
program_xml.append(f" <keyword>{html.escape(keyword)}</keyword>")
# Add keywords if available
if "keywords" in custom_data and custom_data["keywords"]:
for keyword in custom_data["keywords"]:
program_xml.append(f" <keyword>{html.escape(keyword)}</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' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>')
elif "episode" in custom_data:
program_xml.append(f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>')
# 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' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>')
elif "episode" in custom_data:
program_xml.append(f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>')
# Handle dd_progid format
if 'dd_progid' in custom_data:
program_xml.append(f' <episode-num system="dd_progid">{html.escape(custom_data["dd_progid"])}</episode-num>')
# Handle dd_progid format
if 'dd_progid' in custom_data:
program_xml.append(f' <episode-num system="dd_progid">{html.escape(custom_data["dd_progid"])}</episode-num>')
# Handle external database IDs
for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']:
if f'{system}_id' in custom_data:
program_xml.append(f' <episode-num system="{system}">{html.escape(custom_data[f"{system}_id"])}</episode-num>')
# Handle external database IDs
for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']:
if f'{system}_id' in custom_data:
program_xml.append(f' <episode-num system="{system}">{html.escape(custom_data[f"{system}_id"])}</episode-num>')
# 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' <episode-num system="xmltv_ns">{season}.{episode}.</episode-num>')
# 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' <episode-num system="xmltv_ns">{season}.{episode}.</episode-num>')
# Add language information
if "language" in custom_data:
program_xml.append(f' <language>{html.escape(custom_data["language"])}</language>')
# Add language information
if "language" in custom_data:
program_xml.append(f' <language>{html.escape(custom_data["language"])}</language>')
if "original_language" in custom_data:
program_xml.append(f' <orig-language>{html.escape(custom_data["original_language"])}</orig-language>')
if "original_language" in custom_data:
program_xml.append(f' <orig-language>{html.escape(custom_data["original_language"])}</orig-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' <length units="{html.escape(length_units)}">{html.escape(str(length_value))}</length>')
# 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' <length units="{html.escape(length_units)}">{html.escape(str(length_value))}</length>')
# Add video information
if "video" in custom_data and isinstance(custom_data["video"], dict):
program_xml.append(" <video>")
for attr in ['present', 'colour', 'aspect', 'quality']:
if attr in custom_data["video"]:
program_xml.append(f" <{attr}>{html.escape(custom_data['video'][attr])}</{attr}>")
program_xml.append(" </video>")
# Add video information
if "video" in custom_data and isinstance(custom_data["video"], dict):
program_xml.append(" <video>")
for attr in ['present', 'colour', 'aspect', 'quality']:
if attr in custom_data["video"]:
program_xml.append(f" <{attr}>{html.escape(custom_data['video'][attr])}</{attr}>")
program_xml.append(" </video>")
# Add audio information
if "audio" in custom_data and isinstance(custom_data["audio"], dict):
program_xml.append(" <audio>")
for attr in ['present', 'stereo']:
if attr in custom_data["audio"]:
program_xml.append(f" <{attr}>{html.escape(custom_data['audio'][attr])}</{attr}>")
program_xml.append(" </audio>")
# Add audio information
if "audio" in custom_data and isinstance(custom_data["audio"], dict):
program_xml.append(" <audio>")
for attr in ['present', 'stereo']:
if attr in custom_data["audio"]:
program_xml.append(f" <{attr}>{html.escape(custom_data['audio'][attr])}</{attr}>")
program_xml.append(" </audio>")
# 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" <subtitles{type_attr}>")
if "language" in subtitle:
program_xml.append(f" <language>{html.escape(subtitle['language'])}</language>")
program_xml.append(" </subtitles>")
# 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" <subtitles{type_attr}>")
if "language" in subtitle:
program_xml.append(f" <language>{html.escape(subtitle['language'])}</language>")
program_xml.append(" </subtitles>")
# Add rating if available
if "rating" in custom_data:
rating_system = custom_data.get("rating_system", "TV Parental Guidelines")
program_xml.append(f' <rating system="{html.escape(rating_system)}">')
program_xml.append(f' <value>{html.escape(custom_data["rating"])}</value>')
program_xml.append(f" </rating>")
# Add rating if available
if "rating" in custom_data:
rating_system = custom_data.get("rating_system", "TV Parental Guidelines")
program_xml.append(f' <rating system="{html.escape(rating_system)}">')
program_xml.append(f' <value>{html.escape(custom_data["rating"])}</value>')
program_xml.append(f" </rating>")
# 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" <star-rating{system_attr}>")
program_xml.append(f" <value>{html.escape(star_rating['value'])}</value>")
program_xml.append(" </star-rating>")
# 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" <star-rating{system_attr}>")
program_xml.append(f" <value>{html.escape(star_rating['value'])}</value>")
program_xml.append(" </star-rating>")
# 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' <review {attr_str}>{html.escape(review["content"])}</review>')
# 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' <review {attr_str}>{html.escape(review["content"])}</review>')
# 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' <image{attr_str}>{html.escape(image["url"])}</image>')
# 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' <image{attr_str}>{html.escape(image["url"])}</image>')
# Add enhanced credits handling
if "credits" in custom_data:
program_xml.append(" <credits>")
credits = custom_data["credits"]
# Add enhanced credits handling
if "credits" in custom_data:
program_xml.append(" <credits>")
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" <actor{role_attr}{guest_attr}>{html.escape(name)}</actor>")
else:
program_xml.append(f" <actor>{html.escape(actor)}</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" <actor>{html.escape(actors)}</actor>")
program_xml.append(f" <{role}>{html.escape(people)}</{role}>")
program_xml.append(" </credits>")
# Add program date if available (full date, not just year)
if "date" in custom_data:
program_xml.append(f' <date>{html.escape(custom_data["date"])}</date>')
# Add country if available
if "country" in custom_data:
program_xml.append(f' <country>{html.escape(custom_data["country"])}</country>')
# Add icon if available
if "icon" in custom_data:
program_xml.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
# 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" <previously-shown{attr_str} />")
if custom_data.get("premiere", False):
premiere_text = custom_data.get("premiere_text", "")
if premiere_text:
program_xml.append(f" <premiere>{html.escape(premiere_text)}</premiere>")
# 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" <actor{role_attr}{guest_attr}>{html.escape(name)}</actor>")
else:
program_xml.append(f" <actor>{html.escape(actor)}</actor>")
else:
program_xml.append(" <premiere />")
program_xml.append(f" <actor>{html.escape(actors)}</actor>")
if custom_data.get("last_chance", False):
last_chance_text = custom_data.get("last_chance_text", "")
if last_chance_text:
program_xml.append(f" <last-chance>{html.escape(last_chance_text)}</last-chance>")
else:
program_xml.append(" <last-chance />")
program_xml.append(" </credits>")
if custom_data.get("new", False):
program_xml.append(" <new />")
# Add program date if available (full date, not just year)
if "date" in custom_data:
program_xml.append(f' <date>{html.escape(custom_data["date"])}</date>')
if custom_data.get('live', False):
program_xml.append(' <live />')
# Add country if available
if "country" in custom_data:
program_xml.append(f' <country>{html.escape(custom_data["country"])}</country>')
except Exception as e:
program_xml.append(f" <!-- Error parsing custom properties: {html.escape(str(e))} -->")
# Add icon if available
if "icon" in custom_data:
program_xml.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
# 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" <previously-shown{attr_str} />")
if custom_data.get("premiere", False):
premiere_text = custom_data.get("premiere_text", "")
if premiere_text:
program_xml.append(f" <premiere>{html.escape(premiere_text)}</premiere>")
else:
program_xml.append(" <premiere />")
if custom_data.get("last_chance", False):
last_chance_text = custom_data.get("last_chance_text", "")
if last_chance_text:
program_xml.append(f" <last-chance>{html.escape(last_chance_text)}</last-chance>")
else:
program_xml.append(" <last-chance />")
if custom_data.get("new", False):
program_xml.append(" <new />")
if custom_data.get('live', False):
program_xml.append(' <live />')
program_xml.append(" </programme>")
@ -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)

View file

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

View file

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

View file

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