diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..d36be10c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: Bug Report +description: I have an issue with Dispatcharr +title: "[Bug]: " +labels: ["Bug", "Triage"] +type: "Bug" +projects: [] +assignees: [] +body: + - type: markdown + attributes: + value: | + Please make sure you search for similar issues before submitting. Thank you for your bug report! + - type: textarea + id: describe-the-bug + attributes: + label: Describe the bug + description: Make sure to attach screenshots if possible! + placeholder: Tell us what you see! + value: "A clear and concise description of what the bug is. What did you expect to happen?" + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: How can we recreate this bug? + description: Be detailed! + placeholder: Tell us what you see! + value: "1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error" + validations: + required: true + - type: input + id: dispatcharr-version + attributes: + label: Dispatcharr Version + description: What version of Dispatcharr are you running? + placeholder: Located bottom left of main screen + validations: + required: true + - type: input + id: docker-version + attributes: + label: Docker Version + description: What version of Docker are you running? + placeholder: docker --version + validations: + required: true + - type: textarea + id: docker-compose + attributes: + label: What's in your Docker Compose file? + description: Please share your docker-compose.yml file + placeholder: Tell us what you see! + value: "If not using Docker Compose just put not using." + validations: + required: true + - type: textarea + id: client-info + attributes: + label: Client Information + description: What are you using the view the streams from Dispatcharr + placeholder: Tell us what you see! + value: "Device, App, Versions for both, etc..." + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ec4bb386 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..bf7db830 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: Feature request +description: I want to suggest a new feature for Dispatcharr +title: "[Feature]: " +labels: ["Feature Request"] +type: "Feature" +projects: [] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thank you for helping to make Dispatcharr better! + - type: textarea + id: describe-problem + attributes: + label: Is your feature request related to a problem? + description: Make sure to attach screenshots if possible! + placeholder: Tell us what you see! + value: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]" + validations: + required: true + - type: textarea + id: describe-solution + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + placeholder: Tell us what you see! + value: "Describe here." + validations: + required: true + - type: textarea + id: extras + attributes: + label: Additional context + description: Anything else you want to add? + placeholder: Tell us what you see! + value: "Nothing Extra" + validations: + required: true \ No newline at end of file diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 1a5f832e..d3062171 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -843,7 +843,7 @@ def parse_channels_only(source): # Change iterparse to look for both channel and programme elements logger.debug(f"Creating iterparse context for channels and programmes") - channel_parser = etree.iterparse(source_file, events=('end',), tag=('channel', 'programme')) + channel_parser = etree.iterparse(source_file, events=('end',), tag=('channel', 'programme'), remove_blank_text=True) if process: logger.debug(f"[parse_channels_only] Memory after creating iterparse: {process.memory_info().rss / 1024 / 1024:.2f} MB") @@ -851,7 +851,6 @@ def parse_channels_only(source): total_elements_processed = 0 # Track total elements processed, not just channels for _, elem in channel_parser: total_elements_processed += 1 - # Only process channel elements if elem.tag == 'channel': channel_count += 1 @@ -967,6 +966,7 @@ def parse_channels_only(source): logger.debug(f"[parse_channels_only] Total elements processed: {total_elements_processed}") else: + logger.trace(f"[parse_channels_only] Skipping non-channel element: {elem.get('channel', 'unknown')} - {elem.get('start', 'unknown')} {elem.tag}") clear_element(elem) continue @@ -1034,6 +1034,11 @@ def parse_channels_only(source): if process: logger.debug(f"[parse_channels_only] Memory before cleanup: {process.memory_info().rss / 1024 / 1024:.2f} MB") try: + # Output any errors in the channel_parser error log + if 'channel_parser' in locals() and hasattr(channel_parser, 'error_log') and len(channel_parser.error_log) > 0: + logger.debug(f"XML parser errors found ({len(channel_parser.error_log)} total):") + for i, error in enumerate(channel_parser.error_log): + logger.debug(f" Error {i+1}: {error}") if 'channel_parser' in locals(): del channel_parser if 'elem' in locals(): @@ -1190,7 +1195,7 @@ def parse_programs_for_tvg_id(epg_id): source_file = open(file_path, 'rb') # Stream parse the file using lxml's iterparse - program_parser = etree.iterparse(source_file, events=('end',), tag='programme') + program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True) for _, elem in program_parser: if elem.get('channel') == epg.tvg_id: @@ -1629,6 +1634,9 @@ def extract_custom_properties(prog): elif system == 'onscreen' and ep_num.text: # Just store the raw onscreen format custom_props['onscreen_episode'] = ep_num.text.strip() + elif system == 'dd_progid' and ep_num.text: + # Store the dd_progid format + custom_props['dd_progid'] = ep_num.text.strip() # Extract ratings more efficiently rating_elem = prog.find('rating') @@ -1664,7 +1672,7 @@ def extract_custom_properties(prog): custom_props['icon'] = icon_elem.get('src') # Simpler approach for boolean flags - for kw in ['previously-shown', 'premiere', 'new']: + for kw in ['previously-shown', 'premiere', 'new', 'live']: if prog.find(kw) is not None: custom_props[kw.replace('-', '_')] = True diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 278efc36..0d7b77e0 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -79,8 +79,8 @@ class DiscoverAPIView(APIView): # Otherwise use the limited profile sum plus custom streams tuner_count = limited_tuners + custom_stream_count - # 5. Ensure minimum of 2 tuners - tuner_count = max(2, tuner_count) + # 5. Ensure minimum of 1 tuners + tuner_count = max(1, tuner_count) logger.debug(f"Calculated tuner count: {tuner_count} (limited profiles: {limited_tuners}, custom streams: {custom_stream_count}, unlimited: {has_unlimited})") diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index b1b1170d..ce46a2ec 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -239,7 +239,7 @@ def process_groups(account, groups): group_objs = [] groups_to_create = [] for group_name, custom_props in groups.items(): - logger.debug(f"Handling group: {group_name}") + logger.debug(f"Handling group for M3U account {account.id}: {group_name}") if (group_name not in existing_groups): groups_to_create.append(ChannelGroup( name=group_name, @@ -405,7 +405,7 @@ def process_m3u_batch(account_id, batch, groups, hash_keys): stream_hashes = {} # compiled_filters = [(f.filter_type, re.compile(f.regex_pattern, re.IGNORECASE)) for f in filters] - logger.debug(f"Processing batch of {len(batch)}") + logger.debug(f"Processing batch of {len(batch)} for M3U account {account_id}") for stream_info in batch: try: name, url = stream_info["name"], stream_info["url"] @@ -487,7 +487,7 @@ def process_m3u_batch(account_id, batch, groups, hash_keys): except Exception as e: logger.error(f"Bulk create failed: {str(e)}") - retval = f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." + retval = f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." # Aggressive garbage collection #del streams_to_create, streams_to_update, stream_hashes, existing_streams @@ -496,17 +496,17 @@ def process_m3u_batch(account_id, batch, groups, hash_keys): return retval -def cleanup_streams(account_id): +def cleanup_streams(account_id, scan_start_time=timezone.now): account = M3UAccount.objects.get(id=account_id, is_active=True) existing_groups = ChannelGroup.objects.filter( m3u_account__m3u_account=account, m3u_account__enabled=True, ).values_list('id', flat=True) - logger.info(f"Found {len(existing_groups)} active groups") + logger.info(f"Found {len(existing_groups)} active groups for M3U account {account_id}") # Calculate cutoff date for stale streams - stale_cutoff = timezone.now() - timezone.timedelta(days=account.stale_stream_days) - logger.info(f"Removing streams not seen since {stale_cutoff}") + stale_cutoff = scan_start_time - timezone.timedelta(days=account.stale_stream_days) + logger.info(f"Removing streams not seen since {stale_cutoff} for M3U account {account_id}") # Delete streams that are not in active groups streams_to_delete = Stream.objects.filter( @@ -527,7 +527,11 @@ def cleanup_streams(account_id): streams_to_delete.delete() stale_streams.delete() - logger.info(f"Cleanup complete: {deleted_count} streams removed due to group filter, {stale_count} removed as stale") + total_deleted = deleted_count + stale_count + logger.info(f"Cleanup for M3U account {account_id} complete: {deleted_count} streams removed due to group filter, {stale_count} removed as stale") + + # Return the total count of deleted streams + return total_deleted @shared_task def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): @@ -712,7 +716,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): group_name = parsed["attributes"]["group-title"] # Log new groups as they're discovered if group_name not in groups: - logger.debug(f"Found new group: '{group_name}'") + logger.debug(f"Found new group for M3U account {account_id}: '{group_name}'") groups[group_name] = {} extinf_data.append(parsed) @@ -729,7 +733,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): # Periodically log progress for large files if valid_stream_count % 1000 == 0: - logger.debug(f"Processed {valid_stream_count} valid streams so far...") + logger.debug(f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}") # Log summary statistics logger.info(f"M3U parsing complete - Lines: {line_count}, EXTINF: {extinf_count}, URLs: {url_count}, Valid streams: {valid_stream_count}") @@ -833,7 +837,8 @@ def refresh_single_m3u_account(account_id): return f"Task already running for account_id={account_id}." # Record start time - start_time = time.time() + refresh_start_timestamp = timezone.now() # For the cleanup function + start_time = time.time() # For tracking elapsed time as float streams_created = 0 streams_updated = 0 streams_deleted = 0 @@ -962,7 +967,7 @@ def refresh_single_m3u_account(account_id): account.save(update_fields=['status']) if account.account_type == M3UAccount.Types.STADNARD: - logger.debug(f"Processing Standard account with groups: {existing_groups}") + logger.debug(f"Processing Standard account ({account_id}) with groups: {existing_groups}") # Break into batches and process in parallel batches = [extinf_data[i:i + BATCH_SIZE] for i in range(0, len(extinf_data), BATCH_SIZE)] task_group = group(process_m3u_batch.s(account_id, batch, existing_groups, hash_keys) for batch in batches) @@ -1077,7 +1082,7 @@ def refresh_single_m3u_account(account_id): Stream.objects.filter(id=-1).exists() # This will never find anything but ensures DB sync # Now run cleanup - cleanup_streams(account_id) + streams_deleted = cleanup_streams(account_id, refresh_start_timestamp) # Calculate elapsed time elapsed_time = time.time() - start_time @@ -1107,8 +1112,6 @@ def refresh_single_m3u_account(account_id): message=account.last_message ) - print(f"Function took {elapsed_time} seconds to execute.") - except Exception as e: logger.error(f"Error processing M3U for account {account_id}: {str(e)}") account.status = M3UAccount.Status.ERROR diff --git a/apps/output/views.py b/apps/output/views.py index 39b20a41..2b18d185 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1,17 +1,27 @@ -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseForbidden from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods from apps.channels.models import Channel, ChannelProfile from apps.epg.models import ProgramData from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt from datetime import datetime, timedelta import re import html # Add this import for XML escaping +@csrf_exempt +@require_http_methods(["GET", "POST"]) def generate_m3u(request, profile_name=None): """ Dynamically generate an M3U file from channels. The stream URL now points to the new stream_view that uses StreamProfile. + Supports both GET and POST methods for compatibility with IPTVSmarters. """ + # Check if this is a POST request with data (which we don't want to allow) + if request.method == "POST" and request.body: + return HttpResponseForbidden("POST requests with content are not allowed") + if profile_name is not None: channel_profile = ChannelProfile.objects.get(name=profile_name) channels = Channel.objects.filter( @@ -21,6 +31,16 @@ def generate_m3u(request, profile_name=None): else: channels = Channel.objects.order_by('channel_number') + # Check if the request wants to use direct logo URLs instead of cache + use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' + + # Check if direct stream URLs should be used instead of proxy + use_direct_urls = request.GET.get('direct', 'false').lower() == 'true' + + # Get the source to use for tvg-id value + # Options: 'channel_number' (default), 'tvg_id', 'gracenote' + tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() + m3u_content = "#EXTM3U\n" for channel in channels: group_title = channel.channel_group.name if channel.channel_group else "Default" @@ -34,13 +54,30 @@ def generate_m3u(request, profile_name=None): else: formatted_channel_number = "" - # Use formatted channel number for tvg_id to ensure proper matching with EPG - tvg_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) + # Determine the tvg-id based on the selected source + if tvg_id_source == 'tvg_id' and channel.tvg_id: + tvg_id = channel.tvg_id + elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: + tvg_id = channel.tvc_guide_stationid + else: + # Default to channel number (original behavior) + tvg_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) + tvg_name = channel.name tvg_logo = "" if channel.logo: - tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) + if use_cached_logos: + # Use cached logo as before + tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) + else: + # Try to find direct logo URL from channel's streams + direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None + # If direct logo found, use it; otherwise fall back to cached version + if direct_logo: + tvg_logo = direct_logo + else: + tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) # create possible gracenote id insertion tvc_guide_stationid = "" @@ -52,10 +89,22 @@ def generate_m3u(request, profile_name=None): f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n' ) - base_url = request.build_absolute_uri('/')[:-1] - stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" + # Determine the stream URL based on the direct parameter + if use_direct_urls: + # Try to get the first stream's direct URL + first_stream = channel.streams.first() + if first_stream and first_stream.url: + # Use the direct stream URL + stream_url = first_stream.url + else: + # Fall back to proxy URL if no direct URL available + base_url = request.build_absolute_uri('/')[:-1] + stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" + else: + # Standard behavior - use proxy URL + base_url = request.build_absolute_uri('/')[:-1] + stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" - #stream_url = request.build_absolute_uri(reverse('output:stream', args=[channel.id])) m3u_content += extinf_line + stream_url + "\n" response = HttpResponse(m3u_content, content_type="audio/x-mpegurl") @@ -160,7 +209,7 @@ def generate_epg(request, profile_name=None): Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models. Since the EPG data is stored independently of Channels, we group programmes by their associated EPGData record. - This version does not filter by time, so it includes the entire EPG saved in the DB. + This version filters data based on the 'days' parameter. """ xml_lines = [] xml_lines.append('') @@ -175,57 +224,115 @@ def generate_epg(request, profile_name=None): else: channels = Channel.objects.all() + # Check if the request wants to use direct logo URLs instead of cache + use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' + + # Get the source to use for tvg-id value + # Options: 'channel_number' (default), 'tvg_id', 'gracenote' + tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() + + # Get the number of days for EPG data + try: + # Default to 0 days (everything) for real EPG if not specified + days_param = request.GET.get('days', '0') + num_days = int(days_param) + # Set reasonable limits + num_days = max(0, min(num_days, 365)) # Between 0 and 365 days + except ValueError: + num_days = 0 # Default to all data if invalid value + + # For dummy EPG, use either the specified value or default to 3 days + dummy_days = num_days if num_days > 0 else 3 + + # Calculate cutoff date for EPG data filtering (only if days > 0) + now = timezone.now() + cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None + # Retrieve all active channels for channel in channels: # Format channel number as integer if it has no decimal component - same as M3U generation if channel.channel_number is not None: if channel.channel_number == int(channel.channel_number): - formatted_channel_number = str(int(channel.channel_number)) + formatted_channel_number = int(channel.channel_number) else: - formatted_channel_number = str(channel.channel_number) + formatted_channel_number = channel.channel_number else: - formatted_channel_number = str(channel.id) + formatted_channel_number = "" - display_name = channel.epg_data.name if channel.epg_data else channel.name - xml_lines.append(f' ') - xml_lines.append(f' {html.escape(display_name)}') + # Determine the channel ID based on the selected source + if tvg_id_source == 'tvg_id' and channel.tvg_id: + channel_id = channel.tvg_id + elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: + channel_id = channel.tvc_guide_stationid + else: + # Default to channel number (original behavior) + channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) # Add channel logo if available + tvg_logo = "" if channel.logo: - logo_url = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) - xml_lines.append(f' ') + if use_cached_logos: + # Use cached logo as before + tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) + else: + # Try to find direct logo URL from channel's streams + direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None + # If direct logo found, use it; otherwise fall back to cached version + if direct_logo: + tvg_logo = direct_logo + else: + tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) + display_name = channel.epg_data.name if channel.epg_data else channel.name + xml_lines.append(f' ') + xml_lines.append(f' {html.escape(display_name)}') + xml_lines.append(f' ') xml_lines.append(' ') for channel in channels: - # Use the same formatting for channel ID in program entries - if channel.channel_number is not None: - if channel.channel_number == int(channel.channel_number): - formatted_channel_number = str(int(channel.channel_number)) - else: - formatted_channel_number = str(channel.channel_number) + # Use the same channel ID determination for program entries + if tvg_id_source == 'tvg_id' and channel.tvg_id: + channel_id = channel.tvg_id + elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: + channel_id = channel.tvc_guide_stationid else: - formatted_channel_number = str(channel.id) + # Get formatted channel number + if channel.channel_number is not None: + if channel.channel_number == int(channel.channel_number): + formatted_channel_number = int(channel.channel_number) + else: + formatted_channel_number = channel.channel_number + else: + formatted_channel_number = "" + # Default to channel number + channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) display_name = channel.epg_data.name if channel.epg_data else channel.name if not channel.epg_data: # Use the enhanced dummy EPG generation function with defaults - # These values could be made configurable via settings or request parameters - num_days = 1 # Default to 1 days of dummy EPG data program_length_hours = 4 # Default to 4-hour program blocks generate_dummy_epg( - formatted_channel_number, + channel_id, display_name, xml_lines, - num_days=num_days, + num_days=dummy_days, # Use dummy_days (3 days by default) program_length_hours=program_length_hours ) else: - programs = channel.epg_data.programs.all() + # For real EPG data - filter only if days parameter was specified + if num_days > 0: + programs = channel.epg_data.programs.filter( + start_time__gte=now, + start_time__lt=cutoff_date + ) + else: + # Return all programs if days=0 or not specified + programs = channel.epg_data.programs.all() + for prog in programs: start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z") stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z") - xml_lines.append(f' ') + xml_lines.append(f' ') xml_lines.append(f' {html.escape(prog.title)}') # Add subtitle if available @@ -256,6 +363,10 @@ def generate_epg(request, profile_name=None): if 'onscreen_episode' in custom_data: xml_lines.append(f' {html.escape(custom_data["onscreen_episode"])}') + # Handle dd_progid format + if 'dd_progid' in custom_data: + xml_lines.append(f' {html.escape(custom_data["dd_progid"])}') + # 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 @@ -302,6 +413,9 @@ def generate_epg(request, profile_name=None): if custom_data.get('new', False): xml_lines.append(f' ') + if custom_data.get('live', False): + xml_lines.append(f' ') + except Exception as e: xml_lines.append(f' ') diff --git a/apps/proxy/ts_proxy/channel_status.py b/apps/proxy/ts_proxy/channel_status.py index dd18d922..50e84eec 100644 --- a/apps/proxy/ts_proxy/channel_status.py +++ b/apps/proxy/ts_proxy/channel_status.py @@ -307,16 +307,23 @@ class ChannelStatus: client_count = proxy_server.redis_client.scard(client_set_key) or 0 # Calculate uptime - created_at = float(metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0').decode('utf-8')) + init_time_bytes = metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0') + created_at = float(init_time_bytes.decode('utf-8')) uptime = time.time() - created_at if created_at > 0 else 0 + # Safely decode bytes or use defaults + def safe_decode(bytes_value, default="unknown"): + if bytes_value is None: + return default + return bytes_value.decode('utf-8') + # Simplified info info = { 'channel_id': channel_id, - 'state': metadata.get(ChannelMetadataField.STATE.encode('utf-8'), b'unknown').decode('utf-8'), - 'url': metadata.get(ChannelMetadataField.URL.encode('utf-8'), b'').decode('utf-8'), - 'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8'), b'').decode('utf-8'), - 'owner': metadata.get(ChannelMetadataField.OWNER.encode('utf-8'), b'unknown').decode('utf-8'), + 'state': safe_decode(metadata.get(ChannelMetadataField.STATE.encode('utf-8'))), + 'url': safe_decode(metadata.get(ChannelMetadataField.URL.encode('utf-8')), ""), + 'stream_profile': safe_decode(metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8')), ""), + 'owner': safe_decode(metadata.get(ChannelMetadataField.OWNER.encode('utf-8'))), 'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0, 'client_count': client_count, 'uptime': uptime @@ -376,14 +383,15 @@ class ChannelStatus: # Efficient way - just retrieve the essentials client_info = { 'client_id': client_id_str, - 'user_agent': proxy_server.redis_client.hget(client_key, 'user_agent'), - 'ip_address': proxy_server.redis_client.hget(client_key, 'ip_address').decode('utf-8'), } - if client_info['user_agent']: - client_info['user_agent'] = client_info['user_agent'].decode('utf-8') - else: - client_info['user_agent'] = 'unknown' + # Safely get user_agent and ip_address + user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent') + client_info['user_agent'] = safe_decode(user_agent_bytes) + + ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address') + if ip_address_bytes: + client_info['ip_address'] = safe_decode(ip_address_bytes) # Just get connected_at for client age connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at') @@ -416,5 +424,5 @@ class ChannelStatus: return info except Exception as e: - logger.error(f"Error getting channel info: {e}") + logger.error(f"Error getting channel info: {e}", exc_info=True) # Added exc_info for better debugging return None diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 4e1e0d55..06084b49 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -199,15 +199,6 @@ CELERY_BROKER_TRANSPORT_OPTIONS = { CELERY_ACCEPT_CONTENT = ['json'] CELERY_TASK_SERIALIZER = 'json' -# Memory management settings -#CELERY_WORKER_MAX_TASKS_PER_CHILD = 10 # Restart worker after 10 tasks to free memory -#CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # Don't prefetch tasks - process one at a time -#CELERY_TASK_ACKS_LATE = True # Only acknowledge tasks after they're processed -#CELERY_TASK_TIME_LIMIT = 3600 # 1 hour time limit per task -#CELERY_TASK_SOFT_TIME_LIMIT = 3540 # Soft limit 60 seconds before hard limit -#CELERY_WORKER_CANCEL_LONG_RUNNING_TASKS_ON_CONNECTION_LOSS = True # Cancel tasks if connection lost -#CELERY_TASK_IGNORE_RESULT = True # Don't store results unless explicitly needed - CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler" CELERY_BEAT_SCHEDULE = { 'fetch-channel-statuses': { diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 59e21010..d195fbdc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,6 @@ services: web: - image: dispatcharr/dispatcharr:alpha-v1 + image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_web ports: - 9191:9191 @@ -32,7 +32,7 @@ services: # capabilities: [gpu] celery: - image: dispatcharr/dispatcharr:alpha-v1 + image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery depends_on: - db diff --git a/docker/uwsgi.debug.ini b/docker/uwsgi.debug.ini index 6ca855f3..e049df87 100644 --- a/docker/uwsgi.debug.ini +++ b/docker/uwsgi.debug.ini @@ -8,7 +8,7 @@ exec-before = python /app/scripts/wait_for_redis.py ; Start Redis first attach-daemon = redis-server ; Then start other services -attach-daemon = celery -A dispatcharr worker --concurrency=4 +attach-daemon = celery -A dispatcharr worker --autoscale=6,1 attach-daemon = celery -A dispatcharr beat attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application attach-daemon = cd /app/frontend && npm run dev diff --git a/docker/uwsgi.dev.ini b/docker/uwsgi.dev.ini index f3e5238e..7e50f2ef 100644 --- a/docker/uwsgi.dev.ini +++ b/docker/uwsgi.dev.ini @@ -10,7 +10,7 @@ exec-pre = python /app/scripts/wait_for_redis.py ; Start Redis first attach-daemon = redis-server ; Then start other services -attach-daemon = celery -A dispatcharr worker --concurrency=4 +attach-daemon = celery -A dispatcharr worker --autoscale=6,1 attach-daemon = celery -A dispatcharr beat attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application attach-daemon = cd /app/frontend && npm run dev diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index 32eb6e3c..b35ea5bf 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -10,7 +10,7 @@ exec-pre = python /app/scripts/wait_for_redis.py ; Start Redis first attach-daemon = redis-server ; Then start other services -attach-daemon = celery -A dispatcharr worker --concurrency=4 +attach-daemon = celery -A dispatcharr worker --autoscale=6,1 attach-daemon = celery -A dispatcharr beat attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 260ec468..aeed8826 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -192,9 +192,10 @@ export const WebsocketProvider = ({ children }) => { // Update the playlist status whenever we receive a status update // Not just when progress is 100% or status is pending_setup if (parsedEvent.data.status && parsedEvent.data.account) { - const playlist = playlists.find( - (p) => p.id === parsedEvent.data.account - ); + // Check if playlists is an object with IDs as keys or an array + const playlist = Array.isArray(playlists) + ? playlists.find((p) => p.id === parsedEvent.data.account) + : playlists[parsedEvent.data.account]; if (playlist) { // When we receive a "success" status with 100% progress, this is a completed refresh @@ -212,9 +213,18 @@ export const WebsocketProvider = ({ children }) => { parsedEvent.data.progress === 100 ) { updateData.updated_at = new Date().toISOString(); + // Log successful completion for debugging + console.log('M3U refresh completed successfully:', updateData); } updatePlaylist(updateData); + } else { + // Log when playlist can't be found for debugging purposes + console.warn( + `Received update for unknown playlist ID: ${parsedEvent.data.account}`, + Array.isArray(playlists) ? 'playlists is array' : 'playlists is object', + Object.keys(playlists).length + ); } } break; diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 9affa984..24ddd377 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -85,7 +85,7 @@ const M3U = ({ account_type: m3uAccount.account_type, username: m3uAccount.username ?? '', password: '', - stale_stream_days: m3uAccount.stale_stream_days || 7, + stale_stream_days: m3uAccount.stale_stream_days !== undefined && m3uAccount.stale_stream_days !== null ? m3uAccount.stale_stream_days : 7, }); if (m3uAccount.account_type == 'XC') { @@ -225,7 +225,7 @@ const M3U = ({ id="account_type" name="account_type" label="Account Type" - description="Standard for direct M3U URLs, Xtream Codes for panel-based services" + description={<>Standard for direct M3U URLs,
Xtream Codes for panel-based services} data={[ { value: 'STD', @@ -233,7 +233,7 @@ const M3U = ({ }, { value: 'XC', - label: 'XTream Codes', + label: 'Xtream Codes', }, ]} key={form.key('account_type')} @@ -324,7 +324,7 @@ const M3U = ({ />