Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into ffmpeg-stats

This commit is contained in:
SergeantPanda 2025-06-09 15:42:31 -05:00
commit 44c8189c29
16 changed files with 322 additions and 84 deletions

64
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,64 @@
name: Bug Report
description: I have an issue with Dispatcharr
title: "[Bug]: "
labels: ["Bug", "Triage"]
type: "Bug"
projects: []
assignees: []
body:
- type: markdown
attributes:
value: |
Please make sure you search for similar issues before submitting. Thank you for your bug report!
- type: textarea
id: describe-the-bug
attributes:
label: Describe the bug
description: Make sure to attach screenshots if possible!
placeholder: Tell us what you see!
value: "A clear and concise description of what the bug is. What did you expect to happen?"
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: How can we recreate this bug?
description: Be detailed!
placeholder: Tell us what you see!
value: "1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error"
validations:
required: true
- type: input
id: dispatcharr-version
attributes:
label: Dispatcharr Version
description: What version of Dispatcharr are you running?
placeholder: Located bottom left of main screen
validations:
required: true
- type: input
id: docker-version
attributes:
label: Docker Version
description: What version of Docker are you running?
placeholder: docker --version
validations:
required: true
- type: textarea
id: docker-compose
attributes:
label: What's in your Docker Compose file?
description: Please share your docker-compose.yml file
placeholder: Tell us what you see!
value: "If not using Docker Compose just put not using."
validations:
required: true
- type: textarea
id: client-info
attributes:
label: Client Information
description: What are you using the view the streams from Dispatcharr
placeholder: Tell us what you see!
value: "Device, App, Versions for both, etc..."
validations:
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1 @@
blank_issues_enabled: false

View file

@ -0,0 +1,39 @@
name: Feature request
description: I want to suggest a new feature for Dispatcharr
title: "[Feature]: "
labels: ["Feature Request"]
type: "Feature"
projects: []
assignees: []
body:
- type: markdown
attributes:
value: |
Thank you for helping to make Dispatcharr better!
- type: textarea
id: describe-problem
attributes:
label: Is your feature request related to a problem?
description: Make sure to attach screenshots if possible!
placeholder: Tell us what you see!
value: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]"
validations:
required: true
- type: textarea
id: describe-solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
placeholder: Tell us what you see!
value: "Describe here."
validations:
required: true
- type: textarea
id: extras
attributes:
label: Additional context
description: Anything else you want to add?
placeholder: Tell us what you see!
value: "Nothing Extra"
validations:
required: true

View file

@ -843,7 +843,7 @@ def parse_channels_only(source):
# Change iterparse to look for both channel and programme elements
logger.debug(f"Creating iterparse context for channels and programmes")
channel_parser = etree.iterparse(source_file, events=('end',), tag=('channel', 'programme'))
channel_parser = etree.iterparse(source_file, events=('end',), tag=('channel', 'programme'), remove_blank_text=True)
if process:
logger.debug(f"[parse_channels_only] Memory after creating iterparse: {process.memory_info().rss / 1024 / 1024:.2f} MB")
@ -851,7 +851,6 @@ def parse_channels_only(source):
total_elements_processed = 0 # Track total elements processed, not just channels
for _, elem in channel_parser:
total_elements_processed += 1
# Only process channel elements
if elem.tag == 'channel':
channel_count += 1
@ -967,6 +966,7 @@ def parse_channels_only(source):
logger.debug(f"[parse_channels_only] Total elements processed: {total_elements_processed}")
else:
logger.trace(f"[parse_channels_only] Skipping non-channel element: {elem.get('channel', 'unknown')} - {elem.get('start', 'unknown')} {elem.tag}")
clear_element(elem)
continue
@ -1034,6 +1034,11 @@ def parse_channels_only(source):
if process:
logger.debug(f"[parse_channels_only] Memory before cleanup: {process.memory_info().rss / 1024 / 1024:.2f} MB")
try:
# Output any errors in the channel_parser error log
if 'channel_parser' in locals() and hasattr(channel_parser, 'error_log') and len(channel_parser.error_log) > 0:
logger.debug(f"XML parser errors found ({len(channel_parser.error_log)} total):")
for i, error in enumerate(channel_parser.error_log):
logger.debug(f" Error {i+1}: {error}")
if 'channel_parser' in locals():
del channel_parser
if 'elem' in locals():
@ -1190,7 +1195,7 @@ def parse_programs_for_tvg_id(epg_id):
source_file = open(file_path, 'rb')
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme')
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True)
for _, elem in program_parser:
if elem.get('channel') == epg.tvg_id:
@ -1629,6 +1634,9 @@ def extract_custom_properties(prog):
elif system == 'onscreen' and ep_num.text:
# Just store the raw onscreen format
custom_props['onscreen_episode'] = ep_num.text.strip()
elif system == 'dd_progid' and ep_num.text:
# Store the dd_progid format
custom_props['dd_progid'] = ep_num.text.strip()
# Extract ratings more efficiently
rating_elem = prog.find('rating')
@ -1664,7 +1672,7 @@ def extract_custom_properties(prog):
custom_props['icon'] = icon_elem.get('src')
# Simpler approach for boolean flags
for kw in ['previously-shown', 'premiere', 'new']:
for kw in ['previously-shown', 'premiere', 'new', 'live']:
if prog.find(kw) is not None:
custom_props[kw.replace('-', '_')] = True

