mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-26 19:48:02 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into ffmpeg-stats
This commit is contained in:
commit
44c8189c29
16 changed files with 322 additions and 84 deletions
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
||||
39
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))} -->')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue