From 9711d7ab34e9fce4e7dfe8f9b99473b2409321aa Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 16 Mar 2025 09:07:10 -0400 Subject: [PATCH] modified database fields for consistency, removed custom_url from streams (no longer needed) --- apps/channels/admin.py | 18 +++---- apps/channels/api_views.py | 24 ++++----- apps/channels/forms.py | 3 +- ...name_channel_name_channel_name_and_more.py | 27 ++++++++++ apps/channels/models.py | 13 ++--- apps/channels/serializers.py | 10 +--- apps/channels/tasks.py | 14 ++--- apps/channels/views.py | 4 +- apps/dashboard/views.py | 5 +- apps/epg/admin.py | 2 +- apps/epg/migrations/0001_initial.py | 2 +- apps/epg/models.py | 6 +-- apps/epg/serializers.py | 4 +- apps/epg/tasks.py | 6 +-- apps/hdhr/api_views.py | 4 +- apps/hdhr/views.py | 4 +- apps/m3u/migrations/0001_initial.py | 2 +- apps/m3u/models.py | 4 +- apps/m3u/tasks.py | 6 +-- apps/output/views.py | 12 ++--- apps/proxy/ts_proxy/views.py | 51 ++++++++++--------- core/admin.py | 8 +-- core/fixtures/initial_data.json | 8 +-- ...eate_proxy_and_redirect_stream_profiles.py | 8 ++- ...rofile_name_streamprofile_name_and_more.py | 23 +++++++++ core/models.py | 26 +++++----- core/serializers.py | 4 +- core/views.py | 6 +-- dispatcharr/settings.py | 2 +- frontend/src/components/forms/Channel.jsx | 18 +++---- frontend/src/components/forms/M3U.jsx | 2 +- frontend/src/components/forms/Stream.jsx | 2 +- .../src/components/forms/StreamProfile.jsx | 19 ++++--- frontend/src/components/forms/UserAgent.jsx | 17 +++---- .../src/components/tables/ChannelsTable.jsx | 6 +-- .../components/tables/StreamProfilesTable.jsx | 2 +- .../src/components/tables/StreamsTable.jsx | 4 +- .../src/components/tables/UserAgentsTable.jsx | 2 +- frontend/src/pages/Guide.jsx | 6 +-- frontend/src/pages/Settings.jsx | 4 +- 40 files changed, 216 insertions(+), 172 deletions(-) create mode 100644 apps/channels/migrations/0002_rename_channel_name_channel_name_and_more.py create mode 100644 core/migrations/0008_rename_profile_name_streamprofile_name_and_more.py diff --git a/apps/channels/admin.py b/apps/channels/admin.py index 49ef04a9..709dc81a 100644 --- a/apps/channels/admin.py +++ b/apps/channels/admin.py @@ -5,27 +5,27 @@ from .models import Stream, Channel, ChannelGroup class StreamAdmin(admin.ModelAdmin): list_display = ( 'id', # Primary Key - 'name', - 'group_name', - 'custom_url', - 'current_viewers', + 'name', + 'group_name', + 'url', + 'current_viewers', 'updated_at', ) list_filter = ('group_name',) - search_fields = ('id', 'name', 'custom_url', 'group_name') # Added 'id' for searching by ID + search_fields = ('id', 'name', 'url', 'group_name') # Added 'id' for searching by ID ordering = ('-updated_at',) @admin.register(Channel) class ChannelAdmin(admin.ModelAdmin): list_display = ( 'id', # Primary Key - 'channel_number', - 'channel_name', - 'channel_group', + 'channel_number', + 'name', + 'channel_group', 'tvg_name' ) list_filter = ('channel_group',) - search_fields = ('id', 'channel_name', 'channel_group__name', 'tvg_name') # Added 'id' + search_fields = ('id', 'name', 'channel_group__name', 'tvg_name') # Added 'id' ordering = ('channel_number',) @admin.register(ChannelGroup) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 27b07af5..9744f7f0 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -145,7 +145,7 @@ class ChannelViewSet(viewsets.ModelViewSet): type=openapi.TYPE_INTEGER, description="(Optional) Desired channel number. Must not be in use." ), - "channel_name": openapi.Schema( + "name": openapi.Schema( type=openapi.TYPE_STRING, description="Desired channel name" ) } @@ -176,13 +176,13 @@ class ChannelViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST ) - channel_name = request.data.get('channel_name') - if channel_name is None: - channel_name = stream.name + name = request.data.get('name') + if name is None: + name = stream.name channel_data = { 'channel_number': channel_number, - 'channel_name': channel_name, + 'name': name, 'tvg_id': stream.tvg_id, 'channel_group_id': channel_group.id, 'logo_url': stream.logo_url, @@ -199,7 +199,7 @@ class ChannelViewSet(viewsets.ModelViewSet): operation_description=( "Bulk create channels from existing streams. For each object, if 'channel_number' is provided, " "it is used (if available); otherwise, the next available number is auto-assigned. " - "Each object must include 'stream_id' and 'channel_name'." + "Each object must include 'stream_id' and 'name'." ), request_body=openapi.Schema( type=openapi.TYPE_ARRAY, @@ -214,7 +214,7 @@ class ChannelViewSet(viewsets.ModelViewSet): type=openapi.TYPE_INTEGER, description="(Optional) Desired channel number. Must not be in use." ), - "channel_name": openapi.Schema( + "name": openapi.Schema( type=openapi.TYPE_STRING, description="Desired channel name" ) } @@ -245,7 +245,7 @@ class ChannelViewSet(viewsets.ModelViewSet): for item in data_list: stream_id = item.get('stream_id') if not all([stream_id]): - errors.append({"item": item, "error": "Missing required fields: stream_id and channel_name are required."}) + errors.append({"item": item, "error": "Missing required fields: stream_id and name are required."}) continue try: @@ -271,13 +271,13 @@ class ChannelViewSet(viewsets.ModelViewSet): continue used_numbers.add(channel_number) - channel_name = item.get('channel_name') - if channel_name is None: - channel_name = stream.name + name = item.get('name') + if name is None: + name = stream.name channel_data = { "channel_number": channel_number, - "channel_name": channel_name, + "name": name, "tvg_id": stream.tvg_id, "channel_group_id": channel_group.id, "logo_url": stream.logo_url, diff --git a/apps/channels/forms.py b/apps/channels/forms.py index 171c77d9..baf169af 100644 --- a/apps/channels/forms.py +++ b/apps/channels/forms.py @@ -25,7 +25,7 @@ class ChannelForm(forms.ModelForm): model = Channel fields = [ 'channel_number', - 'channel_name', + 'name', 'channel_group', ] @@ -39,7 +39,6 @@ class StreamForm(forms.ModelForm): fields = [ 'name', 'url', - 'custom_url', 'logo_url', 'tvg_id', 'local_file', diff --git a/apps/channels/migrations/0002_rename_channel_name_channel_name_and_more.py b/apps/channels/migrations/0002_rename_channel_name_channel_name_and_more.py new file mode 100644 index 00000000..55a134a2 --- /dev/null +++ b/apps/channels/migrations/0002_rename_channel_name_channel_name_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-16 12:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='channel', + old_name='channel_name', + new_name='name', + ), + migrations.RemoveField( + model_name='stream', + name='url', + ), + migrations.RenameField( + model_name='stream', + old_name='custom_url', + new_name='url', + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 54f8b11a..207f5b77 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -5,6 +5,7 @@ from django.conf import settings from core.models import StreamProfile, CoreSettings from core.utils import redis_client import logging +import uuid logger = logging.getLogger(__name__) @@ -16,8 +17,7 @@ class Stream(models.Model): Represents a single stream (e.g. from an M3U source or custom URL). """ name = models.CharField(max_length=255, default="Default Stream") - url = models.URLField() - custom_url = models.URLField(max_length=2000, blank=True, null=True) + url = models.URLField(max_length=2000, blank=True, null=True) m3u_account = models.ForeignKey( M3UAccount, on_delete=models.CASCADE, @@ -38,14 +38,15 @@ class Stream(models.Model): on_delete=models.SET_NULL, related_name='streams' ) + class Meta: - # If you use m3u_account, you might do unique_together = ('name','custom_url','m3u_account') + # If you use m3u_account, you might do unique_together = ('name','url','m3u_account') verbose_name = "Stream" verbose_name_plural = "Streams" ordering = ['-updated_at'] def __str__(self): - return self.name or self.custom_url or f"Stream ID {self.id}" + return self.name or self.url or f"Stream ID {self.id}" class ChannelManager(models.Manager): @@ -55,7 +56,7 @@ class ChannelManager(models.Manager): class Channel(models.Model): channel_number = models.IntegerField() - channel_name = models.CharField(max_length=255) + name = models.CharField(max_length=255) logo_url = models.URLField(max_length=2000, blank=True, null=True) logo_file = models.ImageField( upload_to='logos/', # Will store in MEDIA_ROOT/logos @@ -104,7 +105,7 @@ class Channel(models.Model): ) def __str__(self): - return f"{self.channel_number} - {self.channel_name}" + return f"{self.channel_number} - {self.name}" def get_stream_profile(self): stream_profile = self.stream_profile diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index d58c7bcb..744e4504 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -19,7 +19,6 @@ class StreamSerializer(serializers.ModelSerializer): 'id', 'name', 'url', - 'custom_url', 'm3u_account', # Uncomment if using M3U fields 'logo_url', 'tvg_id', @@ -84,7 +83,7 @@ class ChannelSerializer(serializers.ModelSerializer): fields = [ 'id', 'channel_number', - 'channel_name', + 'name', 'logo_url', 'logo_file', 'channel_group', @@ -99,9 +98,6 @@ class ChannelSerializer(serializers.ModelSerializer): def get_streams(self, obj): """Retrieve ordered stream objects for GET requests.""" ordered_streams = obj.streams.all().order_by('channelstream__order') - print(f'Retrieving streams in order') - for index, stream in enumerate(ordered_streams): - print(f'Stream {stream.id}, index {index}') return StreamSerializer(ordered_streams, many=True).data # def get_stream_ids(self, obj): @@ -119,13 +115,11 @@ class ChannelSerializer(serializers.ModelSerializer): return channel def update(self, instance, validated_data): - print("Validated Data:", validated_data) streams = validated_data.pop('stream_ids', None) - print(f'stream ids: {streams}') # Update the actual Channel fields instance.channel_number = validated_data.get('channel_number', instance.channel_number) - instance.channel_name = validated_data.get('channel_name', instance.channel_name) + instance.name = validated_data.get('name', instance.name) instance.logo_url = validated_data.get('logo_url', instance.logo_url) instance.tvg_id = validated_data.get('tvg_id', instance.tvg_id) instance.tvg_name = validated_data.get('tvg_name', instance.tvg_name) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index c4bf8177..c1d16e1b 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -31,7 +31,7 @@ COMMON_EXTRANEOUS_WORDS = [ "arabic", "latino", "film", "movie", "movies" ] -def normalize_channel_name(name: str) -> str: +def normalize_name(name: str) -> str: """ A more aggressive normalization that: - Lowercases @@ -90,8 +90,8 @@ def match_epg_channels(): epg_rows.append({ "epg_id": e.id, "tvg_id": e.tvg_id or "", # e.g. "Fox News.us" - "raw_name": e.channel_name, - "norm_name": normalize_channel_name(e.channel_name), + "raw_name": e.name, + "norm_name": normalize_name(e.name), }) # 2) Pre-encode embeddings if possible @@ -115,16 +115,16 @@ def match_epg_channels(): epg_match = EPGData.objects.filter(tvg_id=chan.tvg_id).first() if epg_match: logger.info( - f"Channel {chan.id} '{chan.channel_name}' => found EPG by tvg_id={chan.tvg_id}" + f"Channel {chan.id} '{chan.name}' => found EPG by tvg_id={chan.tvg_id}" ) continue # C) No valid tvg_id => name-based matching - fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.channel_name - norm_chan = normalize_channel_name(fallback_name) + fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.name + norm_chan = normalize_name(fallback_name) if not norm_chan: logger.info( - f"Channel {chan.id} '{chan.channel_name}' => empty after normalization, skipping" + f"Channel {chan.id} '{chan.name}' => empty after normalization, skipping" ) continue diff --git a/apps/channels/views.py b/apps/channels/views.py index 2292a128..b28cc123 100644 --- a/apps/channels/views.py +++ b/apps/channels/views.py @@ -15,7 +15,7 @@ class StreamDashboardView(View): """ def get(self, request, *args, **kwargs): streams = Stream.objects.values( - 'id', 'name', 'url', 'custom_url', + 'id', 'name', 'url', 'group_name', 'current_viewers' ) return JsonResponse({'data': list(streams)}, safe=False) @@ -38,4 +38,4 @@ class StreamDashboardView(View): @login_required def channels_dashboard_view(request): - return render(request, 'channels/channels.html') \ No newline at end of file + return render(request, 'channels/channels.html') diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index 8548f652..2d143114 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -23,7 +23,7 @@ def dashboard_view(request): # Fetch active streams and related channels active_streams = Stream.objects.filter(current_viewers__gt=0).prefetch_related('channels') active_streams_list = [ - f"Stream {i + 1}: {stream.custom_url or 'Unknown'} ({stream.current_viewers} viewers)" + f"Stream {i + 1}: {stream.url or 'Unknown'} ({stream.current_viewers} viewers)" for i, stream in enumerate(active_streams) ] @@ -58,7 +58,7 @@ def live_dashboard_data(request): active_streams = Stream.objects.filter(current_viewers__gt=0) active_streams_list = [ - f"Stream {i + 1}: {stream.custom_url or 'Unknown'} ({stream.current_viewers} viewers)" + f"Stream {i + 1}: {stream.url or 'Unknown'} ({stream.current_viewers} viewers)" for i, stream in enumerate(active_streams) ] @@ -77,4 +77,3 @@ def live_dashboard_data(request): "error": str(e) } return JsonResponse(data) - diff --git a/apps/epg/admin.py b/apps/epg/admin.py index 54a9d7a6..24cc2823 100644 --- a/apps/epg/admin.py +++ b/apps/epg/admin.py @@ -11,7 +11,7 @@ class EPGSourceAdmin(admin.ModelAdmin): class ProgramAdmin(admin.ModelAdmin): list_display = ['title', 'get_epg_tvg_id', 'start_time', 'end_time'] list_filter = ['epg__tvg_id', 'tvg_id'] - search_fields = ['title', 'epg__channel_name'] + search_fields = ['title', 'epg__name'] def get_epg_tvg_id(self, obj): return obj.epg.tvg_id if obj.epg else '' diff --git a/apps/epg/migrations/0001_initial.py b/apps/epg/migrations/0001_initial.py index 7c77ba5e..fd49cb5d 100644 --- a/apps/epg/migrations/0001_initial.py +++ b/apps/epg/migrations/0001_initial.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('tvg_id', models.CharField(blank=True, max_length=255, null=True)), - ('channel_name', models.CharField(max_length=255)), + ('name', models.CharField(max_length=255)), ], ), migrations.CreateModel( diff --git a/apps/epg/models.py b/apps/epg/models.py index 64961c89..7a6a49b4 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -17,12 +17,12 @@ class EPGSource(models.Model): class EPGData(models.Model): # Removed the Channel foreign key. We now just store the original tvg_id - # and a channel_name (which might simply be the tvg_id if no real channel exists). + # and a name (which might simply be the tvg_id if no real channel exists). tvg_id = models.CharField(max_length=255, null=True, blank=True) - channel_name = models.CharField(max_length=255) + name = models.CharField(max_length=255) def __str__(self): - return f"EPG Data for {self.channel_name}" + return f"EPG Data for {self.name}" class ProgramData(models.Model): # Each programme is associated with an EPGData record. diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 4812a9b0..9a62e74e 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -17,8 +17,8 @@ class EPGDataSerializer(serializers.ModelSerializer): channel = serializers.SerializerMethodField() def get_channel(self, obj): - return {"id": obj.channel.id, "name": obj.channel.channel_name} if obj.channel else None + return {"id": obj.channel.id, "name": obj.channel.name} if obj.channel else None class Meta: model = EPGData - fields = ['id', 'channel', 'channel_name', 'programs'] + fields = ['id', 'channel', 'name', 'programs'] diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 532b4de0..4d62f556 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -30,7 +30,7 @@ def fetch_xmltv(source): response = requests.get(source.url, timeout=30) response.raise_for_status() logger.debug("XMLTV data fetched successfully.") - + # If the URL ends with '.gz', decompress the response content if source.url.lower().endswith('.gz'): logger.debug("Detected .gz file. Decompressing...") @@ -64,7 +64,7 @@ def fetch_xmltv(source): # Create (or get) an EPGData record using the tvg_id. epg_data, created = EPGData.objects.get_or_create( tvg_id=tvg_id, - defaults={'channel_name': tvg_id} # Use tvg_id as a fallback name + defaults={'name': tvg_id} # Use tvg_id as a fallback name ) if created: logger.info(f"Created new EPGData for tvg_id '{tvg_id}'.") @@ -120,7 +120,7 @@ def fetch_schedules_direct(source): # Create (or get) an EPGData record using the tvg_id. epg_data, created = EPGData.objects.get_or_create( tvg_id=tvg_id, - defaults={'channel_name': tvg_id} + defaults={'name': tvg_id} ) if created: logger.info(f"Created new EPGData for tvg_id '{tvg_id}'.") diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 844ee8fe..7eda441c 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -80,7 +80,7 @@ class LineupAPIView(APIView): lineup = [ { "GuideNumber": str(ch.channel_number), - "GuideName": ch.channel_name, + "GuideName": ch.name, "URL": request.build_absolute_uri(f"/output/stream/{ch.id}") } for ch in channels @@ -128,5 +128,5 @@ class HDHRDeviceXMLAPIView(APIView): {base_url} {base_url}/lineup.json """ - + return HttpResponse(xml_response, content_type="application/xml") diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 844ee8fe..7eda441c 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -80,7 +80,7 @@ class LineupAPIView(APIView): lineup = [ { "GuideNumber": str(ch.channel_number), - "GuideName": ch.channel_name, + "GuideName": ch.name, "URL": request.build_absolute_uri(f"/output/stream/{ch.id}") } for ch in channels @@ -128,5 +128,5 @@ class HDHRDeviceXMLAPIView(APIView): {base_url} {base_url}/lineup.json """ - + return HttpResponse(xml_response, content_type="application/xml") diff --git a/apps/m3u/migrations/0001_initial.py b/apps/m3u/migrations/0001_initial.py index 7a20a713..58c1f473 100644 --- a/apps/m3u/migrations/0001_initial.py +++ b/apps/m3u/migrations/0001_initial.py @@ -59,7 +59,7 @@ class Migration(migrations.Migration): ('m3u_account', models.ForeignKey(help_text='The M3U account this profile belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='profiles', to='m3u.m3uaccount')), ], options={ - 'constraints': [models.UniqueConstraint(fields=('m3u_account', 'name'), name='unique_account_profile_name')], + 'constraints': [models.UniqueConstraint(fields=('m3u_account', 'name'), name='unique_account_name')], }, ), ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index 36f49374..8561456f 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -186,7 +186,7 @@ class M3UAccountProfile(models.Model): class Meta: constraints = [ - models.UniqueConstraint(fields=['m3u_account', 'name'], name='unique_account_profile_name') + models.UniqueConstraint(fields=['m3u_account', 'name'], name='unique_account_name') ] def __str__(self): @@ -210,7 +210,7 @@ def create_profile_for_m3u_account(sender, instance, created, **kwargs): m3u_account=instance, is_default=True, ) - + profile.max_streams = instance.max_streams profile.save() diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 211524d6..cbb7bd94 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -21,7 +21,7 @@ def parse_extinf_line(line: str) -> dict: Parse an EXTINF line from an M3U file. This function removes the "#EXTINF:" prefix, then splits the remaining string on the first comma that is not enclosed in quotes. - + Returns a dictionary with: - 'attributes': a dict of attribute key/value pairs (e.g. tvg-id, tvg-logo, group-title) - 'display_name': the text after the comma (the fallback display name) @@ -186,7 +186,7 @@ def refresh_single_m3u_account(account_id): try: obj, created = Stream.objects.update_or_create( name=current_info["name"], - custom_url=line, + url=line, m3u_account=account, group_name=current_info["group_title"], defaults=defaults @@ -267,7 +267,7 @@ def parse_m3u_file(file_path, account): try: obj, created = Stream.objects.update_or_create( name=current_info["name"], - custom_url=line, + url=line, m3u_account=account, defaults=defaults ) diff --git a/apps/output/views.py b/apps/output/views.py index aeaa5d52..92319e93 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -14,13 +14,13 @@ def generate_m3u(request): for channel in channels: group_title = channel.channel_group.name if channel.channel_group else "Default" tvg_id = channel.tvg_id or "" - tvg_name = channel.tvg_name or channel.channel_name + tvg_name = channel.tvg_name or channel.name tvg_logo = channel.logo_url or "" channel_number = channel.channel_number extinf_line = ( f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" ' - f'tvg-chno="{channel_number}" group-title="{group_title}",{channel.channel_name}\n' + f'tvg-chno="{channel_number}" group-title="{group_title}",{channel.name}\n' ) stream_url = request.build_absolute_uri(reverse('output:stream', args=[channel.id])) m3u_content += extinf_line + stream_url + "\n" @@ -48,15 +48,15 @@ def generate_epg(request): xml_lines = [] xml_lines.append('') xml_lines.append('') - + # Output channel definitions based on EPGData. # Use the EPGData's tvg_id (or a fallback) as the channel identifier. for epg in epg_programs.keys(): channel_id = epg.tvg_id if epg.tvg_id else f"default-{epg.id}" xml_lines.append(f' ') - xml_lines.append(f' {epg.channel_name}') + xml_lines.append(f' {epg.name}') xml_lines.append(' ') - + # Output programme entries referencing the channel id from EPGData. for epg, progs in epg_programs.items(): channel_id = epg.tvg_id if epg.tvg_id else f"default-{epg.id}" @@ -67,7 +67,7 @@ def generate_epg(request): xml_lines.append(f' {prog.title}') xml_lines.append(f' {prog.description}') xml_lines.append(' ') - + xml_lines.append('') xml_content = "\n".join(xml_lines) diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 0d703745..1b5a15b9 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -13,7 +13,7 @@ from .channel_status import ChannelStatus import logging from apps.channels.models import Channel, Stream from apps.m3u.models import M3UAccount, M3UAccountProfile -from core.models import UserAgent, CoreSettings +from core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME # Configure logging properly logger = logging.getLogger("ts_proxy") @@ -43,7 +43,7 @@ def stream_ts(request, channel_id): if not proxy_server.check_if_channel_exists(channel_id): # Initialize the channel (but don't wait for completion) logger.info(f"[{client_id}] Starting channel {channel_id} initialization") - + # Get stream details from channel model stream_id, profile_id = channel.get_stream() if stream_id is None or profile_id is None: @@ -63,9 +63,9 @@ def stream_ts(request, channel_id): logger.debug(f"No user agent found for account, using default: {stream_user_agent}") else: logger.debug(f"User agent found for account: {stream_user_agent}") - + # Generate stream URL based on the selected profile - input_url = stream.custom_url or stream.url + input_url = stream.url logger.debug("Executing the following pattern replacement:") logger.debug(f" search: {profile.search_pattern}") safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern) @@ -78,9 +78,10 @@ def stream_ts(request, channel_id): stream_profile = channel.get_stream_profile() if stream_profile.is_redirect(): return HttpResponseRedirect(stream_url) + # Need to check if profile is transcoded logger.debug(f"Using profile {stream_profile} for stream {stream_id}") - if stream_profile == 'PROXY' or stream_profile is None: + if stream_profile.is_proxy() or stream_profile is None: transcode = False else: transcode = True @@ -90,7 +91,7 @@ def stream_ts(request, channel_id): if proxy_server.redis_client: metadata_key = f"ts_proxy:channel:{channel_id}:metadata" profile_value = str(stream_profile) - proxy_server.redis_client.hset(metadata_key, "profile", profile_value) + proxy_server.redis_client.hset(metadata_key, "profile", profile_value) if not success: return JsonResponse({'error': 'Failed to initialize channel'}, status=500) @@ -107,11 +108,11 @@ def stream_ts(request, channel_id): proxy_server.stop_channel(channel_id) return JsonResponse({'error': 'Failed to connect'}, status=502) time.sleep(0.1) - + logger.info(f"[{client_id}] Successfully initialized channel {channel_id}") channel_initializing = True logger.info(f"[{client_id}] Channel {channel_id} initialization started") - + # Register client - can do this regardless of initialization state # Create local resources if needed if channel_id not in proxy_server.stream_buffers or channel_id not in proxy_server.client_managers: @@ -120,21 +121,21 @@ def stream_ts(request, channel_id): # Get URL from Redis metadata url = None stream_user_agent = None # Initialize the variable - + if proxy_server.redis_client: metadata_key = f"ts_proxy:channel:{channel_id}:metadata" url_bytes = proxy_server.redis_client.hget(metadata_key, "url") ua_bytes = proxy_server.redis_client.hget(metadata_key, "user_agent") profile_bytes = proxy_server.redis_client.hget(metadata_key, "profile") - + if url_bytes: url = url_bytes.decode('utf-8') if ua_bytes: stream_user_agent = ua_bytes.decode('utf-8') # Extract transcode setting from Redis profile_str = profile_bytes.decode('utf-8') - use_transcode = (profile_str == 'PROXY' or profile_str == 'None') - + use_transcode = (profile_str == PROXY_PROFILE_NAME or profile_str == 'None') + # Use client_user_agent as fallback if stream_user_agent is None success = proxy_server.initialize_channel(url, channel_id, stream_user_agent or client_user_agent, use_transcode) if not success: @@ -142,7 +143,7 @@ def stream_ts(request, channel_id): return JsonResponse({'error': 'Failed to initialize channel locally'}, status=500) logger.info(f"[{client_id}] Successfully initialized channel {channel_id} locally") - + # Register client buffer = proxy_server.stream_buffers[channel_id] client_manager = proxy_server.client_managers[channel_id] @@ -154,17 +155,17 @@ def stream_ts(request, channel_id): stream_start_time = time.time() bytes_sent = 0 chunks_sent = 0 - + # Keep track of initialization state initialization_start = time.time() max_init_wait = getattr(Config, 'CLIENT_WAIT_TIMEOUT', 30) channel_ready = not channel_initializing keepalive_interval = 0.5 last_keepalive = 0 - + try: logger.info(f"[{client_id}] Stream generator started, channel_ready={channel_ready}") - + # Wait for initialization to complete if needed if not channel_ready: # While init is happening, send keepalive packets @@ -173,7 +174,7 @@ def stream_ts(request, channel_id): if proxy_server.redis_client: metadata_key = f"ts_proxy:channel:{channel_id}:metadata" metadata = proxy_server.redis_client.hgetall(metadata_key) - + if metadata and b'state' in metadata: state = metadata[b'state'].decode('utf-8') if state in ['waiting_for_clients', 'active']: @@ -199,19 +200,19 @@ def stream_ts(request, channel_id): keepalive_packet[0] = 0x47 # Sync byte keepalive_packet[1] = 0x1F # PID high bits (null packet) keepalive_packet[2] = 0xFF # PID low bits (null packet) - + # Add status info in packet payload (will be ignored by players) status_msg = f"Initializing: {state}".encode('utf-8') keepalive_packet[4:4+min(len(status_msg), 180)] = status_msg[:180] - + logger.debug(f"[{client_id}] Sending keepalive packet during initialization, state={state}") yield bytes(keepalive_packet) bytes_sent += len(keepalive_packet) last_keepalive = time.time() - + # Wait a bit before checking again (don't send too many keepalives) time.sleep(0.1) - + # Check if we timed out waiting if not channel_ready: logger.warning(f"[{client_id}] Timed out waiting for initialization") @@ -223,13 +224,13 @@ def stream_ts(request, channel_id): error_packet[4:4+min(len(error_msg), 180)] = error_msg[:180] yield bytes(error_packet) return - + # Channel is now ready - original streaming code goes here logger.info(f"[{client_id}] Channel {channel_id} ready, starting normal streaming") - + # Reset start time for real streaming stream_start_time = time.time() - + # Get buffer - stream manager may not exist in this worker buffer = proxy_server.stream_buffers.get(channel_id) stream_manager = proxy_server.stream_managers.get(channel_id) @@ -558,7 +559,7 @@ def channel_status(request, channel_id=None): try: # Check if Redis is available if not proxy_server.redis_client: - return JsonResponse({'error': 'Redis connection not available'}, status=500) + return JsonResponse({'error': 'Redis connection not available'}, status=500) # Handle single channel or all channels if channel_id: diff --git a/core/admin.py b/core/admin.py index 823e6a5a..45bb7a2e 100644 --- a/core/admin.py +++ b/core/admin.py @@ -6,26 +6,26 @@ from .models import UserAgent, StreamProfile, CoreSettings @admin.register(UserAgent) class UserAgentAdmin(admin.ModelAdmin): list_display = ( - "user_agent_name", + "name", "user_agent", "description", "is_active", "created_at", "updated_at", ) - search_fields = ("user_agent_name", "user_agent", "description") + search_fields = ("name", "user_agent", "description") list_filter = ("is_active",) readonly_fields = ("created_at", "updated_at") @admin.register(StreamProfile) class StreamProfileAdmin(admin.ModelAdmin): list_display = ( - "profile_name", + "name", "command", "is_active", "user_agent", ) - search_fields = ("profile_name", "command", "user_agent") + search_fields = ("name", "command", "user_agent") list_filter = ("is_active",) @admin.register(CoreSettings) diff --git a/core/fixtures/initial_data.json b/core/fixtures/initial_data.json index 57bafd05..c037fa78 100644 --- a/core/fixtures/initial_data.json +++ b/core/fixtures/initial_data.json @@ -3,7 +3,7 @@ "model": "core.useragent", "pk": 1, "fields": { - "user_agent_name": "TiviMate", + "name": "TiviMate", "user_agent": "TiviMate/5.1.6 (Android 12)", "description": "", "is_active": true @@ -13,7 +13,7 @@ "model": "core.useragent", "pk": 2, "fields": { - "user_agent_name": "VLC", + "name": "VLC", "user_agent": "VLC/3.0.21 LibVLC 3.0.21", "description": "", "is_active": true @@ -23,7 +23,7 @@ "model": "core.streamprofile", "pk": 1, "fields": { - "profile_name": "ffmpeg", + "name": "ffmpeg", "command": "ffmpeg", "parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1", "is_active": true, @@ -34,7 +34,7 @@ "model": "core.streamprofile", "pk": 2, "fields": { - "profile_name": "streamlink", + "name": "streamlink", "command": "streamlink", "parameters": "{streamUrl} best --stdout", "is_active": true, diff --git a/core/migrations/0007_create_proxy_and_redirect_stream_profiles.py b/core/migrations/0007_create_proxy_and_redirect_stream_profiles.py index cf10496a..41ca0eeb 100644 --- a/core/migrations/0007_create_proxy_and_redirect_stream_profiles.py +++ b/core/migrations/0007_create_proxy_and_redirect_stream_profiles.py @@ -1,8 +1,11 @@ # Generated by Django 5.1.6 on 2025-03-14 17:16 from django.db import migrations +from core.models import CoreSettings def create_proxy_stream_profile(apps, schema_editor): + default_user_agent_id = CoreSettings.get_default_user_agent_id() + StreamProfile = apps.get_model("core", "StreamProfile") StreamProfile.objects.create( profile_name="Proxy", @@ -10,7 +13,7 @@ def create_proxy_stream_profile(apps, schema_editor): parameters="", locked=True, is_active=True, - user_agent="1", + user_agent_id=default_user_agent_id, ) StreamProfile.objects.create( @@ -19,7 +22,7 @@ def create_proxy_stream_profile(apps, schema_editor): parameters="", locked=True, is_active=True, - user_agent="1", + user_agent_id=default_user_agent_id, ) class Migration(migrations.Migration): @@ -29,4 +32,5 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython(create_proxy_stream_profile) ] diff --git a/core/migrations/0008_rename_profile_name_streamprofile_name_and_more.py b/core/migrations/0008_rename_profile_name_streamprofile_name_and_more.py new file mode 100644 index 00000000..6f0b2824 --- /dev/null +++ b/core/migrations/0008_rename_profile_name_streamprofile_name_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-03-16 12:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_create_proxy_and_redirect_stream_profiles'), + ] + + operations = [ + migrations.RenameField( + model_name='streamprofile', + old_name='profile_name', + new_name='name', + ), + migrations.RenameField( + model_name='useragent', + old_name='user_agent_name', + new_name='name', + ), + ] diff --git a/core/models.py b/core/models.py index 65eb2ef8..e0171aee 100644 --- a/core/models.py +++ b/core/models.py @@ -3,7 +3,7 @@ from django.db import models from django.utils.text import slugify class UserAgent(models.Model): - user_agent_name = models.CharField( + name = models.CharField( max_length=512, unique=True, help_text="The User-Agent name." @@ -26,13 +26,13 @@ class UserAgent(models.Model): updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return self.user_agent_name + return self.name -PROXY_PROFILE = 'Proxy' -REDIRECT_PROFILE = 'Redirect' +PROXY_PROFILE_NAME = 'Proxy' +REDIRECT_PROFILE_NAME = 'Redirect' class StreamProfile(models.Model): - profile_name = models.CharField(max_length=255, help_text="Name of the stream profile") + name = models.CharField(max_length=255, help_text="Name of the stream profile") command = models.CharField( max_length=255, help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')", @@ -56,7 +56,7 @@ class StreamProfile(models.Model): ) def __str__(self): - return self.profile_name + return self.name def delete(self): if self.locked(): @@ -108,12 +108,12 @@ class StreamProfile(models.Model): return instance def is_proxy(self): - if self.locked and self.profile_name == PROXY_PROFILE: + if self.locked and self.name == PROXY_PROFILE_NAME: return True return False def is_redirect(self): - if self.locked and self.profile_name == REDIRECT_PROFILE: + if self.locked and self.name == REDIRECT_PROFILE_NAME: return True return False @@ -149,12 +149,10 @@ class CoreSettings(models.Model): return "Core Settings" @classmethod - def get_default_user_agent(cls): + def get_default_user_agent_id(cls): """Retrieve a system profile by name (or return None if not found).""" - default_ua_id = cls.objects.get(key=DEFAULT_USER_AGENT_KEY).value - return UserAgent.objects.get(id=default_ua_id) + return cls.objects.get(key=DEFAULT_USER_AGENT_KEY).value @classmethod - def get_default_stream_profile(cls): - default_sp_id = cls.objects.get(key=DEFAULT_STREAM_PROFILE_KEY).value - return StreamProfile.objects.get(id=default_sp_id) + def get_default_stream_profile_id(cls): + return cls.objects.get(key=DEFAULT_STREAM_PROFILE_KEY).value diff --git a/core/serializers.py b/core/serializers.py index 32f70ebd..c80ad630 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -6,12 +6,12 @@ from .models import UserAgent, StreamProfile, CoreSettings class UserAgentSerializer(serializers.ModelSerializer): class Meta: model = UserAgent - fields = ['id', 'user_agent_name', 'user_agent', 'description', 'is_active', 'created_at', 'updated_at'] + fields = ['id', 'name', 'user_agent', 'description', 'is_active', 'created_at', 'updated_at'] class StreamProfileSerializer(serializers.ModelSerializer): class Meta: model = StreamProfile - fields = ['id', 'profile_name', 'command', 'parameters', 'is_active', 'user_agent'] + fields = ['id', 'name', 'command', 'parameters', 'is_active', 'user_agent', 'locked'] class CoreSettingsSerializer(serializers.ModelSerializer): class Meta: diff --git a/core/views.py b/core/views.py index 73652b7e..a5852171 100644 --- a/core/views.py +++ b/core/views.py @@ -41,7 +41,7 @@ def stream_view(request, stream_id): # Retrieve the channel by the provided stream_id. channel = Channel.objects.get(channel_number=stream_id) - logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.channel_name) + logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.name) # Ensure the channel has at least one stream. if not channel.streams.exists(): @@ -65,7 +65,7 @@ def stream_view(request, stream_id): logger.debug("Stream M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name) # Use the custom URL if available; otherwise, use the standard URL. - input_url = stream.custom_url or stream.url + input_url = stream.url logger.debug("Input URL: %s", input_url) # Determine which profile we can use. @@ -132,7 +132,7 @@ def stream_view(request, stream_id): logger.error("No stream profile set for channel ID=%s, using default", channel.id) stream_profile = StreamProfile.objects.get(id=CoreSettings.objects.get(key="default-stream-profile").value) - logger.debug("Stream profile used: %s", stream_profile.profile_name) + logger.debug("Stream profile used: %s", stream_profile.name) # Determine the user agent to use. user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0") diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 6005effc..5c3ee116 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -20,7 +20,7 @@ INSTALLED_APPS = [ 'apps.hdhr', 'apps.m3u', 'apps.output', - 'apps.proxy.apps.ProxyConfig', + 'apps.proxy.apps.ProxyConfig', 'core', 'drf_yasg', 'daphne', diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 28a0fab1..af56d041 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -58,7 +58,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { const formik = useFormik({ initialValues: { - channel_name: '', + name: '', channel_number: '', channel_group_id: '', stream_profile_id: '0', @@ -66,7 +66,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { tvg_name: '', }, validationSchema: Yup.object({ - channel_name: Yup.string().required('Name is required'), + name: Yup.string().required('Name is required'), channel_number: Yup.string().required('Invalid channel number').min(0), channel_group_id: Yup.string().required('Channel group is required'), }), @@ -102,7 +102,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { useEffect(() => { if (channel) { formik.setValues({ - channel_name: channel.channel_name, + name: channel.name, channel_number: channel.channel_number, channel_group_id: channel.channel_group?.id, stream_profile_id: channel.stream_profile_id || '0', @@ -256,14 +256,12 @@ const Channel = ({ channel = null, isOpen, onClose }) => { @@ -312,7 +310,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { } data={streamProfiles.map((option) => ({ value: `${option.id}`, - label: option.profile_name, + label: option.name, }))} /> diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 051826ac..86131ea2 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -138,7 +138,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => { onChange={formik.handleChange} error={formik.errors.user_agent ? formik.touched.user_agent : ''} data={userAgents.map((ua) => ({ - label: ua.user_agent_name, + label: ua.name, value: `${ua.id}`, }))} /> diff --git a/frontend/src/components/forms/Stream.jsx b/frontend/src/components/forms/Stream.jsx index adc42485..6c058345 100644 --- a/frontend/src/components/forms/Stream.jsx +++ b/frontend/src/components/forms/Stream.jsx @@ -91,7 +91,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => { onChange={setSelectedStreamProfile} error={formik.errors.stream_profile_id} data={streamProfiles.map((profile) => ({ - label: profile.profile_name, + label: profile.name, value: `${profile.id}`, }))} comboboxProps={{ withinPortal: false, zIndex: 1000 }} diff --git a/frontend/src/components/forms/StreamProfile.jsx b/frontend/src/components/forms/StreamProfile.jsx index eadcca13..ec7d4d8a 100644 --- a/frontend/src/components/forms/StreamProfile.jsx +++ b/frontend/src/components/forms/StreamProfile.jsx @@ -11,14 +11,14 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => { const formik = useFormik({ initialValues: { - profile_name: '', + name: '', command: '', parameters: '', is_active: true, user_agent: '', }, validationSchema: Yup.object({ - profile_name: Yup.string().required('Name is required'), + name: Yup.string().required('Name is required'), command: Yup.string().required('Command is required'), parameters: Yup.string().required('Parameters are is required'), }), @@ -38,7 +38,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => { useEffect(() => { if (profile) { formik.setValues({ - profile_name: profile.profile_name, + name: profile.name, command: profile.command, parameters: profile.parameters, is_active: profile.is_active, @@ -57,12 +57,13 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
{ value={formik.values.command} onChange={formik.handleChange} error={formik.errors.command} + disabled={profile ? profile.locked : false} /> { value={formik.values.parameters} onChange={formik.handleChange} error={formik.errors.parameters} + disabled={profile ? profile.locked : false} />