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)}") - else: - program_xml.append(f" <{role}>{html.escape(people)}") - - # 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)}") else: - program_xml.append(f" {html.escape(actors)}") + program_xml.append(f" <{role}>{html.escape(people)}") - 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;