import ipaddress from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse from rest_framework.response import Response from django.urls import reverse from apps.channels.models import Channel, ChannelProfile, ChannelGroup from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from apps.epg.models import ProgramData from apps.accounts.models import User from dispatcharr.utils import network_access_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 from datetime import datetime, timedelta import html # Add this import for XML escaping import json # Add this import for JSON parsing import time # Add this import for keep-alive delays from tzlocal import get_localzone from urllib.parse import urlparse import base64 import logging from django.db.models.functions import Lower import os from apps.m3u.utils import calculate_tuner_count import regex from core.utils import log_system_event import hashlib logger = logging.getLogger(__name__) def get_client_identifier(request): """Get client information including IP, user agent, and a unique hash identifier Returns: tuple: (client_id_hash, client_ip, user_agent) """ # Get client IP (handle proxies) x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: client_ip = x_forwarded_for.split(',')[0].strip() else: client_ip = request.META.get('REMOTE_ADDR', 'unknown') # Get user agent user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') # Create a hash for a shorter cache key client_str = f"{client_ip}:{user_agent}" client_id_hash = hashlib.md5(client_str.encode()).hexdigest()[:12] return client_id_hash, client_ip, user_agent def m3u_endpoint(request, profile_name=None, user=None): logger.debug("m3u_endpoint called: method=%s, profile=%s", request.method, profile_name) if not network_access_allowed(request, "M3U_EPG"): # Log blocked M3U download from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') log_system_event( event_type='m3u_blocked', profile=profile_name or 'all', reason='Network access denied', client_ip=client_ip, user_agent=user_agent, ) return JsonResponse({"error": "Forbidden"}, status=403) # Handle HEAD requests efficiently without generating content if request.method == "HEAD": logger.debug("Handling HEAD request for M3U") response = HttpResponse(content_type="audio/x-mpegurl") response["Content-Disposition"] = 'attachment; filename="channels.m3u"' return response return generate_m3u(request, profile_name, user) def epg_endpoint(request, profile_name=None, user=None): logger.debug("epg_endpoint called: method=%s, profile=%s", request.method, profile_name) if not network_access_allowed(request, "M3U_EPG"): # Log blocked EPG download from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') log_system_event( event_type='epg_blocked', profile=profile_name or 'all', reason='Network access denied', client_ip=client_ip, user_agent=user_agent, ) return JsonResponse({"error": "Forbidden"}, status=403) # Handle HEAD requests efficiently without generating content if request.method == "HEAD": logger.debug("Handling HEAD request for EPG") response = HttpResponse(content_type="application/xml") response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"' response["Cache-Control"] = "no-cache" return response return generate_epg(request, profile_name, user) @csrf_exempt @require_http_methods(["GET", "POST", "HEAD"]) def generate_m3u(request, profile_name=None, user=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 and the body is not empty (which we don't want to allow) logger.debug("Generating M3U for profile: %s, user: %s, method: %s", profile_name, user.username if user else "Anonymous", request.method) # Check cache for recent identical request (helps with double-GET from browsers) from django.core.cache import cache cache_params = f"{profile_name or 'all'}:{user.username if user else 'anonymous'}:{request.GET.urlencode()}" content_cache_key = f"m3u_content:{cache_params}" cached_content = cache.get(content_cache_key) if cached_content: logger.debug("Serving M3U from cache") response = HttpResponse(cached_content, content_type="audio/x-mpegurl") response["Content-Disposition"] = 'attachment; filename="channels.m3u"' return response # Check if this is a POST request with data (which we don't want to allow) if request.method == "POST" and request.body: if request.body.decode() != '{}': return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode())) if user is not None: if user.user_level == 0: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number") else: # User has specific limited profiles assigned filters = { "channelprofilemembership__enabled": True, "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() } channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( "channel_number" ) else: if profile_name is not None: try: channel_profile = ChannelProfile.objects.get(name=profile_name) except ChannelProfile.DoesNotExist: logger.warning("Requested channel profile (%s) during m3u generation does not exist", profile_name) raise Http404(f"Channel profile '{profile_name}' not found") channels = Channel.objects.filter( channelprofilemembership__channel_profile=channel_profile, channelprofilemembership__enabled=True ).order_by('channel_number') 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() # Build EPG URL with query parameters if needed # Check if this is an XC API request (has username/password in GET params and user is authenticated) xc_username = request.GET.get('username') xc_password = request.GET.get('password') if user is not None and xc_username and xc_password: # This is an XC API request - use XC-style EPG URL base_url = build_absolute_uri_with_port(request, '') epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}" else: # Regular request - use standard EPG endpoint epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint')) # Optionally preserve certain query parameters preserved_params = ['tvg_id_source', 'cachedlogos', 'days'] query_params = {k: v for k, v in request.GET.items() if k in preserved_params} if query_params: from urllib.parse import urlencode epg_url = f"{epg_base_url}?{urlencode(query_params)}" else: epg_url = epg_base_url # Add x-tvg-url and url-tvg attribute for EPG URL m3u_content = f'#EXTM3U x-tvg-url="{epg_url}" url-tvg="{epg_url}"\n' # Start building M3U content for channel in channels: group_title = channel.channel_group.name if channel.channel_group else "Default" # Format channel number as integer if it has no decimal component 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 = "" # 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: if use_cached_logos: # Use cached logo as before tvg_logo = build_absolute_uri_with_port(request, 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 = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) # create possible gracenote id insertion tvc_guide_stationid = "" if channel.tvc_guide_stationid: tvc_guide_stationid = ( f'tvc-guide-stationid="{channel.tvc_guide_stationid}" ' ) extinf_line = ( f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" ' f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n' ) # 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.order_by('channelstream__order').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 stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") else: # Standard behavior - use proxy URL stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") m3u_content += extinf_line + stream_url + "\n" # Cache the generated content for 2 seconds to handle double-GET requests cache.set(content_cache_key, m3u_content, 2) # Log system event for M3U download (with deduplication based on client) client_id, client_ip, user_agent = get_client_identifier(request) event_cache_key = f"m3u_download:{user.username if user else 'anonymous'}:{profile_name or 'all'}:{client_id}" if not cache.get(event_cache_key): log_system_event( event_type='m3u_download', profile=profile_name or 'all', user=user.username if user else 'anonymous', channels=channels.count(), client_ip=client_ip, user_agent=user_agent, ) cache.set(event_cache_key, True, 2) # Prevent duplicate events for 2 seconds response = HttpResponse(m3u_content, content_type="audio/x-mpegurl") response["Content-Disposition"] = 'attachment; filename="channels.m3u"' return response def generate_fallback_programs(channel_id, channel_name, now, num_days, program_length_hours, fallback_title, fallback_description): """ Generate dummy programs using custom fallback templates when patterns don't match. Args: channel_id: Channel ID for the programs channel_name: Channel name to use as fallback in templates now: Current datetime (in UTC) num_days: Number of days to generate programs for program_length_hours: Length of each program in hours fallback_title: Custom fallback title template (empty string if not provided) fallback_description: Custom fallback description template (empty string if not provided) Returns: List of program dictionaries """ programs = [] # Use custom fallback title or channel name as default title = fallback_title if fallback_title else channel_name # Use custom fallback description or a simple default message if fallback_description: description = fallback_description else: description = f"EPG information is currently unavailable for {channel_name}" # Create programs for each day for day in range(num_days): day_start = now + timedelta(days=day) # Create programs with specified length throughout the day for hour_offset in range(0, 24, program_length_hours): # Calculate program start and end times start_time = day_start + timedelta(hours=hour_offset) end_time = start_time + timedelta(hours=program_length_hours) programs.append({ "channel_id": channel_id, "start_time": start_time, "end_time": end_time, "title": title, "description": description, }) return programs def generate_dummy_programs(channel_id, channel_name, num_days=1, program_length_hours=4, epg_source=None): """ Generate dummy EPG programs for channels. If epg_source is provided and it's a custom dummy EPG with patterns, use those patterns to generate programs from the channel title. Otherwise, generate default dummy programs. Args: channel_id: Channel ID for the programs channel_name: Channel title/name num_days: Number of days to generate programs for program_length_hours: Length of each program in hours epg_source: Optional EPGSource for custom dummy EPG with patterns Returns: List of program dictionaries """ # Get current time rounded to hour now = django_timezone.now() now = now.replace(minute=0, second=0, microsecond=0) # Check if this is a custom dummy EPG with regex patterns if epg_source and epg_source.source_type == 'dummy' and epg_source.custom_properties: custom_programs = generate_custom_dummy_programs( channel_id, channel_name, now, num_days, epg_source.custom_properties ) # If custom generation succeeded, return those programs # If it returned empty (pattern didn't match), check for custom fallback templates if custom_programs: return custom_programs else: logger.info(f"Custom pattern didn't match for '{channel_name}', checking for custom fallback templates") # Check if custom fallback templates are provided custom_props = epg_source.custom_properties fallback_title = custom_props.get('fallback_title_template', '').strip() fallback_description = custom_props.get('fallback_description_template', '').strip() # If custom fallback templates exist, use them instead of default if fallback_title or fallback_description: logger.info(f"Using custom fallback templates for '{channel_name}'") return generate_fallback_programs( channel_id, channel_name, now, num_days, program_length_hours, fallback_title, fallback_description ) else: logger.info(f"No custom fallback templates found, using default dummy EPG") # Default humorous program descriptions based on time of day time_descriptions = { (0, 4): [ f"Late Night with {channel_name} - Where insomniacs unite!", f"The 'Why Am I Still Awake?' Show on {channel_name}", f"Counting Sheep - A {channel_name} production for the sleepless", ], (4, 8): [ f"Dawn Patrol - Rise and shine with {channel_name}!", f"Early Bird Special - Coffee not included", f"Morning Zombies - Before coffee viewing on {channel_name}", ], (8, 12): [ f"Mid-Morning Meetings - Pretend you're paying attention while watching {channel_name}", f"The 'I Should Be Working' Hour on {channel_name}", f"Productivity Killer - {channel_name}'s daytime programming", ], (12, 16): [ f"Lunchtime Laziness with {channel_name}", f"The Afternoon Slump - Brought to you by {channel_name}", f"Post-Lunch Food Coma Theater on {channel_name}", ], (16, 20): [ f"Rush Hour - {channel_name}'s alternative to traffic", f"The 'What's For Dinner?' Debate on {channel_name}", f"Evening Escapism - {channel_name}'s remedy for reality", ], (20, 24): [ f"Prime Time Placeholder - {channel_name}'s finest not-programming", f"The 'Netflix Was Too Complicated' Show on {channel_name}", f"Family Argument Avoider - Courtesy of {channel_name}", ], } programs = [] # Create programs for each day for day in range(num_days): day_start = now + timedelta(days=day) # Create programs with specified length throughout the day for hour_offset in range(0, 24, program_length_hours): # Calculate program start and end times start_time = day_start + timedelta(hours=hour_offset) end_time = start_time + timedelta(hours=program_length_hours) # Get the hour for selecting a description hour = start_time.hour # Find the appropriate time slot for description for time_range, descriptions in time_descriptions.items(): start_range, end_range = time_range if start_range <= hour < end_range: # Pick a description using the sum of the hour and day as seed # This makes it somewhat random but consistent for the same timeslot description = descriptions[(hour + day) % len(descriptions)] break else: # Fallback description if somehow no range matches description = f"Placeholder program for {channel_name} - EPG data went on vacation" programs.append({ "channel_id": channel_id, "start_time": start_time, "end_time": end_time, "title": channel_name, "description": description, }) return programs def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, custom_properties): """ Generate programs using custom dummy EPG regex patterns. Extracts information from channel title using regex patterns and generates programs based on the extracted data. TIMEZONE HANDLING: ------------------ The timezone parameter specifies the timezone of the event times in your channel titles using standard timezone names (e.g., 'US/Eastern', 'US/Pacific', 'Europe/London'). DST (Daylight Saving Time) is handled automatically by pytz. Examples: - Channel: "NHL 01: Bruins VS Maple Leafs @ 8:00PM ET" - Set timezone = "US/Eastern" - In October (DST): 8:00PM EDT → 12:00AM UTC (automatically uses UTC-4) - In January (no DST): 8:00PM EST → 1:00AM UTC (automatically uses UTC-5) Args: channel_id: Channel ID for the programs channel_name: Channel title to parse now: Current datetime (in UTC) num_days: Number of days to generate programs for custom_properties: Dict with title_pattern, time_pattern, templates, etc. - timezone: Timezone name (e.g., 'US/Eastern') Returns: List of program dictionaries with start_time/end_time in UTC """ import pytz logger.info(f"Generating custom dummy programs for channel: {channel_name}") # Extract patterns from custom properties title_pattern = custom_properties.get('title_pattern', '') time_pattern = custom_properties.get('time_pattern', '') date_pattern = custom_properties.get('date_pattern', '') # Get timezone name (e.g., 'US/Eastern', 'US/Pacific', 'Europe/London') timezone_value = custom_properties.get('timezone', 'UTC') output_timezone_value = custom_properties.get('output_timezone', '') # Optional: display times in different timezone program_duration = custom_properties.get('program_duration', 180) # Minutes title_template = custom_properties.get('title_template', '') description_template = custom_properties.get('description_template', '') # Templates for upcoming/ended programs upcoming_title_template = custom_properties.get('upcoming_title_template', '') upcoming_description_template = custom_properties.get('upcoming_description_template', '') ended_title_template = custom_properties.get('ended_title_template', '') ended_description_template = custom_properties.get('ended_description_template', '') # Image URL templates channel_logo_url_template = custom_properties.get('channel_logo_url', '') program_poster_url_template = custom_properties.get('program_poster_url', '') # EPG metadata options category_string = custom_properties.get('category', '') # Split comma-separated categories and strip whitespace, filter out empty strings categories = [cat.strip() for cat in category_string.split(',') if cat.strip()] if category_string else [] include_date = custom_properties.get('include_date', True) include_live = custom_properties.get('include_live', False) include_new = custom_properties.get('include_new', False) # Parse timezone name try: source_tz = pytz.timezone(timezone_value) logger.debug(f"Using timezone: {timezone_value} (DST will be handled automatically)") except pytz.exceptions.UnknownTimeZoneError: logger.warning(f"Unknown timezone: {timezone_value}, defaulting to UTC") source_tz = pytz.utc # Parse output timezone if provided (for display purposes) output_tz = None if output_timezone_value: try: output_tz = pytz.timezone(output_timezone_value) logger.debug(f"Using output timezone for display: {output_timezone_value}") except pytz.exceptions.UnknownTimeZoneError: logger.warning(f"Unknown output timezone: {output_timezone_value}, will use source timezone") output_tz = None if not title_pattern: logger.warning(f"No title_pattern in custom_properties, falling back to default") return [] # Return empty, will use default logger.debug(f"Title pattern from DB: {repr(title_pattern)}") # Convert PCRE/JavaScript named groups (?) to Python format (?P) # This handles patterns created with JavaScript regex syntax # Use negative lookahead to avoid matching lookbehind (?<=) and negative lookbehind (?]+)>', r'(?P<\1>', title_pattern) logger.debug(f"Converted title pattern: {repr(title_pattern)}") # Compile regex patterns using the enhanced regex module # (supports variable-width lookbehinds like JavaScript) try: title_regex = regex.compile(title_pattern) except Exception as e: logger.error(f"Invalid title regex pattern after conversion: {e}") logger.error(f"Pattern was: {repr(title_pattern)}") return [] time_regex = None if time_pattern: # Convert PCRE/JavaScript named groups to Python format # Use negative lookahead to avoid matching lookbehind (?<=) and negative lookbehind (?]+)>', r'(?P<\1>', time_pattern) logger.debug(f"Converted time pattern: {repr(time_pattern)}") try: time_regex = regex.compile(time_pattern) except Exception as e: logger.warning(f"Invalid time regex pattern after conversion: {e}") logger.warning(f"Pattern was: {repr(time_pattern)}") # Compile date regex if provided date_regex = None if date_pattern: # Convert PCRE/JavaScript named groups to Python format # Use negative lookahead to avoid matching lookbehind (?<=) and negative lookbehind (?]+)>', r'(?P<\1>', date_pattern) logger.debug(f"Converted date pattern: {repr(date_pattern)}") try: date_regex = regex.compile(date_pattern) except Exception as e: logger.warning(f"Invalid date regex pattern after conversion: {e}") logger.warning(f"Pattern was: {repr(date_pattern)}") # Try to match the channel name with the title pattern # Use search() instead of match() to match JavaScript behavior where .match() searches anywhere in the string title_match = title_regex.search(channel_name) if not title_match: logger.debug(f"Channel name '{channel_name}' doesn't match title pattern") return [] # Return empty, will use default groups = title_match.groupdict() logger.debug(f"Title pattern matched. Groups: {groups}") # Helper function to format template with matched groups def format_template(template, groups, url_encode=False): """Replace {groupname} placeholders with matched group values Args: template: Template string with {groupname} placeholders groups: Dict of group names to values url_encode: If True, URL encode the group values for safe use in URLs """ if not template: return '' result = template for key, value in groups.items(): if url_encode and value: # URL encode the value to handle spaces and special characters from urllib.parse import quote encoded_value = quote(str(value), safe='') result = result.replace(f'{{{key}}}', encoded_value) else: result = result.replace(f'{{{key}}}', str(value) if value else '') return result # Extract time from title if time pattern exists time_info = None time_groups = {} if time_regex: time_match = time_regex.search(channel_name) if time_match: time_groups = time_match.groupdict() try: hour = int(time_groups.get('hour')) # Handle optional minute group - could be None if not captured minute_value = time_groups.get('minute') minute = int(minute_value) if minute_value is not None else 0 ampm = time_groups.get('ampm') ampm = ampm.lower() if ampm else None # Determine if this is 12-hour or 24-hour format if ampm in ('am', 'pm'): # 12-hour format: convert to 24-hour if ampm == 'pm' and hour != 12: hour += 12 elif ampm == 'am' and hour == 12: hour = 0 logger.debug(f"Extracted time (12-hour): {hour}:{minute:02d} {ampm}") else: # 24-hour format: hour is already in 24-hour format # Validate that it's actually a 24-hour time (0-23) if hour > 23: logger.warning(f"Invalid 24-hour time: {hour}. Must be 0-23.") hour = hour % 24 # Wrap around just in case logger.debug(f"Extracted time (24-hour): {hour}:{minute:02d}") time_info = {'hour': hour, 'minute': minute} except (ValueError, TypeError) as e: logger.warning(f"Error parsing time: {e}") # Extract date from title if date pattern exists date_info = None date_groups = {} if date_regex: date_match = date_regex.search(channel_name) if date_match: date_groups = date_match.groupdict() try: # Support various date group names: month, day, year month_str = date_groups.get('month', '') day_str = date_groups.get('day', '') year_str = date_groups.get('year', '') # Parse day - default to current day if empty or invalid day = int(day_str) if day_str else now.day # Parse year - default to current year if empty or invalid (matches frontend behavior) year = int(year_str) if year_str else now.year # Parse month - can be numeric (1-12) or text (Jan, January, etc.) month = None if month_str: if month_str.isdigit(): month = int(month_str) else: # Try to parse text month names import calendar month_str_lower = month_str.lower() # Check full month names for i, month_name in enumerate(calendar.month_name): if month_name.lower() == month_str_lower: month = i break # Check abbreviated month names if not found if month is None: for i, month_abbr in enumerate(calendar.month_abbr): if month_abbr.lower() == month_str_lower: month = i break # Default to current month if not extracted or invalid if month is None: month = now.month if month and 1 <= month <= 12 and 1 <= day <= 31: date_info = {'year': year, 'month': month, 'day': day} logger.debug(f"Extracted date: {year}-{month:02d}-{day:02d}") else: logger.warning(f"Invalid date values: month={month}, day={day}, year={year}") except (ValueError, TypeError) as e: logger.warning(f"Error parsing date: {e}") # Merge title groups, time groups, and date groups for template formatting all_groups = {**groups, **time_groups, **date_groups} # Add normalized versions of all groups for cleaner URLs # These remove all non-alphanumeric characters and convert to lowercase for key, value in list(all_groups.items()): if value: # Remove all non-alphanumeric characters (except spaces temporarily) # then replace spaces with nothing, and convert to lowercase normalized = regex.sub(r'[^a-zA-Z0-9\s]', '', str(value)) normalized = regex.sub(r'\s+', '', normalized).lower() all_groups[f'{key}_normalize'] = normalized # Format channel logo URL if template provided (with URL encoding) channel_logo_url = None if channel_logo_url_template: channel_logo_url = format_template(channel_logo_url_template, all_groups, url_encode=True) logger.debug(f"Formatted channel logo URL: {channel_logo_url}") # Format program poster URL if template provided (with URL encoding) program_poster_url = None if program_poster_url_template: program_poster_url = format_template(program_poster_url_template, all_groups, url_encode=True) logger.debug(f"Formatted program poster URL: {program_poster_url}") # Add formatted time strings for better display (handles minutes intelligently) if time_info: hour_24 = time_info['hour'] minute = time_info['minute'] # Determine the base date to use for placeholders # If date was extracted, use it; otherwise use current date if date_info: base_date = datetime(date_info['year'], date_info['month'], date_info['day']) else: base_date = datetime.now() # If output_timezone is specified, convert the display time to that timezone if output_tz: # Create a datetime in the source timezone using the base date temp_date = source_tz.localize(base_date.replace(hour=hour_24, minute=minute, second=0, microsecond=0)) # Convert to output timezone temp_date_output = temp_date.astimezone(output_tz) # Extract converted hour and minute for display hour_24 = temp_date_output.hour minute = temp_date_output.minute logger.debug(f"Converted display time from {source_tz} to {output_tz}: {hour_24}:{minute:02d}") # Add date placeholders based on the OUTPUT timezone # This ensures {date}, {month}, {day}, {year} reflect the converted timezone all_groups['date'] = temp_date_output.strftime('%Y-%m-%d') all_groups['month'] = str(temp_date_output.month) all_groups['day'] = str(temp_date_output.day) all_groups['year'] = str(temp_date_output.year) logger.debug(f"Converted date placeholders to {output_tz}: {all_groups['date']}") else: # No output timezone conversion - use source timezone for date # Create temp date to get proper date in source timezone using the base date temp_date_source = source_tz.localize(base_date.replace(hour=hour_24, minute=minute, second=0, microsecond=0)) all_groups['date'] = temp_date_source.strftime('%Y-%m-%d') all_groups['month'] = str(temp_date_source.month) all_groups['day'] = str(temp_date_source.day) all_groups['year'] = str(temp_date_source.year) # Format 24-hour start time string - only include minutes if non-zero if minute > 0: all_groups['starttime24'] = f"{hour_24}:{minute:02d}" else: all_groups['starttime24'] = f"{hour_24:02d}:00" # Convert 24-hour to 12-hour format for {starttime} placeholder # Note: hour_24 is ALWAYS in 24-hour format at this point (converted earlier if needed) ampm = 'AM' if hour_24 < 12 else 'PM' hour_12 = hour_24 if hour_24 == 0: hour_12 = 12 elif hour_24 > 12: hour_12 = hour_24 - 12 # Format 12-hour start time string - only include minutes if non-zero if minute > 0: all_groups['starttime'] = f"{hour_12}:{minute:02d} {ampm}" else: all_groups['starttime'] = f"{hour_12} {ampm}" # Format long version that always includes minutes (e.g., "9:00 PM" instead of "9 PM") all_groups['starttime_long'] = f"{hour_12}:{minute:02d} {ampm}" # Calculate end time based on program duration # Create a datetime for calculations temp_start = datetime.now(source_tz).replace(hour=hour_24, minute=minute, second=0, microsecond=0) temp_end = temp_start + timedelta(minutes=program_duration) # Extract end time components (already in correct timezone if output_tz was applied above) end_hour_24 = temp_end.hour end_minute = temp_end.minute # Format 24-hour end time string - only include minutes if non-zero if end_minute > 0: all_groups['endtime24'] = f"{end_hour_24}:{end_minute:02d}" else: all_groups['endtime24'] = f"{end_hour_24:02d}:00" # Convert 24-hour to 12-hour format for {endtime} placeholder end_ampm = 'AM' if end_hour_24 < 12 else 'PM' end_hour_12 = end_hour_24 if end_hour_24 == 0: end_hour_12 = 12 elif end_hour_24 > 12: end_hour_12 = end_hour_24 - 12 # Format 12-hour end time string - only include minutes if non-zero if end_minute > 0: all_groups['endtime'] = f"{end_hour_12}:{end_minute:02d} {end_ampm}" else: all_groups['endtime'] = f"{end_hour_12} {end_ampm}" # Format long version that always includes minutes (e.g., "9:00 PM" instead of "9 PM") all_groups['endtime_long'] = f"{end_hour_12}:{end_minute:02d} {end_ampm}" # Generate programs programs = [] # If we have extracted time AND date, the event happens on a SPECIFIC date # If we have time but NO date, generate for multiple days (existing behavior) # All other days and times show "Upcoming" before or "Ended" after event_happened = False # Determine how many iterations we need if date_info and time_info: # Specific date extracted - only generate for that one date iterations = 1 logger.debug(f"Date extracted, generating single event for specific date") else: # No specific date - use num_days (existing behavior) iterations = num_days for day in range(iterations): # Start from current time (like standard dummy) instead of midnight # This ensures programs appear in the guide's current viewing window day_start = now + timedelta(days=day) day_end = day_start + timedelta(days=1) if time_info: # We have an extracted event time - this is when the MAIN event starts # The extracted time is in the SOURCE timezone (e.g., 8PM ET) # We need to convert it to UTC for storage # Determine which date to use if date_info: # Use the extracted date from the channel title current_date = datetime( date_info['year'], date_info['month'], date_info['day'] ).date() logger.debug(f"Using extracted date: {current_date}") else: # No date extracted, use day offset from current time in SOURCE timezone # This ensures we calculate "today" in the event's timezone, not UTC # For example: 8:30 PM Central (1:30 AM UTC next day) for a 10 PM ET event # should use today's date in ET, not tomorrow's date in UTC now_in_source_tz = now.astimezone(source_tz) current_date = (now_in_source_tz + timedelta(days=day)).date() logger.debug(f"No date extracted, using day offset in {source_tz}: {current_date}") # Create a naive datetime (no timezone info) representing the event in source timezone event_start_naive = datetime.combine( current_date, datetime.min.time().replace( hour=time_info['hour'], minute=time_info['minute'] ) ) # Use pytz to localize the naive datetime to the source timezone # This automatically handles DST! try: event_start_local = source_tz.localize(event_start_naive) # Convert to UTC event_start_utc = event_start_local.astimezone(pytz.utc) logger.debug(f"Converted {event_start_local} to UTC: {event_start_utc}") except Exception as e: logger.error(f"Error localizing time to {source_tz}: {e}") # Fallback: treat as UTC event_start_utc = django_timezone.make_aware(event_start_naive, pytz.utc) event_end_utc = event_start_utc + timedelta(minutes=program_duration) # Pre-generate the main event title and description for reuse if title_template: main_event_title = format_template(title_template, all_groups) else: title_parts = [] if 'league' in all_groups and all_groups['league']: title_parts.append(all_groups['league']) if 'team1' in all_groups and 'team2' in all_groups: title_parts.append(f"{all_groups['team1']} vs {all_groups['team2']}") elif 'title' in all_groups and all_groups['title']: title_parts.append(all_groups['title']) main_event_title = ' - '.join(title_parts) if title_parts else channel_name if description_template: main_event_description = format_template(description_template, all_groups) else: main_event_description = main_event_title # Determine if this day is before, during, or after the event # Event only happens on day 0 (first day) is_event_day = (day == 0) if is_event_day and not event_happened: # This is THE day the event happens # Fill programs BEFORE the event current_time = day_start while current_time < event_start_utc: program_start_utc = current_time program_end_utc = min(current_time + timedelta(minutes=program_duration), event_start_utc) # Use custom upcoming templates if provided, otherwise use defaults if upcoming_title_template: upcoming_title = format_template(upcoming_title_template, all_groups) else: upcoming_title = main_event_title if upcoming_description_template: upcoming_description = format_template(upcoming_description_template, all_groups) else: upcoming_description = f"Upcoming: {main_event_description}" # Build custom_properties for upcoming programs (only date, no category/live) program_custom_properties = {} # Add date if requested (YYYY-MM-DD format from start time in event timezone) if include_date: # Convert UTC time to event timezone for date calculation local_time = program_start_utc.astimezone(source_tz) date_str = local_time.strftime('%Y-%m-%d') program_custom_properties['date'] = date_str # Add program poster URL if provided if program_poster_url: program_custom_properties['icon'] = program_poster_url programs.append({ "channel_id": channel_id, "start_time": program_start_utc, "end_time": program_end_utc, "title": upcoming_title, "description": upcoming_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation }) current_time += timedelta(minutes=program_duration) # Add the MAIN EVENT at the extracted time # Build custom_properties for main event (includes category and live) main_event_custom_properties = {} # Add categories if provided if categories: main_event_custom_properties['categories'] = categories # Add date if requested (YYYY-MM-DD format from start time in event timezone) if include_date: # Convert UTC time to event timezone for date calculation local_time = event_start_utc.astimezone(source_tz) date_str = local_time.strftime('%Y-%m-%d') main_event_custom_properties['date'] = date_str # Add live flag if requested if include_live: main_event_custom_properties['live'] = True # Add new flag if requested if include_new: main_event_custom_properties['new'] = True # Add program poster URL if provided if program_poster_url: main_event_custom_properties['icon'] = program_poster_url programs.append({ "channel_id": channel_id, "start_time": event_start_utc, "end_time": event_end_utc, "title": main_event_title, "description": main_event_description, "custom_properties": main_event_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation }) event_happened = True # Fill programs AFTER the event until end of day current_time = event_end_utc while current_time < day_end: program_start_utc = current_time program_end_utc = min(current_time + timedelta(minutes=program_duration), day_end) # Use custom ended templates if provided, otherwise use defaults if ended_title_template: ended_title = format_template(ended_title_template, all_groups) else: ended_title = main_event_title if ended_description_template: ended_description = format_template(ended_description_template, all_groups) else: ended_description = f"Ended: {main_event_description}" # Build custom_properties for ended programs (only date, no category/live) program_custom_properties = {} # Add date if requested (YYYY-MM-DD format from start time in event timezone) if include_date: # Convert UTC time to event timezone for date calculation local_time = program_start_utc.astimezone(source_tz) date_str = local_time.strftime('%Y-%m-%d') program_custom_properties['date'] = date_str # Add program poster URL if provided if program_poster_url: program_custom_properties['icon'] = program_poster_url programs.append({ "channel_id": channel_id, "start_time": program_start_utc, "end_time": program_end_utc, "title": ended_title, "description": ended_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation }) current_time += timedelta(minutes=program_duration) else: # This day is either before the event (future days) or after the event happened # Fill entire day with appropriate message current_time = day_start # If event already happened, all programs show "Ended" # If event hasn't happened yet (shouldn't occur with day 0 logic), show "Upcoming" is_ended = event_happened while current_time < day_end: program_start_utc = current_time program_end_utc = min(current_time + timedelta(minutes=program_duration), day_end) # Use custom templates based on whether event has ended or is upcoming if is_ended: if ended_title_template: program_title = format_template(ended_title_template, all_groups) else: program_title = main_event_title if ended_description_template: program_description = format_template(ended_description_template, all_groups) else: program_description = f"Ended: {main_event_description}" else: if upcoming_title_template: program_title = format_template(upcoming_title_template, all_groups) else: program_title = main_event_title if upcoming_description_template: program_description = format_template(upcoming_description_template, all_groups) else: program_description = f"Upcoming: {main_event_description}" # Build custom_properties (only date for upcoming/ended filler programs) program_custom_properties = {} # Add date if requested (YYYY-MM-DD format from start time in event timezone) if include_date: # Convert UTC time to event timezone for date calculation local_time = program_start_utc.astimezone(source_tz) date_str = local_time.strftime('%Y-%m-%d') program_custom_properties['date'] = date_str # Add program poster URL if provided if program_poster_url: program_custom_properties['icon'] = program_poster_url programs.append({ "channel_id": channel_id, "start_time": program_start_utc, "end_time": program_end_utc, "title": program_title, "description": program_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, }) current_time += timedelta(minutes=program_duration) else: # No extracted time - fill entire day with regular intervals # day_start and day_end are already in UTC, so no conversion needed programs_per_day = max(1, int(24 / (program_duration / 60))) for program_num in range(programs_per_day): program_start_utc = day_start + timedelta(minutes=program_num * program_duration) program_end_utc = program_start_utc + timedelta(minutes=program_duration) if title_template: title = format_template(title_template, all_groups) else: title_parts = [] if 'league' in all_groups and all_groups['league']: title_parts.append(all_groups['league']) if 'team1' in all_groups and 'team2' in all_groups: title_parts.append(f"{all_groups['team1']} vs {all_groups['team2']}") elif 'title' in all_groups and all_groups['title']: title_parts.append(all_groups['title']) title = ' - '.join(title_parts) if title_parts else channel_name if description_template: description = format_template(description_template, all_groups) else: description = title # Build custom_properties for this program program_custom_properties = {} # Add categories if provided if categories: program_custom_properties['categories'] = categories # Add date if requested (YYYY-MM-DD format from start time in event timezone) if include_date: # Convert UTC time to event timezone for date calculation local_time = program_start_utc.astimezone(source_tz) date_str = local_time.strftime('%Y-%m-%d') program_custom_properties['date'] = date_str # Add live flag if requested if include_live: program_custom_properties['live'] = True # Add new flag if requested if include_new: program_custom_properties['new'] = True # Add program poster URL if provided if program_poster_url: program_custom_properties['icon'] = program_poster_url programs.append({ "channel_id": channel_id, "start_time": program_start_utc, "end_time": program_end_utc, "title": title, "description": description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation }) logger.info(f"Generated {len(programs)} custom dummy programs for {channel_name}") return programs def generate_dummy_epg( channel_id, channel_name, xml_lines=None, num_days=1, program_length_hours=4 ): """ Generate dummy EPG programs for channels without EPG data. Creates program blocks for a specified number of days. Args: channel_id: The channel ID to use in the program entries channel_name: The name of the channel to use in program titles xml_lines: Optional list to append lines to, otherwise returns new list num_days: Number of days to generate EPG data for (default: 1) program_length_hours: Length of each program block in hours (default: 4) Returns: List of XML lines for the dummy EPG entries """ if xml_lines is None: xml_lines = [] for program in generate_dummy_programs(channel_id, channel_name, num_days=1, program_length_hours=4): # Format times in XMLTV format start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") # Create program entry with escaped channel name xml_lines.append( f' ' ) xml_lines.append(f" {html.escape(program['title'])}") xml_lines.append(f" {html.escape(program['description'])}") # Add custom_properties if present custom_data = program.get('custom_properties', {}) # Categories if 'categories' in custom_data: for cat in custom_data['categories']: xml_lines.append(f" {html.escape(cat)}") # Date tag if 'date' in custom_data: xml_lines.append(f" {html.escape(custom_data['date'])}") # Live tag if custom_data.get('live', False): xml_lines.append(f" ") # New tag if custom_data.get('new', False): xml_lines.append(f" ") xml_lines.append(f" ") return xml_lines def generate_epg(request, profile_name=None, user=None): """ Dynamically generate an XMLTV (EPG) file using streaming response to handle keep-alives. Since the EPG data is stored independently of Channels, we group programmes by their associated EPGData record. This version filters data based on the 'days' parameter and sends keep-alives during processing. """ # Check cache for recent identical request (helps with double-GET from browsers) from django.core.cache import cache cache_params = f"{profile_name or 'all'}:{user.username if user else 'anonymous'}:{request.GET.urlencode()}" content_cache_key = f"epg_content:{cache_params}" cached_content = cache.get(content_cache_key) if cached_content: logger.debug("Serving EPG from cache") response = HttpResponse(cached_content, content_type="application/xml") response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"' response["Cache-Control"] = "no-cache" return response def epg_generator(): """Generator function that yields EPG data with keep-alives during processing""" # Send initial HTTP headers as comments (these will be ignored by XML parsers but keep connection alive) xml_lines = [] xml_lines.append('') xml_lines.append( '' ) # Get channels based on user/profile if user is not None: if user.user_level == 0: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number") else: # User has specific limited profiles assigned filters = { "channelprofilemembership__enabled": True, "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() } channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( "channel_number" ) else: if profile_name is not None: try: channel_profile = ChannelProfile.objects.get(name=profile_name) except ChannelProfile.DoesNotExist: logger.warning("Requested channel profile (%s) during epg generation does not exist", profile_name) raise Http404(f"Channel profile '{profile_name}' not found") channels = Channel.objects.filter( channelprofilemembership__channel_profile=channel_profile, channelprofilemembership__enabled=True, ).order_by("channel_number") else: channels = Channel.objects.all().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' # 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 = django_timezone.now() cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None # Build collision-free channel number mapping for XC clients (if user is authenticated) # XC clients require integer channel numbers, so we need to ensure no conflicts channel_num_map = {} if user is not None: # This is an XC client - build collision-free mapping used_numbers = set() # First pass: assign integers for channels that already have integer numbers for channel in channels: if channel.channel_number == int(channel.channel_number): num = int(channel.channel_number) channel_num_map[channel.id] = num used_numbers.add(num) # Second pass: assign integers for channels with float numbers for channel in channels: if channel.channel_number != int(channel.channel_number): candidate = int(channel.channel_number) while candidate in used_numbers: candidate += 1 channel_num_map[channel.id] = candidate used_numbers.add(candidate) # Process channels for the section for channel in channels: # For XC clients (user is not None), use collision-free integer mapping # For regular clients (user is None), use original formatting logic if user is not None: # XC client - use collision-free integer formatted_channel_number = channel_num_map[channel.id] else: # Regular client - format channel number as integer if it has no decimal component 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 = "" # 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 = "" # Check if this is a custom dummy EPG with channel logo URL template if channel.epg_data and channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': epg_source = channel.epg_data.epg_source if epg_source.custom_properties: custom_props = epg_source.custom_properties channel_logo_url_template = custom_props.get('channel_logo_url', '') if channel_logo_url_template: # Determine which name to use for pattern matching (same logic as program generation) pattern_match_name = channel.name name_source = custom_props.get('name_source') if name_source == 'stream': stream_index = custom_props.get('stream_index', 1) - 1 channel_streams = channel.streams.all().order_by('channelstream__order') if channel_streams.exists() and 0 <= stream_index < channel_streams.count(): stream = list(channel_streams)[stream_index] pattern_match_name = stream.name # Try to extract groups from the channel/stream name and build the logo URL title_pattern = custom_props.get('title_pattern', '') if title_pattern: try: # Convert PCRE/JavaScript named groups to Python format title_pattern = regex.sub(r'\(\?<(?![=!])([^>]+)>', r'(?P<\1>', title_pattern) title_regex = regex.compile(title_pattern) title_match = title_regex.search(pattern_match_name) if title_match: groups = title_match.groupdict() # Add normalized versions of all groups for cleaner URLs for key, value in list(groups.items()): if value: # Remove all non-alphanumeric characters and convert to lowercase normalized = regex.sub(r'[^a-zA-Z0-9\s]', '', str(value)) normalized = regex.sub(r'\s+', '', normalized).lower() groups[f'{key}_normalize'] = normalized # Format the logo URL template with the matched groups (with URL encoding) from urllib.parse import quote for key, value in groups.items(): if value: encoded_value = quote(str(value), safe='') channel_logo_url_template = channel_logo_url_template.replace(f'{{{key}}}', encoded_value) else: channel_logo_url_template = channel_logo_url_template.replace(f'{{{key}}}', '') tvg_logo = channel_logo_url_template logger.debug(f"Built channel logo URL from template: {tvg_logo}") except Exception as e: logger.warning(f"Failed to build channel logo URL for {channel.name}: {e}") # If no custom dummy logo, use regular logo logic if not tvg_logo and channel.logo: if use_cached_logos: # Use cached logo as before tvg_logo = build_absolute_uri_with_port(request, 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 = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) display_name = channel.name xml_lines.append(f' ') xml_lines.append(f' {html.escape(display_name)}') xml_lines.append(f' ') xml_lines.append(" ") # Send all channel definitions channel_xml = '\n'.join(xml_lines) + '\n' yield channel_xml xml_lines = [] # Clear to save memory # Process programs for each channel for channel in channels: # 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: # For XC clients (user is not None), use collision-free integer mapping # For regular clients (user is None), use original formatting logic if user is not None: # XC client - use collision-free integer from map formatted_channel_number = channel_num_map[channel.id] else: # Regular client - format channel number as before 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) # Use EPG data name for display, but channel name for pattern matching display_name = channel.epg_data.name if channel.epg_data else channel.name # For dummy EPG pattern matching, determine which name to use pattern_match_name = channel.name # Check if we should use stream name instead of channel name if channel.epg_data and channel.epg_data.epg_source: epg_source = channel.epg_data.epg_source if epg_source.custom_properties: custom_props = epg_source.custom_properties name_source = custom_props.get('name_source') if name_source == 'stream': stream_index = custom_props.get('stream_index', 1) - 1 channel_streams = channel.streams.all().order_by('channelstream__order') if channel_streams.exists() and 0 <= stream_index < channel_streams.count(): stream = list(channel_streams)[stream_index] pattern_match_name = stream.name logger.debug(f"Using stream name for parsing: {pattern_match_name} (stream index: {stream_index})") else: logger.warning(f"Stream index {stream_index} not found for channel {channel.name}, falling back to channel name") if not channel.epg_data: # Use the enhanced dummy EPG generation function with defaults program_length_hours = 4 # Default to 4-hour program blocks dummy_programs = generate_dummy_programs( channel_id, pattern_match_name, num_days=dummy_days, program_length_hours=program_length_hours, epg_source=None ) for program in dummy_programs: # Format times in XMLTV format start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") # Create program entry with escaped channel name yield f' \n' yield f" {html.escape(program['title'])}\n" yield f" {html.escape(program['description'])}\n" # Add custom_properties if present custom_data = program.get('custom_properties', {}) # Categories if 'categories' in custom_data: for cat in custom_data['categories']: yield f" {html.escape(cat)}\n" # Date tag if 'date' in custom_data: yield f" {html.escape(custom_data['date'])}\n" # Live tag if custom_data.get('live', False): yield f" \n" # New tag if custom_data.get('new', False): yield f" \n" # Icon/poster URL if 'icon' in custom_data: yield f" \n" yield f" \n" else: # Check if this is a dummy EPG with no programs (generate on-demand) if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': # This is a custom dummy EPG - check if it has programs if not channel.epg_data.programs.exists(): # No programs stored, generate on-demand using custom patterns # Use actual channel name for pattern matching program_length_hours = 4 dummy_programs = generate_dummy_programs( channel_id, pattern_match_name, num_days=dummy_days, program_length_hours=program_length_hours, epg_source=channel.epg_data.epg_source ) for program in dummy_programs: start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") yield f' \n' yield f" {html.escape(program['title'])}\n" yield f" {html.escape(program['description'])}\n" # Add custom_properties if present custom_data = program.get('custom_properties', {}) # Categories if 'categories' in custom_data: for cat in custom_data['categories']: yield f" {html.escape(cat)}\n" # Date tag if 'date' in custom_data: yield f" {html.escape(custom_data['date'])}\n" # Live tag if custom_data.get('live', False): yield f" \n" # New tag if custom_data.get('new', False): yield f" \n" # Icon/poster URL if 'icon' in custom_data: yield f" \n" yield f" \n" continue # Skip to next channel # For real EPG data - filter only if days parameter was specified if num_days > 0: programs_qs = channel.epg_data.programs.filter( start_time__gte=now, start_time__lt=cutoff_date ).order_by('id') # Explicit ordering for consistent chunking else: # Return all programs if days=0 or not specified programs_qs = channel.epg_data.programs.all().order_by('id') # Process programs in chunks to avoid cursor timeout issues program_batch = [] batch_size = 250 chunk_size = 1000 # Fetch 1000 programs at a time from DB # Fetch chunks until no more results (avoids count() query) offset = 0 while True: # Fetch a chunk of programs - this closes the cursor after fetching program_chunk = list(programs_qs[offset:offset + chunk_size]) # Break if no more programs if not program_chunk: break # Process each program in the chunk for prog in program_chunk: 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") program_xml = [f' '] program_xml.append(f' {html.escape(prog.title)}') # Add subtitle if available if prog.sub_title: program_xml.append(f" {html.escape(prog.sub_title)}") # Add description if available if prog.description: program_xml.append(f" {html.escape(prog.description)}") # Process custom properties if available if 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 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 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"])}') # 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"])}') 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 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 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 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 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"] # 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)}") else: program_xml.append(f" {html.escape(actors)}") 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)}") 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(" ") # Add to batch program_batch.extend(program_xml) # Send batch when full or send keep-alive if len(program_batch) >= batch_size: batch_xml = '\n'.join(program_batch) + '\n' yield batch_xml program_batch = [] # Move to next chunk offset += chunk_size # Send remaining programs in batch if program_batch: batch_xml = '\n'.join(program_batch) + '\n' yield batch_xml # Send final closing tag and completion message yield "\n" # Log system event for EPG download after streaming completes (with deduplication based on client) client_id, client_ip, user_agent = get_client_identifier(request) event_cache_key = f"epg_download:{user.username if user else 'anonymous'}:{profile_name or 'all'}:{client_id}" if not cache.get(event_cache_key): log_system_event( event_type='epg_download', profile=profile_name or 'all', user=user.username if user else 'anonymous', channels=channels.count(), client_ip=client_ip, user_agent=user_agent, ) cache.set(event_cache_key, True, 2) # Prevent duplicate events for 2 seconds # Wrapper generator that collects content for caching def caching_generator(): collected_content = [] for chunk in epg_generator(): collected_content.append(chunk) yield chunk # After streaming completes, cache the full content full_content = ''.join(collected_content) cache.set(content_cache_key, full_content, 300) logger.debug("Cached EPG content (%d bytes)", len(full_content)) # Return streaming response response = StreamingHttpResponse( streaming_content=caching_generator(), content_type="application/xml" ) response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"' response["Cache-Control"] = "no-cache" return response def xc_get_user(request): username = request.GET.get("username") password = request.GET.get("password") if not username or not password: return None user = get_object_or_404(User, username=username) custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: return None if custom_properties["xc_password"] != password: return None return user def xc_get_info(request, full=False): if not network_access_allowed(request, 'XC_API'): return JsonResponse({'error': 'Forbidden'}, status=403) user = xc_get_user(request) if user is None: return JsonResponse({'error': 'Unauthorized'}, status=401) raw_host = request.get_host() if ":" in raw_host: hostname, port = raw_host.split(":", 1) else: hostname = raw_host port = "443" if request.is_secure() else "80" info = { "user_info": { "username": request.GET.get("username"), "password": request.GET.get("password"), "message": "Dispatcharr XC API", "auth": 1, "status": "Active", "exp_date": str(int(time.time()) + (90 * 24 * 60 * 60)), "max_connections": str(calculate_tuner_count(minimum=1, unlimited_default=50)), "allowed_output_formats": [ "ts", ], }, "server_info": { "url": hostname, "server_protocol": request.scheme, "port": port, "timezone": get_localzone().key, "timestamp_now": int(time.time()), "time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "process": True, }, } if full == True: info['categories'] = { "series": [], "movie": [], "live": xc_get_live_categories(user), } info['available_channels'] = {channel["stream_id"]: channel for channel in xc_get_live_streams(request, user, request.GET.get("category_id"))} return info def xc_player_api(request, full=False): if not network_access_allowed(request, 'XC_API'): return JsonResponse({'error': 'Forbidden'}, status=403) action = request.GET.get("action") user = xc_get_user(request) if user is None: return JsonResponse({'error': 'Unauthorized'}, status=401) if action == "get_live_categories": return JsonResponse(xc_get_live_categories(user), safe=False) elif action == "get_live_streams": return JsonResponse(xc_get_live_streams(request, user, request.GET.get("category_id")), safe=False) elif action == "get_short_epg": return JsonResponse(xc_get_epg(request, user, short=True), safe=False) elif action == "get_simple_data_table": return JsonResponse(xc_get_epg(request, user, short=False), safe=False) elif action == "get_vod_categories": return JsonResponse(xc_get_vod_categories(user), safe=False) elif action == "get_vod_streams": return JsonResponse(xc_get_vod_streams(request, user, request.GET.get("category_id")), safe=False) elif action == "get_series_categories": return JsonResponse(xc_get_series_categories(user), safe=False) elif action == "get_series": return JsonResponse(xc_get_series(request, user, request.GET.get("category_id")), safe=False) elif action == "get_series_info": return JsonResponse(xc_get_series_info(request, user, request.GET.get("series_id")), safe=False) elif action == "get_vod_info": return JsonResponse(xc_get_vod_info(request, user, request.GET.get("vod_id")), safe=False) else: # For any other action (including get_account_info or unknown actions), # return server_info/account_info to match provider behavior server_info = xc_get_info(request) return JsonResponse(server_info, safe=False) def xc_panel_api(request): if not network_access_allowed(request, 'XC_API'): return JsonResponse({'error': 'Forbidden'}, status=403) user = xc_get_user(request) if user is None: return JsonResponse({'error': 'Unauthorized'}, status=401) return JsonResponse(xc_get_info(request, True)) def xc_get(request): if not network_access_allowed(request, 'XC_API'): # Log blocked M3U download from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') log_system_event( event_type='m3u_blocked', user=request.GET.get('username', 'unknown'), reason='Network access denied (XC API)', client_ip=client_ip, user_agent=user_agent, ) return JsonResponse({'error': 'Forbidden'}, status=403) action = request.GET.get("action") user = xc_get_user(request) if user is None: # Log blocked M3U download due to invalid credentials from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') log_system_event( event_type='m3u_blocked', user=request.GET.get('username', 'unknown'), reason='Invalid XC credentials', client_ip=client_ip, user_agent=user_agent, ) return JsonResponse({'error': 'Unauthorized'}, status=401) return generate_m3u(request, None, user) def xc_xmltv(request): if not network_access_allowed(request, 'XC_API'): # Log blocked EPG download from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') log_system_event( event_type='epg_blocked', user=request.GET.get('username', 'unknown'), reason='Network access denied (XC API)', client_ip=client_ip, user_agent=user_agent, ) return JsonResponse({'error': 'Forbidden'}, status=403) user = xc_get_user(request) if user is None: # Log blocked EPG download due to invalid credentials from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') log_system_event( event_type='epg_blocked', user=request.GET.get('username', 'unknown'), reason='Invalid XC credentials', client_ip=client_ip, user_agent=user_agent, ) return JsonResponse({'error': 'Unauthorized'}, status=401) return generate_epg(request, None, user) def xc_get_live_categories(user): from django.db.models import Min response = [] if user.user_level == 0: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access if user_profile_count == 0: # No profile filtering - user sees all channel groups channel_groups = ChannelGroup.objects.filter( channels__isnull=False, channels__user_level__lte=user.user_level ).distinct().annotate(min_channel_number=Min('channels__channel_number')).order_by('min_channel_number') else: # User has specific limited profiles assigned filters = { "channels__channelprofilemembership__enabled": True, "channels__user_level": 0, "channels__channelprofilemembership__channel_profile__in": user.channel_profiles.all() } channel_groups = ChannelGroup.objects.filter(**filters).distinct().annotate(min_channel_number=Min('channels__channel_number')).order_by('min_channel_number') else: channel_groups = ChannelGroup.objects.filter( channels__isnull=False, channels__user_level__lte=user.user_level ).distinct().annotate(min_channel_number=Min('channels__channel_number')).order_by('min_channel_number') for group in channel_groups: response.append( { "category_id": str(group.id), "category_name": group.name, "parent_id": 0, } ) return response def xc_get_live_streams(request, user, category_id=None): streams = [] if user.user_level == 0: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level filters = {"user_level__lte": user.user_level} if category_id is not None: filters["channel_group__id"] = category_id channels = Channel.objects.filter(**filters).order_by("channel_number") else: # User has specific limited profiles assigned filters = { "channelprofilemembership__enabled": True, "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() } if category_id is not None: filters["channel_group__id"] = category_id channels = Channel.objects.filter(**filters).distinct().order_by("channel_number") else: if not category_id: channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number") else: channels = Channel.objects.filter( channel_group__id=category_id, user_level__lte=user.user_level ).order_by("channel_number") # Build collision-free mapping for XC clients (which require integers) # This ensures channels with float numbers don't conflict with existing integers channel_num_map = {} # Maps channel.id -> integer channel number for XC used_numbers = set() # Track all assigned integer channel numbers # First pass: assign integers for channels that already have integer numbers for channel in channels: if channel.channel_number == int(channel.channel_number): # Already an integer, use it directly num = int(channel.channel_number) channel_num_map[channel.id] = num used_numbers.add(num) # Second pass: assign integers for channels with float numbers # Find next available number to avoid collisions for channel in channels: if channel.channel_number != int(channel.channel_number): # Has decimal component, need to find available integer # Start from truncated value and increment until we find an unused number candidate = int(channel.channel_number) while candidate in used_numbers: candidate += 1 channel_num_map[channel.id] = candidate used_numbers.add(candidate) # Build the streams list with the collision-free channel numbers for channel in channels: channel_num_int = channel_num_map[channel.id] streams.append( { "num": channel_num_int, "name": channel.name, "stream_type": "live", "stream_id": channel.id, "stream_icon": ( None if not channel.logo else build_absolute_uri_with_port( request, reverse("api:channels:logo-cache", args=[channel.logo.id]) ) ), "epg_channel_id": str(channel_num_int), "added": int(channel.created_at.timestamp()), "is_adult": 0, "category_id": str(channel.channel_group.id), "category_ids": [channel.channel_group.id], "custom_sid": None, "tv_archive": 0, "direct_source": "", "tv_archive_duration": 0, } ) return streams def xc_get_epg(request, user, short=False): channel_id = request.GET.get('stream_id') if not channel_id: raise Http404() channel = None if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level channel = Channel.objects.filter( id=channel_id, user_level__lte=user.user_level ).first() else: # User has specific limited profiles assigned filters = { "id": channel_id, "channelprofilemembership__enabled": True, "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() } channel = Channel.objects.filter(**filters).distinct().first() if not channel: raise Http404() else: channel = get_object_or_404(Channel, id=channel_id) if not channel: raise Http404() # Calculate the collision-free integer channel number for this channel # This must match the logic in xc_get_live_streams to ensure consistency # Get all channels in the same category for collision detection category_channels = Channel.objects.filter( channel_group=channel.channel_group ).order_by("channel_number") channel_num_map = {} used_numbers = set() # First pass: assign integers for channels that already have integer numbers for ch in category_channels: if ch.channel_number == int(ch.channel_number): num = int(ch.channel_number) channel_num_map[ch.id] = num used_numbers.add(num) # Second pass: assign integers for channels with float numbers for ch in category_channels: if ch.channel_number != int(ch.channel_number): candidate = int(ch.channel_number) while candidate in used_numbers: candidate += 1 channel_num_map[ch.id] = candidate used_numbers.add(candidate) # Get the mapped integer for this specific channel channel_num_int = channel_num_map.get(channel.id, int(channel.channel_number)) limit = int(request.GET.get('limit', 4)) if channel.epg_data: # Check if this is a dummy EPG that generates on-demand if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': if not channel.epg_data.programs.exists(): # Generate on-demand using custom patterns programs = generate_dummy_programs( channel_id=channel_id, channel_name=channel.name, epg_source=channel.epg_data.epg_source ) else: # Has stored programs, use them if short == False: programs = channel.epg_data.programs.filter( start_time__gte=django_timezone.now() ).order_by('start_time') else: programs = channel.epg_data.programs.all().order_by('start_time')[:limit] else: # Regular EPG with stored programs if short == False: programs = channel.epg_data.programs.filter( start_time__gte=django_timezone.now() ).order_by('start_time') else: programs = channel.epg_data.programs.all().order_by('start_time')[:limit] else: # No EPG data assigned, generate default dummy programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name, epg_source=None) output = {"epg_listings": []} for program in programs: title = program['title'] if isinstance(program, dict) else program.title description = program['description'] if isinstance(program, dict) else program.description start = program["start_time"] if isinstance(program, dict) else program.start_time end = program["end_time"] if isinstance(program, dict) else program.end_time # For database programs, use actual ID; for generated dummy programs, create synthetic ID if isinstance(program, dict): # Generated dummy program - create unique ID from channel + timestamp program_id = str(abs(hash(f"{channel_id}_{int(start.timestamp())}"))) else: # Database program - use actual ID program_id = str(program.id) # epg_id refers to the EPG source/channel mapping in XC panels # Use the actual EPGData ID when available, otherwise fall back to 0 epg_id = str(channel.epg_data.id) if channel.epg_data else "0" program_output = { "id": program_id, "epg_id": epg_id, "title": base64.b64encode((title or "").encode()).decode(), "lang": "", "start": start.strftime("%Y-%m-%d %H:%M:%S"), "end": end.strftime("%Y-%m-%d %H:%M:%S"), "description": base64.b64encode((description or "").encode()).decode(), "channel_id": str(channel_num_int), "start_timestamp": str(int(start.timestamp())), "stop_timestamp": str(int(end.timestamp())), "stream_id": f"{channel_id}", } if short == False: program_output["now_playing"] = 1 if start <= django_timezone.now() <= end else 0 program_output["has_archive"] = 0 output['epg_listings'].append(program_output) return output def xc_get_vod_categories(user): """Get VOD categories for XtreamCodes API""" from apps.vod.models import VODCategory, M3UMovieRelation response = [] # All authenticated users get access to VOD from all active M3U accounts categories = VODCategory.objects.filter( category_type='movie', m3umovierelation__m3u_account__is_active=True ).distinct().order_by(Lower("name")) for category in categories: response.append({ "category_id": str(category.id), "category_name": category.name, "parent_id": 0, }) return response def xc_get_vod_streams(request, user, category_id=None): """Get VOD streams (movies) for XtreamCodes API""" from apps.vod.models import Movie, M3UMovieRelation from django.db.models import Prefetch streams = [] # All authenticated users get access to VOD from all active M3U accounts filters = {"m3u_relations__m3u_account__is_active": True} if category_id: filters["m3u_relations__category_id"] = category_id # Optimize with prefetch_related to eliminate N+1 queries # This loads all relations in a single query instead of one per movie movies = Movie.objects.filter(**filters).select_related('logo').prefetch_related( Prefetch( 'm3u_relations', queryset=M3UMovieRelation.objects.filter( m3u_account__is_active=True ).select_related('m3u_account', 'category').order_by('-m3u_account__priority', 'id'), to_attr='active_relations' ) ).distinct() for movie in movies: # Get the first (highest priority) relation from prefetched data # This avoids the N+1 query problem entirely if hasattr(movie, 'active_relations') and movie.active_relations: relation = movie.active_relations[0] else: # Fallback - should rarely be needed with proper prefetching continue streams.append({ "num": movie.id, "name": movie.name, "stream_type": "movie", "stream_id": movie.id, "stream_icon": ( None if not movie.logo else build_absolute_uri_with_port( request, reverse("api:vod:vodlogo-cache", args=[movie.logo.id]) ) ), #'stream_icon': movie.logo.url if movie.logo else '', "rating": movie.rating or "0", "rating_5based": round(float(movie.rating or 0) / 2, 2) if movie.rating else 0, "added": str(int(movie.created_at.timestamp())), "is_adult": 0, "tmdb_id": movie.tmdb_id or "", "imdb_id": movie.imdb_id or "", "trailer": (movie.custom_properties or {}).get('trailer') or "", "category_id": str(relation.category.id) if relation.category else "0", "category_ids": [int(relation.category.id)] if relation.category else [], "container_extension": relation.container_extension or "mp4", "custom_sid": None, "direct_source": "", }) return streams def xc_get_series_categories(user): """Get series categories for XtreamCodes API""" from apps.vod.models import VODCategory, M3USeriesRelation response = [] # All authenticated users get access to series from all active M3U accounts categories = VODCategory.objects.filter( category_type='series', m3useriesrelation__m3u_account__is_active=True ).distinct().order_by(Lower("name")) for category in categories: response.append({ "category_id": str(category.id), "category_name": category.name, "parent_id": 0, }) return response def xc_get_series(request, user, category_id=None): """Get series list for XtreamCodes API""" from apps.vod.models import M3USeriesRelation series_list = [] # All authenticated users get access to series from all active M3U accounts filters = {"m3u_account__is_active": True} if category_id: filters["category_id"] = category_id # Get series relations instead of series directly series_relations = M3USeriesRelation.objects.filter(**filters).select_related( 'series', 'series__logo', 'category', 'm3u_account' ) for relation in series_relations: series = relation.series series_list.append({ "num": relation.id, # Use relation ID "name": series.name, "series_id": relation.id, # Use relation ID "cover": ( None if not series.logo else build_absolute_uri_with_port( request, reverse("api:vod:vodlogo-cache", args=[series.logo.id]) ) ), "plot": series.description or "", "cast": series.custom_properties.get('cast', '') if series.custom_properties else "", "director": series.custom_properties.get('director', '') if series.custom_properties else "", "genre": series.genre or "", "release_date": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), "releaseDate": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), "last_modified": str(int(relation.updated_at.timestamp())), "rating": str(series.rating or "0"), "rating_5based": str(round(float(series.rating or 0) / 2, 2)) if series.rating else "0", "backdrop_path": series.custom_properties.get('backdrop_path', []) if series.custom_properties else [], "youtube_trailer": series.custom_properties.get('youtube_trailer', '') if series.custom_properties else "", "episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "", "category_id": str(relation.category.id) if relation.category else "0", "category_ids": [int(relation.category.id)] if relation.category else [], }) return series_list def xc_get_series_info(request, user, series_id): """Get detailed series information including episodes""" from apps.vod.models import M3USeriesRelation, M3UEpisodeRelation if not series_id: raise Http404() # All authenticated users get access to series from all active M3U accounts filters = {"id": series_id, "m3u_account__is_active": True} try: series_relation = M3USeriesRelation.objects.select_related('series', 'series__logo').get(**filters) series = series_relation.series except M3USeriesRelation.DoesNotExist: raise Http404() # Check if we need to refresh detailed info (similar to vod api_views pattern) try: should_refresh = ( not series_relation.last_episode_refresh or series_relation.last_episode_refresh < django_timezone.now() - timedelta(hours=24) ) # Check if detailed data has been fetched custom_props = series_relation.custom_properties or {} episodes_fetched = custom_props.get('episodes_fetched', False) detailed_fetched = custom_props.get('detailed_fetched', False) # Force refresh if episodes/details have never been fetched or time interval exceeded if not episodes_fetched or not detailed_fetched or should_refresh: from apps.vod.tasks import refresh_series_episodes account = series_relation.m3u_account if account and account.is_active: refresh_series_episodes(account, series, series_relation.external_series_id) # Refresh objects from database after task completion series.refresh_from_db() series_relation.refresh_from_db() except Exception as e: logger.error(f"Error refreshing series data for relation {series_relation.id}: {str(e)}") # Get unique episodes for this series that have relations from any active M3U account # We query episodes directly to avoid duplicates when multiple relations exist # (e.g., same episode in different languages/qualities) from apps.vod.models import Episode episodes = Episode.objects.filter( series=series, m3u_relations__m3u_account__is_active=True ).distinct().order_by('season_number', 'episode_number') # Group episodes by season seasons = {} for episode in episodes: season_num = episode.season_number or 1 if season_num not in seasons: seasons[season_num] = [] # Get the highest priority relation for this episode (for container_extension, video/audio/bitrate) from apps.vod.models import M3UEpisodeRelation best_relation = M3UEpisodeRelation.objects.filter( episode=episode, m3u_account__is_active=True ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() video = audio = bitrate = None container_extension = "mp4" added_timestamp = str(int(episode.created_at.timestamp())) if best_relation: container_extension = best_relation.container_extension or "mp4" added_timestamp = str(int(best_relation.created_at.timestamp())) if best_relation.custom_properties: info = best_relation.custom_properties.get('info') if info and isinstance(info, dict): info_info = info.get('info') if info_info and isinstance(info_info, dict): video = info_info.get('video', {}) audio = info_info.get('audio', {}) bitrate = info_info.get('bitrate', 0) if video is None: video = episode.custom_properties.get('video', {}) if episode.custom_properties else {} if audio is None: audio = episode.custom_properties.get('audio', {}) if episode.custom_properties else {} if bitrate is None: bitrate = episode.custom_properties.get('bitrate', 0) if episode.custom_properties else 0 seasons[season_num].append({ "id": episode.id, "season": season_num, "episode_num": episode.episode_number or 0, "title": episode.name, "container_extension": container_extension, "added": added_timestamp, "custom_sid": None, "direct_source": "", "info": { "id": int(episode.id), "name": episode.name, "overview": episode.description or "", "crew": str(episode.custom_properties.get('crew', "") if episode.custom_properties else ""), "directed_by": episode.custom_properties.get('director', '') if episode.custom_properties else "", "imdb_id": episode.imdb_id or "", "air_date": f"{episode.air_date}" if episode.air_date else "", "backdrop_path": episode.custom_properties.get('backdrop_path', []) if episode.custom_properties else [], "movie_image": episode.custom_properties.get('movie_image', '') if episode.custom_properties else "", "rating": float(episode.rating or 0), "release_date": f"{episode.air_date}" if episode.air_date else "", "duration_secs": (episode.duration_secs or 0), "duration": format_duration_hms(episode.duration_secs), "video": video, "audio": audio, "bitrate": bitrate, } }) # Build response using potentially refreshed data series_data = { 'name': series.name, 'description': series.description or '', 'year': series.year, 'genre': series.genre or '', 'rating': series.rating or '0', 'cast': '', 'director': '', 'youtube_trailer': '', 'episode_run_time': '', 'backdrop_path': [], } # Add detailed info from custom_properties if available try: if series.custom_properties: custom_data = series.custom_properties series_data.update({ 'cast': custom_data.get('cast', ''), 'director': custom_data.get('director', ''), 'youtube_trailer': custom_data.get('youtube_trailer', ''), 'episode_run_time': custom_data.get('episode_run_time', ''), 'backdrop_path': custom_data.get('backdrop_path', []), }) # Check relation custom_properties for detailed_info if series_relation.custom_properties and 'detailed_info' in series_relation.custom_properties: detailed_info = series_relation.custom_properties['detailed_info'] # Override with detailed_info values where available for key in ['name', 'description', 'year', 'genre', 'rating']: if detailed_info.get(key): series_data[key] = detailed_info[key] # Handle plot vs description if detailed_info.get('plot'): series_data['description'] = detailed_info['plot'] elif detailed_info.get('description'): series_data['description'] = detailed_info['description'] # Update additional fields from detailed info series_data.update({ 'cast': detailed_info.get('cast', series_data['cast']), 'director': detailed_info.get('director', series_data['director']), 'youtube_trailer': detailed_info.get('youtube_trailer', series_data['youtube_trailer']), 'episode_run_time': detailed_info.get('episode_run_time', series_data['episode_run_time']), 'backdrop_path': detailed_info.get('backdrop_path', series_data['backdrop_path']), }) except Exception as e: logger.error(f"Error parsing series custom_properties: {str(e)}") seasons_list = [ {"season_number": int(season_num), "name": f"Season {season_num}"} for season_num in sorted(seasons.keys(), key=lambda x: int(x)) ] info = { 'seasons': seasons_list, "info": { "name": series_data['name'], "cover": ( None if not series.logo else build_absolute_uri_with_port( request, reverse("api:vod:vodlogo-cache", args=[series.logo.id]) ) ), "plot": series_data['description'], "cast": series_data['cast'], "director": series_data['director'], "genre": series_data['genre'], "release_date": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), "releaseDate": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), "added": str(int(series_relation.created_at.timestamp())), "last_modified": str(int(series_relation.updated_at.timestamp())), "rating": str(series_data['rating']), "rating_5based": str(round(float(series_data['rating'] or 0) / 2, 2)) if series_data['rating'] else "0", "backdrop_path": series_data['backdrop_path'], "youtube_trailer": series_data['youtube_trailer'], "imdb": str(series.imdb_id) if series.imdb_id else "", "tmdb": str(series.tmdb_id) if series.tmdb_id else "", "episode_run_time": str(series_data['episode_run_time']), "category_id": str(series_relation.category.id) if series_relation.category else "0", "category_ids": [int(series_relation.category.id)] if series_relation.category else [], }, "episodes": dict(seasons) } return info def xc_get_vod_info(request, user, vod_id): """Get detailed VOD (movie) information""" from apps.vod.models import M3UMovieRelation from django.utils import timezone from datetime import timedelta if not vod_id: raise Http404() # All authenticated users get access to VOD from all active M3U accounts filters = {"movie_id": vod_id, "m3u_account__is_active": True} try: # Order by account priority to get the best relation when multiple exist movie_relation = M3UMovieRelation.objects.select_related('movie', 'movie__logo').filter(**filters).order_by('-m3u_account__priority', 'id').first() if not movie_relation: raise Http404() movie = movie_relation.movie except (M3UMovieRelation.DoesNotExist, M3UMovieRelation.MultipleObjectsReturned): raise Http404() # Initialize basic movie data first movie_data = { 'name': movie.name, 'description': movie.description or '', 'year': movie.year, 'genre': movie.genre or '', 'rating': movie.rating or 0, 'tmdb_id': movie.tmdb_id or '', 'imdb_id': movie.imdb_id or '', 'director': '', 'actors': '', 'country': '', 'release_date': '', 'youtube_trailer': '', 'backdrop_path': [], 'cover_big': '', 'bitrate': 0, 'video': {}, 'audio': {}, } # Duplicate the provider_info logic for detailed information try: # Check if we need to refresh detailed info (same logic as provider_info) should_refresh = ( not movie_relation.last_advanced_refresh or movie_relation.last_advanced_refresh < timezone.now() - timedelta(hours=24) ) if should_refresh: # Trigger refresh of detailed info from apps.vod.tasks import refresh_movie_advanced_data refresh_movie_advanced_data(movie_relation.id) # Refresh objects from database after task completion movie.refresh_from_db() movie_relation.refresh_from_db() # Add detailed info from custom_properties if available if 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', {}), }) # 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 Exception as e: logger.error(f"Failed to process movie data: {e}") # Transform API response to XtreamCodes format info = { "info": { "name": movie_data.get('name', movie.name), "o_name": movie_data.get('name', movie.name), "cover_big": ( None if not movie.logo else build_absolute_uri_with_port( request, reverse("api:vod:vodlogo-cache", args=[movie.logo.id]) ) ), "movie_image": ( None if not movie.logo else build_absolute_uri_with_port( request, reverse("api:vod:vodlogo-cache", args=[movie.logo.id]) ) ), 'description': movie_data.get('description', ''), 'plot': movie_data.get('description', ''), 'year': movie_data.get('year', ''), 'release_date': movie_data.get('release_date', ''), 'genre': movie_data.get('genre', ''), 'director': movie_data.get('director', ''), 'actors': movie_data.get('actors', ''), 'cast': movie_data.get('actors', ''), 'country': movie_data.get('country', ''), 'rating': movie_data.get('rating', 0), 'imdb_id': movie_data.get('imdb_id', ''), "tmdb_id": movie_data.get('tmdb_id', ''), 'youtube_trailer': movie_data.get('youtube_trailer', ''), 'backdrop_path': movie_data.get('backdrop_path', []), 'cover': movie_data.get('cover_big', ''), 'bitrate': movie_data.get('bitrate', 0), 'video': movie_data.get('video', {}), 'audio': movie_data.get('audio', {}), }, "movie_data": { "stream_id": movie.id, "name": movie.name, "added": int(movie_relation.created_at.timestamp()), "category_id": str(movie_relation.category.id) if movie_relation.category else "0", "category_ids": [int(movie_relation.category.id)] if movie_relation.category else [], "container_extension": movie_relation.container_extension or "mp4", "custom_sid": None, "direct_source": "", } } return info def xc_movie_stream(request, username, password, stream_id, extension): """Handle XtreamCodes movie streaming requests""" from apps.vod.models import M3UMovieRelation user = get_object_or_404(User, username=username) custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: return JsonResponse({"error": "Invalid credentials"}, status=401) if custom_properties["xc_password"] != password: return JsonResponse({"error": "Invalid credentials"}, status=401) # All authenticated users get access to VOD from all active M3U accounts filters = {"movie_id": stream_id, "m3u_account__is_active": True} try: # Order by account priority to get the best relation when multiple exist movie_relation = M3UMovieRelation.objects.select_related('movie').filter(**filters).order_by('-m3u_account__priority', 'id').first() if not movie_relation: return JsonResponse({"error": "Movie not found"}, status=404) except (M3UMovieRelation.DoesNotExist, M3UMovieRelation.MultipleObjectsReturned): return JsonResponse({"error": "Movie not found"}, status=404) # Redirect to the VOD proxy endpoint from django.http import HttpResponseRedirect from django.urls import reverse vod_url = reverse('proxy:vod_proxy:vod_stream', kwargs={ 'content_type': 'movie', 'content_id': movie_relation.movie.uuid }) return HttpResponseRedirect(vod_url) def xc_series_stream(request, username, password, stream_id, extension): """Handle XtreamCodes series/episode streaming requests""" from apps.vod.models import M3UEpisodeRelation user = get_object_or_404(User, username=username) custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: return JsonResponse({"error": "Invalid credentials"}, status=401) if custom_properties["xc_password"] != password: return JsonResponse({"error": "Invalid credentials"}, status=401) # All authenticated users get access to series/episodes from all active M3U accounts filters = {"episode_id": stream_id, "m3u_account__is_active": True} try: episode_relation = M3UEpisodeRelation.objects.select_related('episode').filter(**filters).order_by('-m3u_account__priority', 'id').first() except M3UEpisodeRelation.DoesNotExist: return JsonResponse({"error": "Episode not found"}, status=404) # Redirect to the VOD proxy endpoint from django.http import HttpResponseRedirect from django.urls import reverse vod_url = reverse('proxy:vod_proxy:vod_stream', kwargs={ 'content_type': 'episode', 'content_id': episode_relation.episode.uuid }) return HttpResponseRedirect(vod_url) def get_host_and_port(request): """ Returns (host, port) for building absolute URIs. - Prefers X-Forwarded-Host/X-Forwarded-Port (nginx). - Falls back to Host header. - Returns None for port if using standard ports (80/443) to omit from URLs. - In dev, uses 5656 as a guess if port cannot be determined. """ # Determine the scheme first - needed for standard port detection scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme) standard_port = "443" if scheme == "https" else "80" # 1. Try X-Forwarded-Host (may include port) - set by our nginx xfh = request.META.get("HTTP_X_FORWARDED_HOST") if xfh: if ":" in xfh: host, port = xfh.split(":", 1) # Omit standard ports from URLs if port == standard_port: return host, None # Non-standard port in X-Forwarded-Host - return it # This handles reverse proxies on non-standard ports (e.g., https://example.com:8443) return host, port else: host = xfh # Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host) port = request.META.get("HTTP_X_FORWARDED_PORT") if port: # Omit standard ports from URLs return host, None if port == standard_port else port # If X-Forwarded-Proto is set but no valid port, assume standard if request.META.get("HTTP_X_FORWARDED_PROTO"): return host, None # 2. Try Host header raw_host = request.get_host() if ":" in raw_host: host, port = raw_host.split(":", 1) # Omit standard ports from URLs return host, None if port == standard_port else port else: host = raw_host # 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy) port = request.META.get("HTTP_X_FORWARDED_PORT") if port: # Omit standard ports from URLs return host, None if port == standard_port else port # 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present) # If so, assume standard port for the scheme (don't trust SERVER_PORT in this case) if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"): return host, None # 5. Try SERVER_PORT from META (only if NOT behind reverse proxy) port = request.META.get("SERVER_PORT") if port: # Omit standard ports from URLs return host, None if port == standard_port else port # 6. Dev fallback: guess port 5656 if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"): return host, "5656" # 7. Final fallback: assume standard port for scheme (omit from URL) return host, None def build_absolute_uri_with_port(request, path): """ Build an absolute URI with optional port. Port is omitted from URL if None (standard port for scheme). """ host, port = get_host_and_port(request) scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme) if port: return f"{scheme}://{host}:{port}{path}" else: return f"{scheme}://{host}{path}" def format_duration_hms(seconds): """ Format a duration in seconds as HH:MM:SS zero-padded string. """ seconds = int(seconds or 0) return f"{seconds//3600:02}:{(seconds%3600)//60:02}:{seconds%60:02}"