View file

@ -79,8 +79,8 @@ class DiscoverAPIView(APIView):
# Otherwise use the limited profile sum plus custom streams
tuner_count = limited_tuners + custom_stream_count
# 5. Ensure minimum of 2 tuners
tuner_count = max(2, tuner_count)
# 5. Ensure minimum of 1 tuners
tuner_count = max(1, tuner_count)
logger.debug(f"Calculated tuner count: {tuner_count} (limited profiles: {limited_tuners}, custom streams: {custom_stream_count}, unlimited: {has_unlimited})")

View file

@ -239,7 +239,7 @@ def process_groups(account, groups):
group_objs = []
groups_to_create = []
for group_name, custom_props in groups.items():
logger.debug(f"Handling group: {group_name}")
logger.debug(f"Handling group for M3U account {account.id}: {group_name}")
if (group_name not in existing_groups):
groups_to_create.append(ChannelGroup(
name=group_name,
@ -405,7 +405,7 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
stream_hashes = {}
# compiled_filters = [(f.filter_type, re.compile(f.regex_pattern, re.IGNORECASE)) for f in filters]
logger.debug(f"Processing batch of {len(batch)}")
logger.debug(f"Processing batch of {len(batch)} for M3U account {account_id}")
for stream_info in batch:
try:
name, url = stream_info["name"], stream_info["url"]
@ -487,7 +487,7 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
except Exception as e:
logger.error(f"Bulk create failed: {str(e)}")
retval = f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated."
retval = f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated."
# Aggressive garbage collection
#del streams_to_create, streams_to_update, stream_hashes, existing_streams
@ -496,17 +496,17 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
return retval
def cleanup_streams(account_id):
def cleanup_streams(account_id, scan_start_time=timezone.now):
account = M3UAccount.objects.get(id=account_id, is_active=True)
existing_groups = ChannelGroup.objects.filter(
m3u_account__m3u_account=account,
m3u_account__enabled=True,
).values_list('id', flat=True)
logger.info(f"Found {len(existing_groups)} active groups")
logger.info(f"Found {len(existing_groups)} active groups for M3U account {account_id}")
# Calculate cutoff date for stale streams
stale_cutoff = timezone.now() - timezone.timedelta(days=account.stale_stream_days)
logger.info(f"Removing streams not seen since {stale_cutoff}")
stale_cutoff = scan_start_time - timezone.timedelta(days=account.stale_stream_days)
logger.info(f"Removing streams not seen since {stale_cutoff} for M3U account {account_id}")
# Delete streams that are not in active groups
streams_to_delete = Stream.objects.filter(
@ -527,7 +527,11 @@ def cleanup_streams(account_id):
streams_to_delete.delete()
stale_streams.delete()
logger.info(f"Cleanup complete: {deleted_count} streams removed due to group filter, {stale_count} removed as stale")
total_deleted = deleted_count + stale_count
logger.info(f"Cleanup for M3U account {account_id} complete: {deleted_count} streams removed due to group filter, {stale_count} removed as stale")
# Return the total count of deleted streams
return total_deleted
@shared_task
def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
@ -712,7 +716,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
group_name = parsed["attributes"]["group-title"]
# Log new groups as they're discovered
if group_name not in groups:
logger.debug(f"Found new group: '{group_name}'")
logger.debug(f"Found new group for M3U account {account_id}: '{group_name}'")
groups[group_name] = {}
extinf_data.append(parsed)
@ -729,7 +733,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
# Periodically log progress for large files
if valid_stream_count % 1000 == 0:
logger.debug(f"Processed {valid_stream_count} valid streams so far...")
logger.debug(f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}")
# Log summary statistics
logger.info(f"M3U parsing complete - Lines: {line_count}, EXTINF: {extinf_count}, URLs: {url_count}, Valid streams: {valid_stream_count}")
@ -833,7 +837,8 @@ def refresh_single_m3u_account(account_id):
return f"Task already running for account_id={account_id}."
# Record start time
start_time = time.time()
refresh_start_timestamp = timezone.now() # For the cleanup function
start_time = time.time() # For tracking elapsed time as float
streams_created = 0
streams_updated = 0
streams_deleted = 0
@ -962,7 +967,7 @@ def refresh_single_m3u_account(account_id):
account.save(update_fields=['status'])
if account.account_type == M3UAccount.Types.STADNARD:
logger.debug(f"Processing Standard account with groups: {existing_groups}")
logger.debug(f"Processing Standard account ({account_id}) with groups: {existing_groups}")
# Break into batches and process in parallel
batches = [extinf_data[i:i + BATCH_SIZE] for i in range(0, len(extinf_data), BATCH_SIZE)]
task_group = group(process_m3u_batch.s(account_id, batch, existing_groups, hash_keys) for batch in batches)
@ -1077,7 +1082,7 @@ def refresh_single_m3u_account(account_id):
Stream.objects.filter(id=-1).exists() # This will never find anything but ensures DB sync
# Now run cleanup
cleanup_streams(account_id)
streams_deleted = cleanup_streams(account_id, refresh_start_timestamp)
# Calculate elapsed time
elapsed_time = time.time() - start_time
@ -1107,8 +1112,6 @@ def refresh_single_m3u_account(account_id):
message=account.last_message
)
print(f"Function took {elapsed_time} seconds to execute.")
except Exception as e:
logger.error(f"Error processing M3U for account {account_id}: {str(e)}")
account.status = M3UAccount.Status.ERROR

View file

@ -1,17 +1,27 @@
from django.http import HttpResponse
from django.http import HttpResponse, HttpResponseForbidden
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.channels.models import Channel, ChannelProfile
from apps.epg.models import ProgramData
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from datetime import datetime, timedelta
import re
import html # Add this import for XML escaping
@csrf_exempt
@require_http_methods(["GET", "POST"])
def generate_m3u(request, profile_name=None):
"""
Dynamically generate an M3U file from channels.
The stream URL now points to the new stream_view that uses StreamProfile.
Supports both GET and POST methods for compatibility with IPTVSmarters.
"""
# Check if this is a POST request with data (which we don't want to allow)
if request.method == "POST" and request.body:
return HttpResponseForbidden("POST requests with content are not allowed")
if profile_name is not None:
channel_profile = ChannelProfile.objects.get(name=profile_name)
channels = Channel.objects.filter(
@ -21,6 +31,16 @@ def generate_m3u(request, profile_name=None):
else:
channels = Channel.objects.order_by('channel_number')
# Check if the request wants to use direct logo URLs instead of cache
use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false'
# Check if direct stream URLs should be used instead of proxy
use_direct_urls = request.GET.get('direct', 'false').lower() == 'true'
# Get the source to use for tvg-id value
# Options: 'channel_number' (default), 'tvg_id', 'gracenote'
tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower()
m3u_content = "#EXTM3U\n"
for channel in channels:
group_title = channel.channel_group.name if channel.channel_group else "Default"
@ -34,13 +54,30 @@ def generate_m3u(request, profile_name=None):
else:
formatted_channel_number = ""
# Use formatted channel number for tvg_id to ensure proper matching with EPG
tvg_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
# Determine the tvg-id based on the selected source
if tvg_id_source == 'tvg_id' and channel.tvg_id:
tvg_id = channel.tvg_id
elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid:
tvg_id = channel.tvc_guide_stationid
else:
# Default to channel number (original behavior)
tvg_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
tvg_name = channel.name
tvg_logo = ""
if channel.logo:
tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
if use_cached_logos:
# Use cached logo as before
tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
else:
# Try to find direct logo URL from channel's streams
direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None
# If direct logo found, use it; otherwise fall back to cached version
if direct_logo:
tvg_logo = direct_logo
else:
tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
# create possible gracenote id insertion
tvc_guide_stationid = ""
@ -52,10 +89,22 @@ def generate_m3u(request, profile_name=None):
f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n'
)
base_url = request.build_absolute_uri('/')[:-1]
stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}"
# Determine the stream URL based on the direct parameter
if use_direct_urls:
# Try to get the first stream's direct URL
first_stream = channel.streams.first()
if first_stream and first_stream.url:
# Use the direct stream URL
stream_url = first_stream.url
else:
# Fall back to proxy URL if no direct URL available
base_url = request.build_absolute_uri('/')[:-1]
stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}"
else:
# Standard behavior - use proxy URL
base_url = request.build_absolute_uri('/')[:-1]
stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}"
#stream_url = request.build_absolute_uri(reverse('output:stream', args=[channel.id]))
m3u_content += extinf_line + stream_url + "\n"
response = HttpResponse(m3u_content, content_type="audio/x-mpegurl")
@ -160,7 +209,7 @@ def generate_epg(request, profile_name=None):
Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models.
Since the EPG data is stored independently of Channels, we group programmes
by their associated EPGData record.
This version does not filter by time, so it includes the entire EPG saved in the DB.
This version filters data based on the 'days' parameter.
"""
xml_lines = []
xml_lines.append('<?xml version="1.0" encoding="UTF-8"?>')
@ -175,57 +224,115 @@ def generate_epg(request, profile_name=None):
else:
channels = Channel.objects.all()
# Check if the request wants to use direct logo URLs instead of cache
use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false'
# Get the source to use for tvg-id value
# Options: 'channel_number' (default), 'tvg_id', 'gracenote'
tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower()
# Get the number of days for EPG data
try:
# Default to 0 days (everything) for real EPG if not specified
days_param = request.GET.get('days', '0')
num_days = int(days_param)
# Set reasonable limits
num_days = max(0, min(num_days, 365)) # Between 0 and 365 days
except ValueError:
num_days = 0 # Default to all data if invalid value
# For dummy EPG, use either the specified value or default to 3 days
dummy_days = num_days if num_days > 0 else 3
# Calculate cutoff date for EPG data filtering (only if days > 0)
now = timezone.now()
cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None
# Retrieve all active channels
for channel in channels:
# Format channel number as integer if it has no decimal component - same as M3U generation
if channel.channel_number is not None:
if channel.channel_number == int(channel.channel_number):
formatted_channel_number = str(int(channel.channel_number))
formatted_channel_number = int(channel.channel_number)
else:
formatted_channel_number = str(channel.channel_number)
formatted_channel_number = channel.channel_number
else:
formatted_channel_number = str(channel.id)
formatted_channel_number = ""
display_name = channel.epg_data.name if channel.epg_data else channel.name
xml_lines.append(f' <channel id="{formatted_channel_number}">')
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
# Determine the channel ID based on the selected source
if tvg_id_source == 'tvg_id' and channel.tvg_id:
channel_id = channel.tvg_id
elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid:
channel_id = channel.tvc_guide_stationid
else:
# Default to channel number (original behavior)
channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
# Add channel logo if available
tvg_logo = ""
if channel.logo:
logo_url = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
xml_lines.append(f' <icon src="{html.escape(logo_url)}" />')
if use_cached_logos:
# Use cached logo as before
tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
else:
# Try to find direct logo URL from channel's streams
direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None
# If direct logo found, use it; otherwise fall back to cached version
if direct_logo:
tvg_logo = direct_logo
else:
tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
display_name = channel.epg_data.name if channel.epg_data else channel.name
xml_lines.append(f' <channel id="{channel_id}">')
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
xml_lines.append(f' <icon src="{html.escape(tvg_logo)}" />')
xml_lines.append(' </channel>')
for channel in channels:
# Use the same formatting for channel ID in program entries
if channel.channel_number is not None:
if channel.channel_number == int(channel.channel_number):
formatted_channel_number = str(int(channel.channel_number))
else:
formatted_channel_number = str(channel.channel_number)
# Use the same channel ID determination for program entries
if tvg_id_source == 'tvg_id' and channel.tvg_id:
channel_id = channel.tvg_id
elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid:
channel_id = channel.tvc_guide_stationid
else:
formatted_channel_number = str(channel.id)
# Get formatted channel number
if channel.channel_number is not None:
if channel.channel_number == int(channel.channel_number):
formatted_channel_number = int(channel.channel_number)
else:
formatted_channel_number = channel.channel_number
else:
formatted_channel_number = ""
# Default to channel number
channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
display_name = channel.epg_data.name if channel.epg_data else channel.name
if not channel.epg_data:
# Use the enhanced dummy EPG generation function with defaults
# These values could be made configurable via settings or request parameters
num_days = 1 # Default to 1 days of dummy EPG data
program_length_hours = 4 # Default to 4-hour program blocks
generate_dummy_epg(
formatted_channel_number,
channel_id,
display_name,
xml_lines,
num_days=num_days,
num_days=dummy_days, # Use dummy_days (3 days by default)
program_length_hours=program_length_hours
)
else:
programs = channel.epg_data.programs.all()
# For real EPG data - filter only if days parameter was specified
if num_days > 0:
programs = channel.epg_data.programs.filter(
start_time__gte=now,
start_time__lt=cutoff_date
)
else:
# Return all programs if days=0 or not specified
programs = channel.epg_data.programs.all()
for prog in programs:
start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
xml_lines.append(f' <programme start="{start_str}" stop="{stop_str}" channel="{formatted_channel_number}">')
xml_lines.append(f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">')
xml_lines.append(f' <title>{html.escape(prog.title)}</title>')
# Add subtitle if available
@ -256,6 +363,10 @@ def generate_epg(request, profile_name=None):
if 'onscreen_episode' in custom_data:
xml_lines.append(f' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>')
# Handle dd_progid format
if 'dd_progid' in custom_data:
xml_lines.append(f' <episode-num system="dd_progid">{html.escape(custom_data["dd_progid"])}</episode-num>')
# Add season and episode numbers in xmltv_ns format if available
if 'season' in custom_data and 'episode' in custom_data:
season = int(custom_data['season']) - 1 if str(custom_data['season']).isdigit() else 0
@ -302,6 +413,9 @@ def generate_epg(request, profile_name=None):
if custom_data.get('new', False):
xml_lines.append(f' <new />')
if custom_data.get('live', False):
xml_lines.append(f' <live />')
except Exception as e:
xml_lines.append(f' <!-- Error parsing custom properties: {html.escape(str(e))} -->')

View file

@ -307,16 +307,23 @@ class ChannelStatus:
client_count = proxy_server.redis_client.scard(client_set_key) or 0
# Calculate uptime
created_at = float(metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0').decode('utf-8'))
init_time_bytes = metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0')
created_at = float(init_time_bytes.decode('utf-8'))
uptime = time.time() - created_at if created_at > 0 else 0
# Safely decode bytes or use defaults
def safe_decode(bytes_value, default="unknown"):
if bytes_value is None:
return default
return bytes_value.decode('utf-8')
# Simplified info
info = {
'channel_id': channel_id,
'state': metadata.get(ChannelMetadataField.STATE.encode('utf-8'), b'unknown').decode('utf-8'),
'url': metadata.get(ChannelMetadataField.URL.encode('utf-8'), b'').decode('utf-8'),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8'), b'').decode('utf-8'),
'owner': metadata.get(ChannelMetadataField.OWNER.encode('utf-8'), b'unknown').decode('utf-8'),
'state': safe_decode(metadata.get(ChannelMetadataField.STATE.encode('utf-8'))),
'url': safe_decode(metadata.get(ChannelMetadataField.URL.encode('utf-8')), ""),
'stream_profile': safe_decode(metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8')), ""),
'owner': safe_decode(metadata.get(ChannelMetadataField.OWNER.encode('utf-8'))),
'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0,
'client_count': client_count,
'uptime': uptime
@ -376,14 +383,15 @@ class ChannelStatus:
# Efficient way - just retrieve the essentials
client_info = {
'client_id': client_id_str,
'user_agent': proxy_server.redis_client.hget(client_key, 'user_agent'),
'ip_address': proxy_server.redis_client.hget(client_key, 'ip_address').decode('utf-8'),
}
if client_info['user_agent']:
client_info['user_agent'] = client_info['user_agent'].decode('utf-8')
else:
client_info['user_agent'] = 'unknown'
# Safely get user_agent and ip_address
user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent')
client_info['user_agent'] = safe_decode(user_agent_bytes)
ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address')
if ip_address_bytes:
client_info['ip_address'] = safe_decode(ip_address_bytes)
# Just get connected_at for client age
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
@ -416,5 +424,5 @@ class ChannelStatus:
return info
except Exception as e:
logger.error(f"Error getting channel info: {e}")
logger.error(f"Error getting channel info: {e}", exc_info=True) # Added exc_info for better debugging
return None

View file

@ -199,15 +199,6 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
# Memory management settings
#CELERY_WORKER_MAX_TASKS_PER_CHILD = 10 # Restart worker after 10 tasks to free memory
#CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # Don't prefetch tasks - process one at a time
#CELERY_TASK_ACKS_LATE = True # Only acknowledge tasks after they're processed
#CELERY_TASK_TIME_LIMIT = 3600 # 1 hour time limit per task
#CELERY_TASK_SOFT_TIME_LIMIT = 3540 # Soft limit 60 seconds before hard limit
#CELERY_WORKER_CANCEL_LONG_RUNNING_TASKS_ON_CONNECTION_LOSS = True # Cancel tasks if connection lost
#CELERY_TASK_IGNORE_RESULT = True # Don't store results unless explicitly needed
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler"
CELERY_BEAT_SCHEDULE = {
'fetch-channel-statuses': {

View file

@ -1,6 +1,6 @@
services:
web:
image: dispatcharr/dispatcharr:alpha-v1
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr_web
ports:
- 9191:9191
@ -32,7 +32,7 @@ services:
# capabilities: [gpu]
celery:
image: dispatcharr/dispatcharr:alpha-v1
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr_celery
depends_on:
- db

View file

@ -8,7 +8,7 @@ exec-before = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
; Then start other services
attach-daemon = celery -A dispatcharr worker --concurrency=4
attach-daemon = celery -A dispatcharr worker --autoscale=6,1
attach-daemon = celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
attach-daemon = cd /app/frontend && npm run dev

View file

@ -10,7 +10,7 @@ exec-pre = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
; Then start other services
attach-daemon = celery -A dispatcharr worker --concurrency=4
attach-daemon = celery -A dispatcharr worker --autoscale=6,1
attach-daemon = celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
attach-daemon = cd /app/frontend && npm run dev

View file

@ -10,7 +10,7 @@ exec-pre = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
; Then start other services
attach-daemon = celery -A dispatcharr worker --concurrency=4
attach-daemon = celery -A dispatcharr worker --autoscale=6,1
attach-daemon = celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application

View file

@ -192,9 +192,10 @@ export const WebsocketProvider = ({ children }) => {
// Update the playlist status whenever we receive a status update
// Not just when progress is 100% or status is pending_setup
if (parsedEvent.data.status && parsedEvent.data.account) {
const playlist = playlists.find(
(p) => p.id === parsedEvent.data.account
);
// Check if playlists is an object with IDs as keys or an array
const playlist = Array.isArray(playlists)
? playlists.find((p) => p.id === parsedEvent.data.account)
: playlists[parsedEvent.data.account];
if (playlist) {
// When we receive a "success" status with 100% progress, this is a completed refresh
@ -212,9 +213,18 @@ export const WebsocketProvider = ({ children }) => {
parsedEvent.data.progress === 100
) {
updateData.updated_at = new Date().toISOString();
// Log successful completion for debugging
console.log('M3U refresh completed successfully:', updateData);
}
updatePlaylist(updateData);
} else {
// Log when playlist can't be found for debugging purposes
console.warn(
`Received update for unknown playlist ID: ${parsedEvent.data.account}`,
Array.isArray(playlists) ? 'playlists is array' : 'playlists is object',
Object.keys(playlists).length
);
}
}
break;

View file

@ -85,7 +85,7 @@ const M3U = ({
account_type: m3uAccount.account_type,
username: m3uAccount.username ?? '',
password: '',
stale_stream_days: m3uAccount.stale_stream_days || 7,
stale_stream_days: m3uAccount.stale_stream_days !== undefined && m3uAccount.stale_stream_days !== null ? m3uAccount.stale_stream_days : 7,
});
if (m3uAccount.account_type == 'XC') {
@ -225,7 +225,7 @@ const M3U = ({
id="account_type"
name="account_type"
label="Account Type"
description="Standard for direct M3U URLs, Xtream Codes for panel-based services"
description={<>Standard for direct M3U URLs, <br />Xtream Codes for panel-based services</>}
data={[
{
value: 'STD',
@ -233,7 +233,7 @@ const M3U = ({
},
{
value: 'XC',
label: 'XTream Codes',
label: 'Xtream Codes',
},
]}
key={form.key('account_type')}
@ -324,7 +324,7 @@ const M3U = ({
/>
<NumberInput
min={1}
min={0}
max={365}
label="Stale Stream Retention (days)"
description="Streams not seen for this many days will be removed"

View file

@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
__version__ = '0.5.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__version__ = '0.5.2' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process