From 560345f7b1b072737ae51384d659323f8a50c1b1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 25 Mar 2025 12:13:06 -0500 Subject: [PATCH 001/239] Changed profile to stream_profile and utilized constants more for consistency. --- apps/proxy/ts_proxy/channel_status.py | 44 ++++++++++++++----------- apps/proxy/ts_proxy/constants.py | 40 ++++++++++++++++++++++ apps/proxy/ts_proxy/stream_generator.py | 11 ++++--- apps/proxy/ts_proxy/stream_manager.py | 39 +++++++++++----------- apps/proxy/ts_proxy/url_utils.py | 3 +- apps/proxy/ts_proxy/views.py | 18 +++++----- 6 files changed, 102 insertions(+), 53 deletions(-) diff --git a/apps/proxy/ts_proxy/channel_status.py b/apps/proxy/ts_proxy/channel_status.py index 9338fb7f..799da605 100644 --- a/apps/proxy/ts_proxy/channel_status.py +++ b/apps/proxy/ts_proxy/channel_status.py @@ -3,7 +3,7 @@ import time import re from . import proxy_server from .redis_keys import RedisKeys -from .constants import TS_PACKET_SIZE +from .constants import TS_PACKET_SIZE, ChannelMetadataField from redis.exceptions import ConnectionError, TimeoutError from .utils import get_logger @@ -35,28 +35,32 @@ class ChannelStatus: info = { 'channel_id': channel_id, - 'state': metadata.get(b'state', b'unknown').decode('utf-8'), - 'url': metadata.get(b'url', b'').decode('utf-8'), - 'profile': metadata.get(b'profile', b'unknown').decode('utf-8'), - 'started_at': metadata.get(b'init_time', b'0').decode('utf-8'), - 'owner': metadata.get(b'owner', b'unknown').decode('utf-8'), + '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'), + 'profile': metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8'), + metadata.get(ChannelMetadataField.PROFILE.encode('utf-8'), b'unknown')).decode('utf-8'), + 'started_at': metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0').decode('utf-8'), + 'owner': metadata.get(ChannelMetadataField.OWNER.encode('utf-8'), b'unknown').decode('utf-8'), 'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0, } # Add timing information - if b'state_changed_at' in metadata: - state_changed_at = float(metadata[b'state_changed_at'].decode('utf-8')) + state_changed_field = ChannelMetadataField.STATE_CHANGED_AT.encode('utf-8') + if state_changed_field in metadata: + state_changed_at = float(metadata[state_changed_field].decode('utf-8')) info['state_changed_at'] = state_changed_at info['state_duration'] = time.time() - state_changed_at - if b'init_time' in metadata: - created_at = float(metadata[b'init_time'].decode('utf-8')) + init_time_field = ChannelMetadataField.INIT_TIME.encode('utf-8') + if init_time_field in metadata: + created_at = float(metadata[init_time_field].decode('utf-8')) info['started_at'] = created_at info['uptime'] = time.time() - created_at # Add data throughput information - if b'total_bytes' in metadata: - total_bytes = int(metadata[b'total_bytes'].decode('utf-8')) + total_bytes_field = ChannelMetadataField.TOTAL_BYTES.encode('utf-8') + if total_bytes_field in metadata: + total_bytes = int(metadata[total_bytes_field].decode('utf-8')) info['total_bytes'] = total_bytes # Format total bytes in human-readable form @@ -87,7 +91,7 @@ class ChannelStatus: for client_id in client_ids: client_id_str = client_id.decode('utf-8') - client_key = f"ts_proxy:channel:{channel_id}:clients:{client_id_str}" + client_key = RedisKeys.client_metadata(channel_id, client_id_str) client_data = proxy_server.redis_client.hgetall(client_key) if client_data: @@ -261,23 +265,23 @@ class ChannelStatus: client_count = proxy_server.redis_client.scard(client_set_key) or 0 # Calculate uptime - created_at = float(metadata.get(b'init_time', b'0').decode('utf-8')) + created_at = float(metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0').decode('utf-8')) uptime = time.time() - created_at if created_at > 0 else 0 # Simplified info info = { 'channel_id': channel_id, - 'state': metadata.get(b'state', b'unknown').decode('utf-8'), - 'url': metadata.get(b'url', b'').decode('utf-8'), - 'profile': metadata.get(b'profile', b'unknown').decode('utf-8'), - 'owner': metadata.get(b'owner', b'unknown').decode('utf-8'), + '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'), + 'profile': metadata.get(ChannelMetadataField.PROFILE.encode('utf-8'), b'unknown').decode('utf-8'), + 'owner': metadata.get(ChannelMetadataField.OWNER.encode('utf-8'), b'unknown').decode('utf-8'), 'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0, 'client_count': client_count, 'uptime': uptime } # Add data throughput information to basic info - total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, 'total_bytes') + total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES.encode('utf-8')) if total_bytes_bytes: total_bytes = int(total_bytes_bytes.decode('utf-8')) info['total_bytes'] = total_bytes @@ -307,7 +311,7 @@ class ChannelStatus: # Get up to 10 clients for the basic view for client_id in list(client_ids)[:10]: client_id_str = client_id.decode('utf-8') - client_key = f"ts_proxy:channel:{channel_id}:clients:{client_id_str}" + client_key = RedisKeys.client_metadata(channel_id, client_id_str) # Efficient way - just retrieve the essentials client_info = { diff --git a/apps/proxy/ts_proxy/constants.py b/apps/proxy/ts_proxy/constants.py index 56caacbe..3d7b333b 100644 --- a/apps/proxy/ts_proxy/constants.py +++ b/apps/proxy/ts_proxy/constants.py @@ -35,6 +35,46 @@ class StreamType: TS = "ts" UNKNOWN = "unknown" +# Channel metadata field names stored in Redis +class ChannelMetadataField: + # Basic fields + URL = "url" + USER_AGENT = "user_agent" + STATE = "state" + OWNER = "owner" + STREAM_ID = "stream_id" + + # Profile fields + STREAM_PROFILE = "stream_profile" # New preferred name + PROFILE = "profile" # Legacy name + M3U_PROFILE = "m3u_profile" + + # Status and error fields + ERROR_MESSAGE = "error_message" + ERROR_TIME = "error_time" + STATE_CHANGED_AT = "state_changed_at" + INIT_TIME = "init_time" + CONNECTION_READY_TIME = "connection_ready_time" + + # Buffer and data tracking + BUFFER_CHUNKS = "buffer_chunks" + TOTAL_BYTES = "total_bytes" + + # Stream switching + STREAM_SWITCH_TIME = "stream_switch_time" + STREAM_SWITCH_REASON = "stream_switch_reason" + + # Client metadata fields + CONNECTED_AT = "connected_at" + LAST_ACTIVE = "last_active" + BYTES_SENT = "bytes_sent" + AVG_RATE_KBPS = "avg_rate_KBps" + CURRENT_RATE_KBPS = "current_rate_KBps" + IP_ADDRESS = "ip_address" + WORKER_ID = "worker_id" + CHUNKS_SENT = "chunks_sent" + STATS_UPDATED_AT = "stats_updated_at" + # TS packet constants TS_PACKET_SIZE = 188 TS_SYNC_BYTE = 0x47 diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py index dc6c2fc2..abf52d3a 100644 --- a/apps/proxy/ts_proxy/stream_generator.py +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -11,6 +11,7 @@ from . import proxy_server from .utils import create_ts_packet, get_logger from .redis_keys import RedisKeys from .utils import get_logger +from .constants import ChannelMetadataField logger = get_logger() @@ -298,11 +299,11 @@ class StreamGenerator: try: client_key = RedisKeys.client_metadata(self.channel_id, self.client_id) stats = { - "chunks_sent": str(self.chunks_sent), - "bytes_sent": str(self.bytes_sent), - "avg_rate_KBps": str(round(avg_rate, 1)), - "current_rate_KBps": str(round(self.current_rate, 1)), - "stats_updated_at": str(current_time) + ChannelMetadataField.CHUNKS_SENT: str(self.chunks_sent), + ChannelMetadataField.BYTES_SENT: str(self.bytes_sent), + ChannelMetadataField.AVG_RATE_KBPS: str(round(avg_rate, 1)), + ChannelMetadataField.CURRENT_RATE_KBPS: str(round(self.current_rate, 1)), + ChannelMetadataField.STATS_UPDATED_AT: str(current_time) } proxy_server.redis_client.hset(client_key, mapping=stats) # No need to set expiration as client heartbeat will refresh this key diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index db0d6bb9..dfe594e1 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -15,7 +15,7 @@ from core.models import UserAgent, CoreSettings from .stream_buffer import StreamBuffer from .utils import detect_stream_type, get_logger from .redis_keys import RedisKeys -from .constants import ChannelState, EventType, StreamType, TS_PACKET_SIZE +from .constants import ChannelState, EventType, StreamType, ChannelMetadataField, TS_PACKET_SIZE from .config_helper import ConfigHelper from .url_utils import get_alternate_streams, get_stream_info_for_switch @@ -284,10 +284,10 @@ class StreamManager: # Update metadata to indicate error state update_data = { - "state": ChannelState.ERROR, - "state_changed_at": str(time.time()), - "error_message": error_message, - "error_time": str(time.time()) + ChannelMetadataField.STATE: ChannelState.ERROR, + ChannelMetadataField.STATE_CHANGED_AT: str(time.time()), + ChannelMetadataField.ERROR_MESSAGE: error_message, + ChannelMetadataField.ERROR_TIME: str(time.time()) } self.buffer.redis_client.hset(metadata_key, mapping=update_data) logger.info(f"Updated channel {self.channel_id} state to ERROR in Redis after stream failure") @@ -398,13 +398,13 @@ class StreamManager: metadata_key = RedisKeys.channel_metadata(self.channel_id) # Use hincrby to atomically increment the total_bytes field - self.buffer.redis_client.hincrby(metadata_key, "total_bytes", self.bytes_processed) + self.buffer.redis_client.hincrby(metadata_key, ChannelMetadataField.TOTAL_BYTES, self.bytes_processed) # Reset local counter after updating Redis self.bytes_processed = 0 self.last_bytes_update = now - logger.debug(f"Updated total_bytes in Redis for channel {self.channel_id}") + logger.debug(f"Updated {ChannelMetadataField.TOTAL_BYTES} in Redis for channel {self.channel_id}") except Exception as e: logger.error(f"Error updating bytes processed: {e}") @@ -743,8 +743,9 @@ class StreamManager: current_state = None try: metadata = redis_client.hgetall(metadata_key) - if metadata and b'state' in metadata: - current_state = metadata[b'state'].decode('utf-8') + state_field = ChannelMetadataField.STATE.encode('utf-8') + if metadata and state_field in metadata: + current_state = metadata[state_field].decode('utf-8') except Exception as e: logger.error(f"Error checking current state: {e}") @@ -758,8 +759,8 @@ class StreamManager: # Not enough buffer yet - set to connecting state if not already if current_state != ChannelState.CONNECTING: update_data = { - "state": ChannelState.CONNECTING, - "state_changed_at": current_time + ChannelMetadataField.STATE: ChannelState.CONNECTING, + ChannelMetadataField.STATE_CHANGED_AT: current_time } redis_client.hset(metadata_key, mapping=update_data) logger.info(f"Channel {channel_id} connected but waiting for buffer to fill: {current_buffer_index}/{initial_chunks_needed} chunks") @@ -772,10 +773,10 @@ class StreamManager: # We have enough buffer, proceed with state change update_data = { - "state": ChannelState.WAITING_FOR_CLIENTS, - "connection_ready_time": current_time, - "state_changed_at": current_time, - "buffer_chunks": str(current_buffer_index) + ChannelMetadataField.STATE: ChannelState.WAITING_FOR_CLIENTS, + ChannelMetadataField.CONNECTION_READY_TIME: current_time, + ChannelMetadataField.STATE_CHANGED_AT: current_time, + ChannelMetadataField.BUFFER_CHUNKS: str(current_buffer_index) } redis_client.hset(metadata_key, mapping=update_data) @@ -885,10 +886,10 @@ class StreamManager: if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: metadata_key = RedisKeys.channel_metadata(self.channel_id) self.buffer.redis_client.hset(metadata_key, mapping={ - "url": new_url, - "user_agent": new_user_agent, - "profile": stream_info['profile'], - "stream_id": str(stream_id), + ChannelMetadataField.URL: new_url, + ChannelMetadataField.USER_AGENT: new_user_agent, + ChannelMetadataField.STREAM_PROFILE: stream_info['profile'], + ChannelMetadataField.STREAM_ID: str(stream_id), "stream_switch_time": str(time.time()), "stream_switch_reason": "max_retries_exceeded" }) diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index a0ed4476..122956c0 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -54,7 +54,8 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]: else: transcode = True - # Get profile name as string + # Get profile name as string - use id for backward compatibility + # but we'll store it in the STREAM_PROFILE field profile_value = stream_profile.id return stream_url, stream_user_agent, transcode, profile_value diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index fe87e677..504fa72c 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -18,7 +18,7 @@ from apps.m3u.models import M3UAccount, M3UAccountProfile from core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated -from .constants import ChannelState, EventType, StreamType +from .constants import ChannelState, EventType, StreamType, ChannelMetadataField from .config_helper import ConfigHelper from .services.channel_service import ChannelService from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch @@ -56,15 +56,17 @@ def stream_ts(request, channel_id): metadata_key = RedisKeys.channel_metadata(channel_id) if proxy_server.redis_client.exists(metadata_key): metadata = proxy_server.redis_client.hgetall(metadata_key) - if b'state' in metadata: - channel_state = metadata[b'state'].decode('utf-8') + state_field = ChannelMetadataField.STATE.encode('utf-8') + if state_field in metadata: + channel_state = metadata[state_field].decode('utf-8') # Only skip initialization if channel is in a healthy state valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS] if channel_state in valid_states: # Verify the owner is still active - if b'owner' in metadata: - owner = metadata[b'owner'].decode('utf-8') + owner_field = ChannelMetadataField.OWNER.encode('utf-8') + if owner_field in metadata: + owner = metadata[owner_field].decode('utf-8') owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat" if proxy_server.redis_client.exists(owner_heartbeat_key): # Owner is active and channel is in good state @@ -134,9 +136,9 @@ def stream_ts(request, channel_id): if proxy_server.redis_client: metadata_key = RedisKeys.channel_metadata(channel_id) - url_bytes = proxy_server.redis_client.hget(metadata_key, "url") - ua_bytes = proxy_server.redis_client.hget(metadata_key, "user_agent") - profile_bytes = proxy_server.redis_client.hget(metadata_key, "profile") + url_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.URL) + ua_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.USER_AGENT) + profile_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_PROFILE) if url_bytes: url = url_bytes.decode('utf-8') From 7bcfc74c1304be775da32b386e9a956f1877eea1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 25 Mar 2025 12:16:01 -0500 Subject: [PATCH 002/239] Missed a couple constants in try next stream. --- apps/proxy/ts_proxy/stream_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index dfe594e1..0f08df97 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -890,8 +890,8 @@ class StreamManager: ChannelMetadataField.USER_AGENT: new_user_agent, ChannelMetadataField.STREAM_PROFILE: stream_info['profile'], ChannelMetadataField.STREAM_ID: str(stream_id), - "stream_switch_time": str(time.time()), - "stream_switch_reason": "max_retries_exceeded" + ChannelMetadataField.STREAM_SWITCH_TIME: str(time.time()), + ChannelMetadataField.STREAM_SWITCH_REASON: "max_retries_exceeded" }) # Log the switch From 4283162ba9a056f386be416adcecac1565f531cb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 25 Mar 2025 13:04:51 -0500 Subject: [PATCH 003/239] Switched profile to stream_profile in channel_service --- apps/proxy/ts_proxy/constants.py | 3 +- .../ts_proxy/services/channel_service.py | 40 +++++++++---------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/apps/proxy/ts_proxy/constants.py b/apps/proxy/ts_proxy/constants.py index 3d7b333b..4827b24b 100644 --- a/apps/proxy/ts_proxy/constants.py +++ b/apps/proxy/ts_proxy/constants.py @@ -45,8 +45,7 @@ class ChannelMetadataField: STREAM_ID = "stream_id" # Profile fields - STREAM_PROFILE = "stream_profile" # New preferred name - PROFILE = "profile" # Legacy name + STREAM_PROFILE = "stream_profile" M3U_PROFILE = "m3u_profile" # Status and error fields diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 210e4b0f..2b7363dc 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -11,7 +11,7 @@ from apps.channels.models import Channel from apps.proxy.config import TSConfig as Config from .. import proxy_server from ..redis_keys import RedisKeys -from ..constants import EventType, ChannelState +from ..constants import EventType, ChannelState, ChannelMetadataField from ..url_utils import get_stream_info_for_switch logger = logging.getLogger("ts_proxy") @@ -42,19 +42,19 @@ class ChannelService: # Check if metadata already exists if proxy_server.redis_client.exists(metadata_key): # Just update the existing metadata with stream_id - proxy_server.redis_client.hset(metadata_key, "stream_id", str(stream_id)) + proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STREAM_ID, str(stream_id)) logger.info(f"Pre-set stream ID {stream_id} in Redis for channel {channel_id}") else: # Create initial metadata with essential values initial_metadata = { - "stream_id": str(stream_id), + ChannelMetadataField.STREAM_ID: str(stream_id), "temp_init": str(time.time()) } proxy_server.redis_client.hset(metadata_key, mapping=initial_metadata) logger.info(f"Created initial metadata with stream_id {stream_id} for channel {channel_id}") # Verify the stream_id was set - stream_id_value = proxy_server.redis_client.hget(metadata_key, "stream_id") + stream_id_value = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID) if stream_id_value: logger.info(f"Verified stream_id {stream_id_value.decode('utf-8')} is now set in Redis") else: @@ -68,9 +68,9 @@ class ChannelService: metadata_key = RedisKeys.channel_metadata(channel_id) update_data = {} if profile_value: - update_data["profile"] = profile_value + update_data[ChannelMetadataField.STREAM_PROFILE] = profile_value if stream_id: - update_data["stream_id"] = str(stream_id) + update_data[ChannelMetadataField.STREAM_ID] = str(stream_id) if update_data: proxy_server.redis_client.hset(metadata_key, mapping=update_data) @@ -220,8 +220,8 @@ class ChannelService: channel_info = {"state": state} # Immediately mark as stopping in metadata so clients detect it faster - proxy_server.redis_client.hset(metadata_key, "state", ChannelState.STOPPING) - proxy_server.redis_client.hset(metadata_key, "state_changed_at", str(time.time())) + proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STATE, ChannelState.STOPPING) + proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STATE_CHANGED_AT, str(time.time())) except Exception as e: logger.error(f"Error fetching channel state: {e}") @@ -350,8 +350,8 @@ class ChannelService: metadata = proxy_server.redis_client.hgetall(metadata_key) # Extract state and owner - state = metadata.get(b'state', b'unknown').decode('utf-8') - owner = metadata.get(b'owner', b'unknown').decode('utf-8') + state = metadata.get(ChannelMetadataField.STATE.encode(), b'unknown').decode('utf-8') + owner = metadata.get(ChannelMetadataField.OWNER.encode(), b'unknown').decode('utf-8') # Valid states indicate channel is running properly valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING] @@ -360,7 +360,7 @@ class ChannelService: return False, state, owner, {"error": f"Invalid state: {state}"} # Check if owner is still active - owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat" + owner_heartbeat_key = RedisKeys.worker_heartbeat(owner) owner_alive = proxy_server.redis_client.exists(owner_heartbeat_key) if not owner_alive: @@ -407,21 +407,21 @@ class ChannelService: # Use the appropriate method based on the key type if key_type == 'hash': - proxy_server.redis_client.hset(metadata_key, "url", url) + proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.URL, url) if user_agent: - proxy_server.redis_client.hset(metadata_key, "user_agent", user_agent) + proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.USER_AGENT, user_agent) elif key_type == 'none': # Key doesn't exist yet # Create new hash with all required fields - metadata = {"url": url} + metadata = {ChannelMetadataField.URL: url} if user_agent: - metadata["user_agent"] = user_agent + metadata[ChannelMetadataField.USER_AGENT] = user_agent proxy_server.redis_client.hset(metadata_key, mapping=metadata) else: # If key exists with wrong type, delete it and recreate proxy_server.redis_client.delete(metadata_key) - metadata = {"url": url} + metadata = {ChannelMetadataField.URL: url} if user_agent: - metadata["user_agent"] = user_agent + metadata[ChannelMetadataField.USER_AGENT] = user_agent proxy_server.redis_client.hset(metadata_key, mapping=metadata) # Set switch request flag to ensure all workers see it @@ -438,7 +438,7 @@ class ChannelService: return False switch_request = { - "event": EventType.STREAM_SWITCH, # Use constant instead of string + "event": EventType.STREAM_SWITCH, "channel_id": channel_id, "url": new_url, "user_agent": user_agent, @@ -459,7 +459,7 @@ class ChannelService: return False stop_request = { - "event": EventType.CHANNEL_STOP, # Use constant instead of string + "event": EventType.CHANNEL_STOP, "channel_id": channel_id, "requester_worker_id": proxy_server.worker_id, "timestamp": time.time() @@ -480,7 +480,7 @@ class ChannelService: return False stop_request = { - "event": EventType.CLIENT_STOP, # Use constant instead of string + "event": EventType.CLIENT_STOP, "channel_id": channel_id, "client_id": client_id, "requester_worker_id": proxy_server.worker_id, From 9ab76c3129c6a1ad56298f2d8a4a8dd2dc509244 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 25 Mar 2025 13:36:55 -0500 Subject: [PATCH 004/239] updated variable names for clarity. --- apps/proxy/ts_proxy/url_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 122956c0..8183c4f2 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -33,10 +33,10 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]: # Get the M3U account profile for URL pattern stream = get_object_or_404(Stream, pk=stream_id) - profile = get_object_or_404(M3UAccountProfile, pk=profile_id) + m3u_profile = get_object_or_404(M3UAccountProfile, pk=profile_id) # Get the appropriate user agent - m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id) + m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id) stream_user_agent = UserAgent.objects.get(id=m3u_account.user_agent.id).user_agent if stream_user_agent is None: @@ -45,7 +45,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]: # Generate stream URL based on the selected profile input_url = stream.url - stream_url = transform_url(input_url, profile.search_pattern, profile.replace_pattern) + stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern) # Check if transcoding is needed stream_profile = channel.get_stream_profile() From de5063d20b5109d9edd3abd658fd8cb0aca253c8 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 26 Mar 2025 13:28:03 -0400 Subject: [PATCH 005/239] epg refactor, new relations and matching --- apps/channels/admin.py | 4 +- apps/channels/api_views.py | 13 + apps/channels/forms.py | 2 +- ...emove_channel_tvg_name_channel_epg_data.py | 24 ++ apps/channels/models.py | 11 +- apps/channels/serializers.py | 14 +- apps/channels/tasks.py | 15 +- .../migrations/0003_alter_epgdata_tvg_id.py | 18 + ...epgdata_epg_source_alter_epgdata_tvg_id.py | 24 ++ apps/epg/models.py | 9 +- apps/epg/serializers.py | 12 +- apps/epg/tasks.py | 42 +-- apps/m3u/tasks.py | 3 +- apps/output/views.py | 2 +- frontend/package-lock.json | 334 ++++++++++++++++++ frontend/package.json | 3 + frontend/src/components/forms/Channel.jsx | 143 ++++++-- frontend/src/components/forms/Stream.jsx | 2 +- frontend/src/components/sidebar.css | 1 - frontend/src/components/tables/EPGsTable.jsx | 4 +- frontend/src/components/tables/M3UsTable.jsx | 2 + .../src/components/tables/StreamsTable.jsx | 5 +- frontend/src/helpers/table.jsx | 4 +- frontend/src/mantineTheme.jsx | 13 + frontend/src/pages/Guide.jsx | 6 +- frontend/src/pages/Stats.jsx | 271 +++++++++----- frontend/src/store/epgs.jsx | 28 +- 27 files changed, 839 insertions(+), 170 deletions(-) create mode 100644 apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py create mode 100644 apps/epg/migrations/0003_alter_epgdata_tvg_id.py create mode 100644 apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py diff --git a/apps/channels/admin.py b/apps/channels/admin.py index 302811af..410f101d 100644 --- a/apps/channels/admin.py +++ b/apps/channels/admin.py @@ -26,10 +26,10 @@ class ChannelAdmin(admin.ModelAdmin): 'uuid', 'name', 'channel_group', - 'tvg_name' + 'epg_data' ) list_filter = ('channel_group',) - search_fields = ('id', 'name', 'channel_group__name', 'tvg_name') # Added 'id' + search_fields = ('id', 'name', 'channel_group__name', 'epg_data') # Added 'id' ordering = ('channel_number',) @admin.register(ChannelGroup) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 579d9dfa..e5e365ba 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -13,6 +13,7 @@ from .tasks import match_epg_channels import django_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter +from apps.epg.models import EPGData from rest_framework.pagination import PageNumberPagination @@ -196,6 +197,12 @@ class ChannelViewSet(viewsets.ModelViewSet): 'logo_url': stream.logo_url, 'streams': [stream_id] } + + # Attempt to find existing EPGs with the same tvg-id + epgs = EPGData.objects.filter(tvg_id=stream.tvg_id) + if epgs: + channel_data["epg_data_id"] = epgs.first().id + serializer = self.get_serializer(data=channel_data) serializer.is_valid(raise_exception=True) channel = serializer.save() @@ -291,6 +298,12 @@ class ChannelViewSet(viewsets.ModelViewSet): "logo_url": stream.logo_url, "streams": [stream_id], } + + # Attempt to find existing EPGs with the same tvg-id + epgs = EPGData.objects.filter(tvg_id=stream.tvg_id) + if epgs: + channel_data["epg_data"] = epgs.first() + serializer = self.get_serializer(data=channel_data) if serializer.is_valid(): channel = serializer.save() diff --git a/apps/channels/forms.py b/apps/channels/forms.py index bee073b6..342bd0fe 100644 --- a/apps/channels/forms.py +++ b/apps/channels/forms.py @@ -40,7 +40,7 @@ class StreamForm(forms.ModelForm): 'name', 'url', 'logo_url', - 'tvg_id', + 'epg_data', 'local_file', 'channel_group', ] diff --git a/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py b/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py new file mode 100644 index 00000000..814ea1ff --- /dev/null +++ b/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-03-26 12:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0008_stream_stream_hash'), + ('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'), + ] + + operations = [ + migrations.RemoveField( + model_name='channel', + name='tvg_name', + ), + migrations.AddField( + model_name='channel', + name='epg_data', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='epg.epgdata'), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index ec95e309..7f299639 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -9,6 +9,7 @@ import uuid from datetime import datetime import hashlib import json +from apps.epg.models import EPGData logger = logging.getLogger(__name__) @@ -164,7 +165,13 @@ class Channel(models.Model): help_text="Channel group this channel belongs to." ) tvg_id = models.CharField(max_length=255, blank=True, null=True) - tvg_name = models.CharField(max_length=255, blank=True, null=True) + epg_data = models.ForeignKey( + EPGData, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='channels' + ) stream_profile = models.ForeignKey( StreamProfile, @@ -219,8 +226,6 @@ class Channel(models.Model): default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default] - logger.info('profiles') - for profile in profiles: logger.info(profile) # Skip inactive profiles diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index a075297d..65843dea 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -1,6 +1,8 @@ from rest_framework import serializers from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount +from apps.epg.serializers import EPGDataSerializer from core.models import StreamProfile +from apps.epg.models import EPGData # # Stream @@ -68,6 +70,13 @@ class ChannelSerializer(serializers.ModelSerializer): write_only=True, required=False ) + epg_data = EPGDataSerializer(read_only=True) + epg_data_id = serializers.PrimaryKeyRelatedField( + queryset=EPGData.objects.all(), + source="epg_data", + write_only=True, + required=False, + ) stream_profile_id = serializers.PrimaryKeyRelatedField( queryset=StreamProfile.objects.all(), @@ -92,7 +101,8 @@ class ChannelSerializer(serializers.ModelSerializer): 'channel_group', 'channel_group_id', 'tvg_id', - 'tvg_name', + 'epg_data', + 'epg_data_id', 'streams', 'stream_ids', 'stream_profile_id', @@ -126,7 +136,7 @@ class ChannelSerializer(serializers.ModelSerializer): instance.name = validated_data.get('name', instance.name) instance.logo_url = validated_data.get('logo_url', instance.logo_url) instance.tvg_id = validated_data.get('tvg_id', instance.tvg_id) - instance.tvg_name = validated_data.get('tvg_name', instance.tvg_name) + instance.epg_data = validated_data.get('epg_data', instance.epg_data) # If serializer allows changing channel_group or stream_profile: if 'channel_group' in validated_data: diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index f9b4992b..c9b89c85 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -84,9 +84,10 @@ def match_epg_channels(): region_code = None # Gather EPGData rows so we can do fuzzy matching in memory - all_epg = list(EPGData.objects.all()) + all_epg = {e.id: e for e in EPGData.objects.all()} + epg_rows = [] - for e in all_epg: + for e in list(all_epg.values()): epg_rows.append({ "epg_id": e.id, "tvg_id": e.tvg_id or "", @@ -121,7 +122,7 @@ def match_epg_channels(): continue # C) Perform name-based fuzzy matching - fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.name + fallback_name = chan.epg_data.name.strip() if chan.epg_data else chan.name norm_chan = normalize_name(fallback_name) if not norm_chan: logger.info(f"Channel {chan.id} '{chan.name}' => empty after normalization, skipping") @@ -165,12 +166,12 @@ def match_epg_channels(): # If best_score is above BEST_FUZZY_THRESHOLD => direct accept if best_score >= BEST_FUZZY_THRESHOLD: - chan.tvg_id = best_epg["tvg_id"] + chan.epg_data = all_epg[best_epg["epg_id"]] chan.save() # Attempt to parse program data for this channel if epg_file_path: - parse_programs_for_tvg_id(epg_file_path, best_epg["tvg_id"]) + parse_programs_for_tvg_id(epg_file_path, all_epg[best_epg["epg_id"]]) logger.info(f"Loaded program data for tvg_id={best_epg['tvg_id']}") matched_channels.append((chan.id, fallback_name, best_epg["tvg_id"])) @@ -187,11 +188,11 @@ def match_epg_channels(): top_value = float(sim_scores[top_index]) if top_value >= EMBED_SIM_THRESHOLD: matched_epg = epg_rows[top_index] - chan.tvg_id = matched_epg["tvg_id"] + chan.epg_data = all_epg[matched_epg["epg_id"]] chan.save() if epg_file_path: - parse_programs_for_tvg_id(epg_file_path, matched_epg["tvg_id"]) + parse_programs_for_tvg_id(epg_file_path, all_epg[matched_epg["epg_id"]]) logger.info(f"Loaded program data for tvg_id={matched_epg['tvg_id']}") matched_channels.append((chan.id, fallback_name, matched_epg["tvg_id"])) diff --git a/apps/epg/migrations/0003_alter_epgdata_tvg_id.py b/apps/epg/migrations/0003_alter_epgdata_tvg_id.py new file mode 100644 index 00000000..f339b981 --- /dev/null +++ b/apps/epg/migrations/0003_alter_epgdata_tvg_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-25 19:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0002_epgsource_file_path'), + ] + + operations = [ + migrations.AlterField( + model_name='epgdata', + name='tvg_id', + field=models.CharField(blank=True, max_length=255, null=True, unique=True), + ), + ] diff --git a/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py b/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py new file mode 100644 index 00000000..ff8f7a11 --- /dev/null +++ b/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-03-26 12:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0003_alter_epgdata_tvg_id'), + ] + + operations = [ + migrations.AddField( + model_name='epgdata', + name='epg_source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='epgs', to='epg.epgsource'), + ), + migrations.AlterField( + model_name='epgdata', + name='tvg_id', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index 305d30ed..83dc7aad 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -19,8 +19,15 @@ class EPGSource(models.Model): class EPGData(models.Model): # Removed the Channel foreign key. We now just store the original tvg_id # and a name (which might simply be the tvg_id if no real channel exists). - tvg_id = models.CharField(max_length=255, null=True, blank=True, unique=True) + tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) name = models.CharField(max_length=255) + epg_source = models.ForeignKey( + EPGSource, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="epgs", + ) def __str__(self): return f"EPG Data for {self.name}" diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index b10e7371..8ea4100d 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -3,9 +3,14 @@ from .models import EPGSource, EPGData, ProgramData from apps.channels.models import Channel class EPGSourceSerializer(serializers.ModelSerializer): + epg_data_ids = serializers.SerializerMethodField() + class Meta: model = EPGSource - fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active'] + fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active', 'epg_data_ids'] + + def get_epg_data_ids(self, obj): + return list(obj.epgs.values_list('id', flat=True)) class ProgramDataSerializer(serializers.ModelSerializer): class Meta: @@ -17,10 +22,13 @@ class EPGDataSerializer(serializers.ModelSerializer): Only returns the tvg_id and the 'name' field from EPGData. We assume 'name' is effectively the channel name. """ + read_only_fields = ['epg_source'] + class Meta: model = EPGData fields = [ 'id', 'tvg_id', 'name', - ] \ No newline at end of file + 'epg_source', + ] diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 9b9fa49f..ed0faf92 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -62,20 +62,21 @@ def fetch_xmltv(source): source.file_path = file_path source.save(update_fields=['file_path']) - epg_entries = EPGData.objects.exclude(tvg_id__isnull=True).exclude(tvg_id__exact='') - for epg in epg_entries: - if Channel.objects.filter(tvg_id=epg.tvg_id).exists(): - logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") - parse_programs_for_tvg_id(file_path, epg.tvg_id) - # Now parse blocks only - parse_channels_only(file_path) + parse_channels_only(source, file_path) + + epg_entries = EPGData.objects.filter(epg_source=source) + for epg in epg_entries: + if epg.tvg_id: + if Channel.objects.filter(epg_data=epg).exists(): + logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") + parse_programs_for_tvg_id(file_path, epg) except Exception as e: logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True) -def parse_channels_only(file_path): +def parse_channels_only(source, file_path): logger.info(f"Parsing channels from EPG file: {file_path}") # Read entire file (decompress if .gz) @@ -101,7 +102,8 @@ def parse_channels_only(file_path): epg_obj, created = EPGData.objects.get_or_create( tvg_id=tvg_id, - defaults={'name': display_name} + epg_source=source, + defaults={'name': display_name}, ) if not created: # Optionally update if new name is different @@ -110,13 +112,11 @@ def parse_channels_only(file_path): epg_obj.save() logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}") - parse_programs_for_tvg_id(file_path, tvg_id) - logger.info("Finished parsing channel info.") -def parse_programs_for_tvg_id(file_path, tvg_id): - logger.info(f"Parsing for tvg_id={tvg_id} from {file_path}") +def parse_programs_for_tvg_id(file_path, epg): + logger.info(f"Parsing for tvg_id={epg.tvg_id} from {file_path}") # Read entire file (decompress if .gz) if file_path.endswith('.gz'): @@ -128,16 +128,10 @@ def parse_programs_for_tvg_id(file_path, tvg_id): xml_data = xml_file.read() root = ET.fromstring(xml_data) - # Retrieve the EPGData record - try: - epg_obj = EPGData.objects.get(tvg_id=tvg_id) - except EPGData.DoesNotExist: - logger.warning(f"No EPGData record found for tvg_id={tvg_id}") - return # Find only elements for this tvg_id - matched_programmes = [p for p in root.findall('programme') if p.get('channel') == tvg_id] - logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={tvg_id}") + matched_programmes = [p for p in root.findall('programme') if p.get('channel') == epg.tvg_id] + logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={epg.tvg_id}") with transaction.atomic(): for prog in matched_programmes: @@ -147,19 +141,19 @@ def parse_programs_for_tvg_id(file_path, tvg_id): desc = prog.findtext('desc', default='') obj, created = ProgramData.objects.update_or_create( - epg=epg_obj, + epg=epg, start_time=start_time, title=title, defaults={ 'end_time': end_time, 'description': desc, 'sub_title': '', - 'tvg_id': tvg_id, + 'tvg_id': epg.tvg_id, } ) if created: logger.debug(f"Created ProgramData: {title} [{start_time} - {end_time}]") - logger.info(f"Completed program parsing for tvg_id={tvg_id}.") + logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.") def fetch_schedules_direct(source): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 82f2310b..83de4d05 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -235,14 +235,13 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys): logger.error(json.dumps(stream_info)) existing_streams = {s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys())} - logger.info(f"Hashed {len(stream_hashes.keys())} unique streams") for stream_hash, stream_props in stream_hashes.items(): if stream_hash in existing_streams: obj = existing_streams[stream_hash] changed = False for key, value in stream_props.items(): - if getattr(obj, key) == value: + if hasattr(obj, key) and getattr(obj, key) == value: continue changed = True setattr(obj, key, value) diff --git a/apps/output/views.py b/apps/output/views.py index cffc20e1..a5c766a0 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -14,7 +14,7 @@ def generate_m3u(request): for channel in channels: group_title = channel.channel_group.name if channel.channel_group else "Default" tvg_id = channel.tvg_id or "" - tvg_name = channel.tvg_name or channel.name + tvg_name = channel.tvg_id or channel.name tvg_logo = channel.logo_url or "" channel_number = channel.channel_number diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b91bb9b..f1abbe02 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "vite", "version": "0.0.0", "dependencies": { + "@mantine/charts": "^7.17.2", "@mantine/core": "^7.17.2", "@mantine/dates": "^7.17.2", "@mantine/dropzone": "^7.17.2", @@ -29,6 +30,8 @@ "react-draggable": "^4.4.6", "react-pro-sidebar": "^1.1.0", "react-router-dom": "^7.3.0", + "react-window": "^1.8.11", + "recharts": "^2.15.1", "video.js": "^8.21.0", "yup": "^1.6.1", "zustand": "^5.0.3" @@ -1082,6 +1085,19 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", "license": "Apache-2.0" }, + "node_modules/@mantine/charts": { + "version": "7.17.2", + "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-7.17.2.tgz", + "integrity": "sha512-ckB23pIqRjzysUz2EiWZD9AVyf7t0r7o7zfJbl01nzOezFgYq5RGeRoxvpcsfBC+YoSbB/43rjNcXtYhtA7QzA==", + "license": "MIT", + "peerDependencies": { + "@mantine/core": "7.17.2", + "@mantine/hooks": "7.17.2", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x", + "recharts": "^2.13.3" + } + }, "node_modules/@mantine/core": { "version": "7.17.2", "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.2.tgz", @@ -1776,6 +1792,69 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2205,6 +2284,127 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -2228,6 +2428,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2592,6 +2798,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2947,6 +3162,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3215,6 +3439,12 @@ "node": ">= 0.4" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3640,6 +3870,12 @@ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", "license": "MIT" }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/react-number-format": { "version": "5.4.3", "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.3.tgz", @@ -3753,6 +3989,21 @@ "react-dom": ">=18" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -3808,6 +4059,61 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", + "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", @@ -3998,6 +4304,12 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -4145,6 +4457,28 @@ } } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/video.js": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.22.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index de53fcc4..27a7b049 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@mantine/charts": "^7.17.2", "@mantine/core": "^7.17.2", "@mantine/dates": "^7.17.2", "@mantine/dropzone": "^7.17.2", @@ -31,6 +32,8 @@ "react-draggable": "^4.4.6", "react-pro-sidebar": "^1.1.0", "react-router-dom": "^7.3.0", + "react-window": "^1.8.11", + "recharts": "^2.15.1", "video.js": "^8.21.0", "yup": "^1.6.1", "zustand": "^5.0.3" diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index a2cfeb75..1a3f1573 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import useChannelsStore from '../../store/channels'; @@ -25,24 +25,32 @@ import { Divider, Stack, useMantineTheme, + Popover, + ScrollArea, } from '@mantine/core'; import { ListOrdered, SquarePlus, SquareX } from 'lucide-react'; import useEPGsStore from '../../store/epgs'; import { Dropzone } from '@mantine/dropzone'; +import { FixedSizeList as List } from 'react-window'; const Channel = ({ channel = null, isOpen, onClose }) => { const theme = useMantineTheme(); + const listRef = useRef(null); + const channelGroups = useChannelsStore((state) => state.channelGroups); const streams = useStreamsStore((state) => state.streams); const { profiles: streamProfiles } = useStreamProfilesStore(); const { playlists } = usePlaylistsStore(); - const { tvgs } = useEPGsStore(); + const { epgs, tvgs, tvgsById } = useEPGsStore(); const [logoFile, setLogoFile] = useState(null); const [logoPreview, setLogoPreview] = useState(null); const [channelStreams, setChannelStreams] = useState([]); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); + const [epgPopoverOpened, setEpgPopoverOpened] = useState(false); + const [selectedEPG, setSelectedEPG] = useState({}); + const [tvgFilter, setTvgFilter] = useState(''); const addStream = (stream) => { const streamSet = new Set(channelStreams); @@ -74,7 +82,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { channel_group_id: '', stream_profile_id: '0', tvg_id: '', - tvg_name: '', + epg_data_id: '', }, validationSchema: Yup.object({ name: Yup.string().required('Name is required'), @@ -109,27 +117,36 @@ const Channel = ({ channel = null, isOpen, onClose }) => { setLogoFile(null); setLogoPreview(null); setSubmitting(false); + setTvgFilter(''); onClose(); }, }); useEffect(() => { if (channel) { + if (channel.epg_data) { + const epgSource = epgs[channel.epg_data.epg_source]; + setSelectedEPG(`${epgSource.id}`); + } + formik.setValues({ name: channel.name, channel_number: channel.channel_number, - channel_group_id: channel.channel_group?.id, - stream_profile_id: channel.stream_profile_id || '0', + channel_group_id: `${channel.channel_group?.id}`, + stream_profile_id: channel.stream_profile_id + ? `${channel.stream_profile_id}` + : '0', tvg_id: channel.tvg_id, - tvg_name: channel.tvg_name, + epg_data_id: channel.epg_data ? `${channel.epg_data?.id}` : '', }); console.log(channel); setChannelStreams(channel.streams); } else { formik.resetForm(); + setTvgFilter(''); } - }, [channel]); + }, [channel, tvgsById]); // const activeStreamsTable = useMantineReactTable({ // data: channelStreams, @@ -263,6 +280,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => { return <>; } + const filteredTvgs = tvgs + .filter((tvg) => tvg.epg_source == selectedEPG) + .filter((tvg) => tvg.name.toLowerCase().includes(tvgFilter)); + return ( <> { - ({ + value: `${epg.id}`, + label: epg.name, + }))} + size="xs" + mb="xs" + /> + + {/* Filter Input */} + + setTvgFilter(event.currentTarget.value) + } + mb="xs" + size="xs" + /> + + + + + {({ index, style }) => ( +
+ +
+ )} +
+
+ + {
+ setChannelGroupModalOpen(false)} diff --git a/frontend/src/components/forms/Stream.jsx b/frontend/src/components/forms/Stream.jsx index e30ed618..269c8113 100644 --- a/frontend/src/components/forms/Stream.jsx +++ b/frontend/src/components/forms/Stream.jsx @@ -1,5 +1,5 @@ // Modal.js -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import API from '../../api'; diff --git a/frontend/src/components/sidebar.css b/frontend/src/components/sidebar.css index f24edd4a..4626db4c 100644 --- a/frontend/src/components/sidebar.css +++ b/frontend/src/components/sidebar.css @@ -6,7 +6,6 @@ gap: 12px; padding: 5px 8px !important; border-radius: 6px; - color: #D4D4D8; /* Default color when not active */ background-color: transparent; /* Default background when not active */ border: 1px solid transparent; transition: all 0.3s ease; diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index c3a9a17f..021ce012 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -23,7 +23,7 @@ const EPGsTable = () => { const [epgModalOpen, setEPGModalOpen] = useState(false); const [rowSelection, setRowSelection] = useState([]); - const epgs = useEPGsStore((state) => state.epgs); + const { epgs } = useEPGsStore(); const theme = useMantineTheme(); @@ -93,7 +93,7 @@ const EPGsTable = () => { const table = useMantineReactTable({ ...TableHelper.defaultProperties, columns, - data: epgs, + data: Object.values(epgs), enablePagination: false, enableRowVirtualization: true, enableRowSelection: false, diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 3add80e6..f937a481 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -116,7 +116,9 @@ const Example = () => { }; const deletePlaylist = async (id) => { + setIsLoading(true); await API.deletePlaylist(id); + setIsLoading(false); }; const closeModal = (newPlaylist = null) => { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 2b8c8f3c..680d4c7d 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -142,7 +142,10 @@ const StreamsTable = ({}) => { }, { header: 'Group', - accessorFn: (row) => channelGroups[row.channel_group].name, + accessorFn: (row) => + channelGroups[row.channel_group] + ? channelGroups[row.channel_group].name + : '', size: 100, Header: ({ column }) => ( diff --git a/frontend/src/helpers/table.jsx b/frontend/src/helpers/table.jsx index 208a4100..7f7205c4 100644 --- a/frontend/src/helpers/table.jsx +++ b/frontend/src/helpers/table.jsx @@ -45,7 +45,7 @@ export default { paddingLeft: 10, paddingRight: 10, borderColor: '#444', - color: '#E0E0E0', + // color: '#E0E0E0', fontSize: '0.85rem', }, }, @@ -56,7 +56,7 @@ export default { paddingTop: 2, paddingBottom: 2, fontWeight: 'normal', - color: '#CFCFCF', + // color: '#CFCFCF', backgroundColor: '#383A3F', borderColor: '#444', // fontWeight: 600, diff --git a/frontend/src/mantineTheme.jsx b/frontend/src/mantineTheme.jsx index 280320c8..6c3611c7 100644 --- a/frontend/src/mantineTheme.jsx +++ b/frontend/src/mantineTheme.jsx @@ -1,6 +1,19 @@ import { createTheme, MantineProvider, rem } from '@mantine/core'; const theme = createTheme({ + globalStyles: (theme) => ({ + ':root': { + '--mantine-color-text': '#fff', + '--mantine-color-body': '#27272A', + }, + ':root[data-mantine-color-scheme="dark"]': { + '--mantine-color-text': '#fff', + }, + ':root[data-mantine-color-scheme="light"]': { + '--mantine-color-text': '#fff', + }, + }), + tailwind: { red: [ 'oklch(0.971 0.013 17.38)', diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 67dad742..6c7f61f8 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -462,7 +462,11 @@ export default function TVChannelGuide({ startDate, endDate }) { {now.isAfter(dayjs(selectedProgram.start_time)) && now.isBefore(dayjs(selectedProgram.end_time)) && ( - diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index d22eac46..62675ff6 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -1,17 +1,18 @@ -import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import { ActionIcon, Box, Card, Center, + Container, Flex, - Grid, Group, SimpleGrid, Stack, Text, Title, Tooltip, + useMantineTheme, } from '@mantine/core'; import { MantineReactTable, useMantineReactTable } from 'mantine-react-table'; import { TableHelper } from '../helpers'; @@ -19,24 +20,41 @@ import API from '../api'; import useChannelsStore from '../store/channels'; import logo from '../images/logo.png'; import { - Tv2, - ScreenShare, - Scroll, - SquareMinus, - CirclePlay, - SquarePen, - Binary, - ArrowDown01, + Gauge, + HardDriveDownload, + HardDriveUpload, SquareX, Timer, + Users, + Video, } from 'lucide-react'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; +import { Sparkline } from '@mantine/charts'; +import useStreamProfilesStore from '../store/streamProfiles'; dayjs.extend(duration); dayjs.extend(relativeTime); +function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; +} + +function formatSpeed(bytes) { + if (bytes === 0) return '0 Bytes'; + + const sizes = ['bps', 'Kbps', 'Mbps', 'Gbps']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; +} + const getStartDate = (uptime) => { // Get the current date and time const currentDate = new Date(); @@ -56,8 +74,12 @@ const getStartDate = (uptime) => { }; const ChannelsPage = () => { + const theme = useMantineTheme(); + const { channels, channelsByUUID, stats: channelStats } = useChannelsStore(); - const [activeChannels, setActiveChannels] = useState([]); + const { profiles: streamProfiles } = useStreamProfilesStore(); + + const [activeChannels, setActiveChannels] = useState({}); const [clients, setClients] = useState([]); const channelsColumns = useMemo( @@ -148,35 +170,35 @@ const ChannelsPage = () => { await API.stopClient(channelId, clientId); }; - const channelsTable = useMantineReactTable({ - ...TableHelper.defaultProperties, - renderTopToolbar: false, - columns: channelsColumns, - data: activeChannels, - enableRowActions: true, - mantineTableBodyCellProps: { - style: { - padding: 4, - borderColor: '#444', - color: '#E0E0E0', - fontSize: '0.85rem', - }, - }, - renderRowActions: ({ row }) => ( - -
- stopChannel(row.original.uuid)} - > - - -
-
- ), - }); + // const channelsTable = useMantineReactTable({ + // ...TableHelper.defaultProperties, + // renderTopToolbar: false, + // columns: channelsColumns, + // data: activeChannels, + // enableRowActions: true, + // mantineTableBodyCellProps: { + // style: { + // padding: 4, + // borderColor: '#444', + // color: '#E0E0E0', + // fontSize: '0.85rem', + // }, + // }, + // renderRowActions: ({ row }) => ( + // + //
+ // stopChannel(row.original.uuid)} + // > + // + // + //
+ //
+ // ), + // }); const clientsTable = useMantineReactTable({ ...TableHelper.defaultProperties, @@ -184,19 +206,19 @@ const ChannelsPage = () => { data: clients, columns: useMemo( () => [ - { - header: 'User-Agent', - accessorKey: 'user_agent', - size: 250, - mantineTableBodyCellProps: { - style: { - whiteSpace: 'nowrap', - maxWidth: 400, - paddingLeft: 10, - paddingRight: 10, - }, - }, - }, + // { + // header: 'User-Agent', + // accessorKey: 'user_agent', + // size: 250, + // mantineTableBodyCellProps: { + // style: { + // whiteSpace: 'nowrap', + // maxWidth: 400, + // paddingLeft: 10, + // paddingRight: 10, + // }, + // }, + // }, { header: 'IP Address', accessorKey: 'ip_address', @@ -209,7 +231,7 @@ const ChannelsPage = () => { style: { padding: 4, borderColor: '#444', - color: '#E0E0E0', + // color: '#E0E0E0', fontSize: '0.85rem', }, }, @@ -236,16 +258,73 @@ const ChannelsPage = () => { overflowY: 'auto', }, }, + renderDetailPanel: ({ row }) => {row.original.user_agent}, + mantineExpandButtonProps: ({ row, table }) => ({ + size: 'xs', + style: { + transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)', + transition: 'transform 0.2s', + }, + }), + enableExpandAll: false, + displayColumnDefOptions: { + 'mrt-row-expand': { + size: 15, + header: '', + // mantineTableHeadCellProps: { + // style: { + // padding: 0, + // minWidth: '20px !important', + // }, + // }, + // mantineTableBodyCellProps: { + // style: { + // padding: 0, + // minWidth: '20px !important', + // }, + // }, + }, + 'mrt-row-actions': { + size: 74, + }, + }, }); useEffect(() => { - const stats = channelStats.channels.map((ch) => ({ - ...ch, - ...channels[channelsByUUID[ch.channel_id]], - })); + if (!channelStats.channels) { + return; + } + + const stats = channelStats.channels.reduce((acc, ch) => { + let bitrates = []; + if (activeChannels[ch.channel_id]) { + bitrates = activeChannels[ch.channel_id].bitrates; + const bitrate = + ch.total_bytes - activeChannels[ch.channel_id].total_bytes; + if (bitrate > 0) { + bitrates.push(bitrate); + } + + if (bitrates.length > 15) { + bitrates = bitrates.slice(1); + } + } + + acc[ch.channel_id] = { + ...ch, + ...channels[channelsByUUID[ch.channel_id]], + bitrates, + stream_profile: streamProfiles.find( + (profile) => profile.id == parseInt(ch.profile) + ), + }; + + return acc; + }, {}); + setActiveChannels(stats); - const clientStats = stats.reduce((acc, ch) => { + const clientStats = Object.values(stats).reduce((acc, ch) => { return acc.concat( ch.clients.map((client) => ({ ...client, @@ -257,19 +336,25 @@ const ChannelsPage = () => { }, [channelStats]); return ( - - {activeChannels.map((channel) => ( - - - - - {channel.name} - channel logo - + + {Object.values(activeChannels).map((channel) => ( + + + + channel logo @@ -288,19 +373,39 @@ const ChannelsPage = () => { + + + + + {channel.name} + + + + - - - Clients - {channel.client_count} - - - + + + + {formatSpeed(channel.bitrates.at(-1))} + + + Avg: {channel.avg_bitrate} + + + + {formatBytes(channel.total_bytes)} + + + + + {channel.client_count} + + + + ))} diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx index c749c6bb..45257c0c 100644 --- a/frontend/src/store/epgs.jsx +++ b/frontend/src/store/epgs.jsx @@ -2,8 +2,9 @@ import { create } from 'zustand'; import api from '../api'; const useEPGsStore = create((set) => ({ - epgs: [], + epgs: {}, tvgs: [], + tvgsById: {}, isLoading: false, error: null, @@ -11,7 +12,13 @@ const useEPGsStore = create((set) => ({ set({ isLoading: true, error: null }); try { const epgs = await api.getEPGs(); - set({ epgs: epgs, isLoading: false }); + set({ + epgs: epgs.reduce((acc, epg) => { + acc[epg.id] = epg; + return acc; + }, {}), + isLoading: false, + }); } catch (error) { console.error('Failed to fetch epgs:', error); set({ error: 'Failed to load epgs.', isLoading: false }); @@ -22,21 +29,30 @@ const useEPGsStore = create((set) => ({ set({ isLoading: true, error: null }); try { const tvgs = await api.getEPGData(); - set({ tvgs: tvgs, isLoading: false }); + set({ + tvgs: tvgs, + tvgsById: tvgs.reduce((acc, tvg) => { + acc[tvg.id] = tvg; + return acc; + }, {}), + isLoading: false, + }); } catch (error) { console.error('Failed to fetch tvgs:', error); set({ error: 'Failed to load tvgs.', isLoading: false }); } }, - addEPG: (newPlaylist) => + addEPG: (epg) => set((state) => ({ - epgs: [...state.epgs, newPlaylist], + epgs: { ...state.epgs, [epg.id]: epg }, })), removeEPGs: (epgIds) => set((state) => ({ - epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)), + epgs: Object.fromEntries( + Object.entries(state.epgs).filter(([id]) => !epgIds.includes(id)) + ), })), })); From 323cc7b52ccf59bdc01f2c6ffe29d460c286666a Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 26 Mar 2025 13:35:41 -0400 Subject: [PATCH 006/239] fixed removal logic of epg store --- frontend/src/components/tables/EPGsTable.jsx | 1 + frontend/src/store/epgs.jsx | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 021ce012..20e4e92c 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -24,6 +24,7 @@ const EPGsTable = () => { const [rowSelection, setRowSelection] = useState([]); const { epgs } = useEPGsStore(); + console.log(epgs); const theme = useMantineTheme(); diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx index 45257c0c..afd24fd5 100644 --- a/frontend/src/store/epgs.jsx +++ b/frontend/src/store/epgs.jsx @@ -49,11 +49,14 @@ const useEPGsStore = create((set) => ({ })), removeEPGs: (epgIds) => - set((state) => ({ - epgs: Object.fromEntries( - Object.entries(state.epgs).filter(([id]) => !epgIds.includes(id)) - ), - })), + set((state) => { + const updatedEPGs = { ...state.epgs }; + for (const id of epgIds) { + delete updatedEPGs[id]; + } + + return { epgs: updatedEPGs }; + }), })); export default useEPGsStore; From 16138592b6829335c67e6b70b0db3cf1a57f83d7 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 26 Mar 2025 16:52:47 -0400 Subject: [PATCH 007/239] epg processing optimizations, websocket success notifications, added allotment to channels page for resizing --- apps/channels/models.py | 63 +++++++++++++++++++ apps/channels/tasks.py | 13 ++++ apps/epg/models.py | 3 + apps/epg/tasks.py | 49 ++++++++++----- apps/m3u/tasks.py | 1 - frontend/src/App.jsx | 1 + frontend/src/WebSocket.jsx | 20 +++++- frontend/src/components/forms/Channel.jsx | 1 + frontend/src/components/forms/Stream.jsx | 2 + .../src/components/tables/ChannelsTable.jsx | 4 +- .../src/components/tables/StreamsTable.jsx | 2 +- frontend/src/index.css | 50 +++++++++++++-- frontend/src/pages/Channels.jsx | 31 ++++++--- 13 files changed, 208 insertions(+), 32 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 7f299639..04a9fd16 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -133,6 +133,69 @@ class Stream(models.Model): stream = cls.objects.create(**fields_to_update) return stream, True # True means it was created + def get_stream(self): + """ + Finds an available stream for the requested channel and returns the selected stream and profile. + """ + + profile_id = redis_client.get(f"stream_profile:{stream_id}") + if profile_id: + profile_id = int(profile_id) + return profile_id + + # Retrieve the M3U account associated with the stream. + m3u_account = self.m3u_account + m3u_profiles = m3u_account.profiles.all() + default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) + profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default] + + for profile in profiles: + logger.info(profile) + # Skip inactive profiles + if profile.is_active == False: + continue + + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + # Check if profile has available slots (or unlimited connections) + if profile.max_streams == 0 or current_connections < profile.max_streams: + # Start a new stream + redis_client.set(f"channel_stream:{self.id}", self.id) + redis_client.set(f"stream_profile:{self.id}", profile.id) # Store only the matched profile + + # Increment connection count for profiles with limits + if profile.max_streams > 0: + redis_client.incr(profile_connections_key) + + return profile.id # Return newly assigned stream and matched profile + + # 4. No available streams + return None + + def release_stream(self): + """ + Called when a stream is finished to release the lock. + """ + stream_id = self.id + # Get the matched profile for cleanup + profile_id = redis_client.get(f"stream_profile:{stream_id}") + if not profile_id: + logger.debug("Invalid profile ID pulled from stream index") + return + + redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association + + profile_id = int(profile_id) + logger.debug(f"Found profile ID {profile_id} associated with stream {stream_id}") + + profile_connections_key = f"profile_connections:{profile_id}" + + # Only decrement if the profile had a max_connections limit + current_count = int(redis_client.get(profile_connections_key) or 0) + if current_count > 0: + redis_client.decr(profile_connections_key) + class ChannelManager(models.Manager): def active(self): return self.all() diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index c9b89c85..ee2eed79 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -14,6 +14,9 @@ from apps.epg.models import EPGData, EPGSource from core.models import CoreSettings from apps.epg.tasks import parse_programs_for_tvg_id # <-- we import our new helper +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + logger = logging.getLogger(__name__) # Load the sentence-transformers model once at the module level @@ -220,4 +223,14 @@ def match_epg_channels(): logger.info("No new channels were matched.") logger.info("Finished EPG matching logic.") + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": {"success": True, "type": "epg_match"} + } + ) + return f"Done. Matched {total_matched} channel(s)." diff --git a/apps/epg/models.py b/apps/epg/models.py index 83dc7aad..e5bac55f 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -29,6 +29,9 @@ class EPGData(models.Model): related_name="epgs", ) + class Meta: + unique_together = ('tvg_id', 'epg_source') + def __str__(self): return f"EPG Data for {self.name}" diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index ed0faf92..c5045503 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -14,6 +14,8 @@ from django.db import transaction from django.utils import timezone from apps.channels.models import Channel +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer from .models import EPGSource, EPGData, ProgramData @@ -78,6 +80,7 @@ def fetch_xmltv(source): def parse_channels_only(source, file_path): logger.info(f"Parsing channels from EPG file: {file_path}") + existing_epgs = {e.tvg_id: e for e in EPGData.objects.filter(epg_source=source)} # Read entire file (decompress if .gz) if file_path.endswith('.gz'): @@ -91,26 +94,42 @@ def parse_channels_only(source, file_path): root = ET.fromstring(xml_data) channels = root.findall('channel') + epgs_to_create = [] + epgs_to_update = [] + logger.info(f"Found {len(channels)} entries in {file_path}") - with transaction.atomic(): - for channel_elem in channels: - tvg_id = channel_elem.get('id', '').strip() - if not tvg_id: - continue # skip blank/invalid IDs + for channel_elem in channels: + tvg_id = channel_elem.get('id', '').strip() + if not tvg_id: + continue # skip blank/invalid IDs - display_name = channel_elem.findtext('display-name', default=tvg_id).strip() + display_name = channel_elem.findtext('display-name', default=tvg_id).strip() - epg_obj, created = EPGData.objects.get_or_create( + if tvg_id in existing_epgs: + epg_obj = existing_epgs[tvg_id] + if epg_obj.name != display_name: + epg_obj.name = display_name + epgs_to_update.append(epg_obj) + else: + epgs_to_create.append(EPGData( tvg_id=tvg_id, + name=display_name, epg_source=source, - defaults={'name': display_name}, - ) - if not created: - # Optionally update if new name is different - if epg_obj.name != display_name: - epg_obj.name = display_name - epg_obj.save() - logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}") + )) + + if epgs_to_create: + EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True) + if epgs_to_update: + EPGData.objects.bulk_update(epgs_to_update, ["name"]) + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": {"success": True, "type": "epg_channels"} + } + ) logger.info("Finished parsing channel info.") diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 83de4d05..605cd7fe 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -15,7 +15,6 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.utils import timezone import time -from channels.layers import get_channel_layer import json from core.utils import redis_client from core.models import CoreSettings diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c769dcc6..2386efb5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -28,6 +28,7 @@ import mantineTheme from './mantineTheme'; import API from './api'; import { Notifications } from '@mantine/notifications'; import M3URefreshNotification from './components/M3URefreshNotification'; +import 'allotment/dist/style.css'; const drawerWidth = 240; const miniDrawerWidth = 60; diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index bd9f0e5d..88cb7b7c 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -18,7 +18,8 @@ export const WebsocketProvider = ({ children }) => { const [val, setVal] = useState(null); const { fetchStreams } = useStreamsStore(); - const { setChannelStats, fetchChannelGroups } = useChannelsStore(); + const { fetchChannels, setChannelStats, fetchChannelGroups } = + useChannelsStore(); const { fetchPlaylists, setRefreshProgress } = usePlaylistsStore(); const { fetchEPGData } = useEPGsStore(); @@ -78,6 +79,23 @@ export const WebsocketProvider = ({ children }) => { setChannelStats(JSON.parse(event.data.stats)); break; + case 'epg_channels': + notifications.show({ + message: 'EPG channels updated!', + color: 'green.5', + }); + fetchEPGData(); + break; + + case 'epg_match': + notifications.show({ + message: 'EPG match is complete!', + color: 'green.5', + }); + fetchChannels(); + fetchEPGData(); + break; + default: console.error(`Unknown websocket event type: ${event.type}`); break; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 1a3f1573..889ecd0f 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -317,6 +317,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { name="channel_group_id" label="Channel Group" value={formik.values.channel_group_id} + searchable onChange={(value) => { formik.setFieldValue('channel_group_id', value); // Update Formik's state with the new value }} diff --git a/frontend/src/components/forms/Stream.jsx b/frontend/src/components/forms/Stream.jsx index 269c8113..aee3438b 100644 --- a/frontend/src/components/forms/Stream.jsx +++ b/frontend/src/components/forms/Stream.jsx @@ -79,6 +79,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => { id="channel_group" name="channel_group" label="Group" + searchable value={formik.values.channel_group} onChange={(value) => { formik.setFieldValue('channel_group', value); // Update Formik's state with the new value @@ -95,6 +96,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => { name="stream_profile_id" label="Stream Profile" placeholder="Optional" + searchable value={formik.values.stream_profile_id} onChange={(value) => { formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 509bea8b..d129e737 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -561,7 +561,7 @@ const ChannelsTable = ({}) => { ), mantineTableContainerProps: { style: { - height: 'calc(100vh - 127px)', + height: 'calc(100vh - 110px)', overflowY: 'auto', }, }, @@ -710,7 +710,7 @@ const ChannelsTable = ({}) => { {/* Paper container: contains top toolbar and table (or ghost state) */} diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 680d4c7d..8622752c 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -571,7 +571,7 @@ const StreamsTable = ({}) => { ), mantineTableContainerProps: { style: { - height: 'calc(100vh - 167px)', + height: 'calc(100vh - 150px)', overflowY: 'auto', }, }, diff --git a/frontend/src/index.css b/frontend/src/index.css index 25abf33b..0b6ca8ea 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,8 @@ /* frontend/src/index.css */ +:root { + --separator-border: transparent !important; /* Override Allotment's default border */ + --sash-hover-size: 3px !important; +} body { margin: 0; @@ -32,7 +36,7 @@ code { @supports (-moz-appearance: none) { input[readonly][aria-haspopup] { - pointer-events: auto !important; + pointer-events: auto !important; } } @@ -41,7 +45,45 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab } .table-input-header input::placeholder { - color: rgb(207,207,207); - font-weight: normal; - font-size: 14px; + color: rgb(207,207,207); + font-weight: normal; + font-size: 14px; +} + +/* Ensure Allotment uses its default layout */ +.split-view { + width: 100%; + height: 100%; +} + +/* Styling for the sash (splitter) */ +.sash.sash-vertical { + position: relative; + width: 4px; /* Thin invisible divider */ + background: transparent; +} + +/* Create a short vertical bar */ +.sash.sash-vertical::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 6px; /* Width of the short bar */ + height: 50px; /* Short bar length */ + background: rgba(255, 255, 255, 0.2); /* Light color similar to your screenshot */ + border-radius: 4px; +} + +/* Optional: Highlight when hovering */ +.sash.sash-vertical:hover::before { + background: rgba(255, 255, 255, 0.4); +} + +/* Tables should fill available space */ +.split-view-view { + width: 100%; + height: 100%; + overflow: auto; } diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index c5a23fbe..e5ce7170 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -2,17 +2,32 @@ import React, { useState } from 'react'; import ChannelsTable from '../components/tables/ChannelsTable'; import StreamsTable from '../components/tables/StreamsTable'; import { Box, Grid } from '@mantine/core'; +import { Allotment } from 'allotment'; const ChannelsPage = () => { return ( - - - - - - - - +
+ +
+ +
+
+ +
+
+
); }; From 91a85020c35db9b86f9f0858d69372502424a92b Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 27 Mar 2025 09:26:04 -0400 Subject: [PATCH 008/239] modifications to allow previewing of a raw stream --- apps/channels/models.py | 15 +++++++++++---- apps/channels/serializers.py | 3 ++- apps/proxy/ts_proxy/server.py | 18 +++++++++++++----- .../ts_proxy/services/channel_service.py | 7 +++++-- apps/proxy/ts_proxy/stream_manager.py | 6 ++---- apps/proxy/ts_proxy/url_utils.py | 19 +++++++++++++++++-- apps/proxy/ts_proxy/views.py | 7 ++++--- 7 files changed, 54 insertions(+), 21 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 04a9fd16..ceeae01e 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -133,15 +133,21 @@ class Stream(models.Model): stream = cls.objects.create(**fields_to_update) return stream, True # True means it was created + # @TODO: honor stream's stream profile + def get_stream_profile(self): + stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id()) + + return stream_profile + def get_stream(self): """ Finds an available stream for the requested channel and returns the selected stream and profile. """ - profile_id = redis_client.get(f"stream_profile:{stream_id}") + profile_id = redis_client.get(f"stream_profile:{self.id}") if profile_id: profile_id = int(profile_id) - return profile_id + return self.id, profile_id # Retrieve the M3U account associated with the stream. m3u_account = self.m3u_account @@ -168,10 +174,10 @@ class Stream(models.Model): if profile.max_streams > 0: redis_client.incr(profile_connections_key) - return profile.id # Return newly assigned stream and matched profile + return self.id, profile.id # Return newly assigned stream and matched profile # 4. No available streams - return None + return None, None def release_stream(self): """ @@ -260,6 +266,7 @@ class Channel(models.Model): def __str__(self): return f"{self.channel_number} - {self.name}" + # @TODO: honor stream's stream profile def get_stream_profile(self): stream_profile = self.stream_profile if not stream_profile: diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 65843dea..9f31eea2 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -14,7 +14,7 @@ class StreamSerializer(serializers.ModelSerializer): allow_null=True, required=False ) - read_only_fields = ['is_custom', 'm3u_account'] + read_only_fields = ['is_custom', 'm3u_account', 'stream_hash'] class Meta: model = Stream @@ -31,6 +31,7 @@ class StreamSerializer(serializers.ModelSerializer): 'stream_profile_id', 'is_custom', 'channel_group', + 'stream_hash', ] def get_fields(self): diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index 2dd923fd..282c5a74 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -17,7 +17,7 @@ import os import json from typing import Dict, Optional, Set from apps.proxy.config import TSConfig as Config -from apps.channels.models import Channel +from apps.channels.models import Channel, Stream from core.utils import redis_client as global_redis_client, redis_pubsub_client as global_redis_pubsub_client # Import both global Redis clients from redis.exceptions import ConnectionError, TimeoutError from .stream_manager import StreamManager @@ -740,12 +740,16 @@ class ProxyServer: # Force release resources in the Channel model try: - from apps.channels.models import Channel channel = Channel.objects.get(uuid=channel_id) channel.release_stream() logger.info(f"Released stream allocation for zombie channel {channel_id}") except Exception as e: - logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}") + try: + stream = Stream.objects.get(stream_hash=channel_id) + stream.release_stream() + logger.info(f"Released stream allocation for zombie channel {channel_id}") + except Exception as e: + logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}") return True except Exception as e: @@ -1067,8 +1071,12 @@ class ProxyServer: def _clean_redis_keys(self, channel_id): """Clean up all Redis keys for a channel more efficiently""" # Release the channel, stream, and profile keys from the channel - channel = Channel.objects.get(uuid=channel_id) - channel.release_stream() + try: + channel = Channel.objects.get(uuid=channel_id) + channel.release_stream() + except: + stream = Stream.objects.get(stream_hash=channel_id) + stream.release_stream() if not self.redis_client: return 0 diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 210e4b0f..d10d5fed 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -248,8 +248,11 @@ class ChannelService: logger.info(f"Released channel {channel_id} stream allocation") model_released = True except Channel.DoesNotExist: - logger.warning(f"Could not find Channel model for UUID {channel_id}") - model_released = False + logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash") + stream = Stream.objects.get(stream_hash=channel_id) + stream.release_stream() + logger.info(f"Released stream {channel_id} stream allocation") + model_released = True except Exception as e: logger.error(f"Error releasing channel stream: {e}") model_released = False diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index db0d6bb9..d2247135 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -17,7 +17,7 @@ from .utils import detect_stream_type, get_logger from .redis_keys import RedisKeys from .constants import ChannelState, EventType, StreamType, TS_PACKET_SIZE from .config_helper import ConfigHelper -from .url_utils import get_alternate_streams, get_stream_info_for_switch +from .url_utils import get_alternate_streams, get_stream_info_for_switch, get_stream_object logger = get_logger() @@ -304,7 +304,7 @@ class StreamManager: """Establish a connection using transcoding""" try: logger.debug(f"Building transcode command for channel {self.channel_id}") - channel = get_object_or_404(Channel, uuid=self.channel_id) + channel = get_stream_object(self.channel_id) # Use FFmpeg specifically for HLS streams if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg: @@ -908,5 +908,3 @@ class StreamManager: except Exception as e: logger.error(f"Error trying next stream for channel {self.channel_id}: {e}", exc_info=True) return False - - diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index a0ed4476..927ea7e8 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -10,9 +10,20 @@ from apps.channels.models import Channel, Stream from apps.m3u.models import M3UAccount, M3UAccountProfile from core.models import UserAgent, CoreSettings from .utils import get_logger +from uuid import UUID logger = get_logger() +def get_stream_object(id: str): + try: + uuid_obj = UUID(id, version=4) + logger.info(f"Fetching channel ID {id}") + return get_object_or_404(Channel, uuid=id) + except: + # UUID check failed, assume stream hash + logger.info(f"Fetching stream hash {id}") + return get_object_or_404(Stream, stream_hash=id) + def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]: """ Generate the appropriate stream URL for a channel based on its profile settings. @@ -24,7 +35,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]: Tuple[str, str, bool]: (stream_url, user_agent, transcode_flag) """ # Get channel and related objects - channel = get_object_or_404(Channel, uuid=channel_id) + channel = get_stream_object(channel_id) stream_id, profile_id = channel.get_stream() if stream_id is None or profile_id is None: @@ -177,7 +188,11 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No """ try: # Get channel object - channel = get_object_or_404(Channel, uuid=channel_id) + channel = get_stream_object(channel_id) + if isinstance(channel, Stream): + logger.error(f"Stream is not a channel") + return [] + logger.debug(f"Looking for alternate streams for channel {channel_id}, current stream ID: {current_stream_id}") # Get all assigned streams for this channel diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index fe87e677..3ba89123 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -21,8 +21,9 @@ from rest_framework.permissions import IsAuthenticated from .constants import ChannelState, EventType, StreamType from .config_helper import ConfigHelper from .services.channel_service import ChannelService -from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch +from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch, get_stream_object from .utils import get_logger +from uuid import UUID logger = get_logger() @@ -30,9 +31,9 @@ logger = get_logger() @api_view(['GET']) def stream_ts(request, channel_id): """Stream TS data to client with immediate response and keep-alive packets during initialization""" + channel = get_stream_object(channel_id) + client_user_agent = None - logger.info(f"Fetching channel ID {channel_id}") - channel = get_object_or_404(Channel, uuid=channel_id) try: # Generate a unique client ID From d7f96005b88156640cc7be24f3ea950e8ae94b12 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 27 Mar 2025 09:27:45 -0400 Subject: [PATCH 009/239] stream preview functionality --- .../src/components/tables/StreamsTable.jsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 8622752c..19b15aa4 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -44,6 +44,8 @@ import { IconSquarePlus, } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; +import useSettingsStore from '../../store/settings'; +import useVideoStore from '../../store/useVideoStore'; const StreamsTable = ({}) => { const theme = useMantineTheme(); @@ -91,6 +93,10 @@ const StreamsTable = ({}) => { const channelSelectionStreams = useChannelsStore( (state) => state.channels[state.channelsPageSelection[0]?.id]?.streams ); + const { + environment: { env_mode }, + } = useSettingsStore(); + const { showVideo } = useVideoStore(); const isMoreActionsOpen = Boolean(moreActionsAnchorEl); @@ -432,6 +438,14 @@ const StreamsTable = ({}) => { setPagination(updater); }; + function handleWatchStream(streamHash) { + let vidUrl = `/proxy/ts/stream/${streamHash}`; + if (env_mode == 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + showVideo(vidUrl); + } + const table = useMantineReactTable({ ...TableHelper.defaultProperties, columns, @@ -565,6 +579,11 @@ const StreamsTable = ({}) => { > Delete Stream + handleWatchStream(row.original.stream_hash)} + > + Preview Stream + From 1fcedab1abe1b492bcf4b3bdcb3a03d3e30f0b34 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 27 Mar 2025 09:49:51 -0500 Subject: [PATCH 010/239] Remote debugging initial commit. --- .dockerignore | 1 + .gitignore | 9 +++- docker/docker-compose.debug.yml | 19 +++++++ docker/entrypoint.sh | 18 ++++--- docker/init/99-init-dev.sh | 8 ++- docker/uwsgi.debug.ini | 81 +++++++++++++++++++++++++++++ scripts/debug_wrapper.py | 90 +++++++++++++++++++++++++++++++++ scripts/standalone_debug.py | 36 +++++++++++++ 8 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 docker/docker-compose.debug.yml create mode 100644 docker/uwsgi.debug.ini create mode 100644 scripts/debug_wrapper.py create mode 100644 scripts/standalone_debug.py diff --git a/.dockerignore b/.dockerignore index e0cc78f0..5073af60 100755 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ **/__pycache__ **/.venv +**/venv **/.classpath **/.dockerignore **/.env diff --git a/.gitignore b/.gitignore index b6631ac0..1dd591d4 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store **/__pycache__/ **/.vscode/ +**/venv *.pyc node_modules/ .history/ @@ -10,4 +11,10 @@ docker/Dockerfile DEV static/ data/ .next -next-env.d.ts \ No newline at end of file +next-env.d.ts +media/ +celerybeat-schedule* +dump.rdb +debugpy* +uwsgi.sock +package-lock.json \ No newline at end of file diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml new file mode 100644 index 00000000..40a87bfe --- /dev/null +++ b/docker/docker-compose.debug.yml @@ -0,0 +1,19 @@ +services: + dispatcharr: + # build: + # context: .. + # dockerfile: docker/Dockerfile.dev + image: dispatcharr/dispatcharr + container_name: dispatcharr_debug + ports: + - 5656:5656 # API port + - 9193:9191 # Web UI port + - 8001:8001 # Socket port + - 5678:5678 # Debugging port + volumes: + - ../:/app + environment: + - DISPATCHARR_ENV=dev + - DISPATCHARR_DEBUG=true + - REDIS_HOST=localhost + - CELERY_BROKER_URL=redis://localhost:6379/0 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index d04edcb0..1e1cb22f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -49,6 +49,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then echo "export POSTGRES_HOST=$POSTGRES_HOST" >> /etc/profile.d/dispatcharr.sh echo "export POSTGRES_PORT=$POSTGRES_PORT" >> /etc/profile.d/dispatcharr.sh echo "export DISPATCHARR_ENV=$DISPATCHARR_ENV" >> /etc/profile.d/dispatcharr.sh + echo "export DISPATCHARR_DEBUG=$DISPATCHARR_DEBUG" >> /etc/profile.d/dispatcharr.sh echo "export REDIS_HOST=$REDIS_HOST" >> /etc/profile.d/dispatcharr.sh echo "export REDIS_DB=$REDIS_DB" >> /etc/profile.d/dispatcharr.sh fi @@ -75,8 +76,18 @@ postgres_pid=$(su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data stat echo "✅ Postgres started with PID $postgres_pid" pids+=("$postgres_pid") -if [ "$DISPATCHARR_ENV" = "dev" ]; then + +uwsgi_file="/app/docker/uwsgi.ini" +if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then + uwsgi_file="/app/docker/uwsgi.dev.ini" +elif [ "$DISPATCHARR_DEBUG" = "true" ]; then + uwsgi_file="/app/docker/uwsgi.debug.ini" +fi + + +if [[ "$DISPATCHARR_ENV" = "dev" ]]; then . /app/docker/init/99-init-dev.sh + else echo "🚀 Starting nginx..." nginx @@ -85,10 +96,6 @@ else pids+=("$nginx_pid") fi -uwsgi_file="/app/docker/uwsgi.ini" -if [ "$DISPATCHARR_ENV" = "dev" ]; then - uwsgi_file="/app/docker/uwsgi.dev.ini" -fi echo "🚀 Starting uwsgi..." su - $POSTGRES_USER -c "cd /app && uwsgi --ini $uwsgi_file &" @@ -97,7 +104,6 @@ echo "✅ uwsgi started with PID $uwsgi_pid" pids+=("$uwsgi_pid") - cd /app python manage.py migrate --noinput python manage.py collectstatic --noinput diff --git a/docker/init/99-init-dev.sh b/docker/init/99-init-dev.sh index 3e9ecc0a..861c8307 100644 --- a/docker/init/99-init-dev.sh +++ b/docker/init/99-init-dev.sh @@ -15,5 +15,11 @@ fi # Install frontend dependencies cd /app/frontend && npm install - +# Install pip dependencies cd /app && pip install -r requirements.txt + +# Install debugpy for remote debugging +if [ "$DISPATCHARR_DEBUG" = "true" ]; then + echo "=== setting up debugpy ===" + pip install debugpy +fi diff --git a/docker/uwsgi.debug.ini b/docker/uwsgi.debug.ini new file mode 100644 index 00000000..f8df7bdc --- /dev/null +++ b/docker/uwsgi.debug.ini @@ -0,0 +1,81 @@ +[uwsgi] +; exec-before = python manage.py collectstatic --noinput +; exec-before = python manage.py migrate --noinput + +; First run Redis availability check script once +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 -l info +attach-daemon = celery -A dispatcharr beat -l info +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application +attach-daemon = cd /app/frontend && npm run dev + +# Core settings +chdir = /app +module = scripts.debug_wrapper:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true +static-map = /static=/app/static + +# Worker configuration +workers = 1 +threads = 4 +enable-threads = true +lazy-apps = true + +# HTTP server +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 +http-timeout = 600 + +# Async mode (use gevent for high concurrency) +gevent = 100 +async = 100 + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false + +; Longer timeouts for debugging sessions +harakiri = 3600 +socket-timeout = 3600 +http-timeout = 3600 + + +# Ignore unknown options +ignore-sigpipe = true +ignore-write-errors = true +disable-write-exception = true + +# Explicitly disable for-server option that confuses debugpy +for-server = false + +# Debugging settings +py-autoreload = 1 +honour-stdin = true + +# Environment variables +env = PYTHONPATH=/app +env = PYTHONUNBUFFERED=1 +env = PYDEVD_DISABLE_FILE_VALIDATION=1 +env = PYTHONUTF8=1 +env = PYTHONXOPT=-Xfrozen_modules=off +env = PYDEVD_DEBUG=1 +env = DEBUGPY_LOG_DIR=/app/debugpy_logs + +# Debugging control variables +env = WAIT_FOR_DEBUGGER=false +env = DEBUG_TIMEOUT=30 + + diff --git a/scripts/debug_wrapper.py b/scripts/debug_wrapper.py new file mode 100644 index 00000000..2339fe87 --- /dev/null +++ b/scripts/debug_wrapper.py @@ -0,0 +1,90 @@ +""" +Debug wrapper for the WSGI application. +This module initializes debugpy and then imports the actual application. +""" +import sys +import os +import time +import logging +import inspect + +# Configure logging to output to both console and file +os.makedirs('/app/debugpy_logs', exist_ok=True) +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s [%(levelname)s] %(name)s - %(message)s', + handlers=[ + logging.FileHandler('/app/debugpy_logs/debug_wrapper.log'), + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger('debug_wrapper') + +# Log system info +logger.info(f"Python version: {sys.version}") +logger.info(f"Current directory: {os.getcwd()}") +logger.info(f"Files in current directory: {os.listdir()}") +logger.info(f"Python path: {sys.path}") + +# Default timeout in seconds +DEBUG_TIMEOUT = int(os.environ.get('DEBUG_TIMEOUT', '30')) +# Whether to wait for debugger to attach +WAIT_FOR_DEBUGGER = os.environ.get('WAIT_FOR_DEBUGGER', 'false').lower() == 'true' + +logger.info(f"DEBUG_TIMEOUT: {DEBUG_TIMEOUT}") +logger.info(f"WAIT_FOR_DEBUGGER: {WAIT_FOR_DEBUGGER}") + +try: + import debugpy + from debugpy import configure + logger.info("Successfully imported debugpy") + + # Critical: Configure debugpy to use regular Python for the adapter, not uwsgi + python_path = '/usr/local/bin/python3' + if os.path.exists(python_path): + logger.info(f"Setting debugpy adapter to use Python interpreter: {python_path}") + debugpy.configure(python=python_path) + else: + logger.warning(f"Python path {python_path} not found. Using system default.") + + # Don't wait for connection, just set up the debugging session + logger.info("Initializing debugpy on 0.0.0.0:5678...") + try: + # Use connect instead of listen to avoid the adapter process + debugpy.listen(("0.0.0.0", 5678)) + logger.info("debugpy now listening on 0.0.0.0:5678") + + if WAIT_FOR_DEBUGGER: + logger.info(f"Waiting for debugger to attach (timeout: {DEBUG_TIMEOUT}s)...") + start_time = time.time() + while not debugpy.is_client_connected() and (time.time() - start_time < DEBUG_TIMEOUT): + time.sleep(1) + logger.info("Waiting for debugger connection...") + + if debugpy.is_client_connected(): + logger.info("Debugger attached!") + else: + logger.info(f"Debugger not attached after {DEBUG_TIMEOUT}s, continuing anyway...") + except Exception as e: + logger.error(f"Error with debugpy.listen: {e}", exc_info=True) + logger.info("Continuing without debugging...") + +except ImportError: + logger.error("debugpy not installed, continuing without debugging support") +except Exception as e: + logger.error(f"Failed to initialize debugpy: {e}", exc_info=True) + logger.info("Continuing without debugging support") + +# Now import the actual WSGI application +logger.info("Loading WSGI application...") +try: + from dispatcharr.wsgi import application + logger.info("WSGI application loaded successfully") + + # Log the application details + logger.info(f"Application type: {type(application)}") + logger.info(f"Application callable: {inspect.isfunction(application) or inspect.ismethod(application)}") + +except Exception as e: + logger.error(f"Error loading WSGI application: {e}", exc_info=True) + raise diff --git a/scripts/standalone_debug.py b/scripts/standalone_debug.py new file mode 100644 index 00000000..69558fab --- /dev/null +++ b/scripts/standalone_debug.py @@ -0,0 +1,36 @@ +""" +Standalone debug entry point for the Django application. +This provides a cleaner way to debug without uWSGI complications. + +Run this directly with Python to debug: + python standalone_debug.py +""" +import os +import sys +import debugpy +import logging + +# Configure basic logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s [%(levelname)s] %(name)s - %(message)s' +) +logger = logging.getLogger('standalone_debug') + +# Setup Django environment +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings') + +# Setup debugpy and wait for connection +logger.info("Setting up debugpy...") +debugpy.listen(("0.0.0.0", 5678)) +logger.info("Waiting for debugger to attach... Connect to 0.0.0.0:5678") +debugpy.wait_for_client() +logger.info("Debugger attached!") + +# Import Django and run the development server +logger.info("Starting Django development server...") +import django +django.setup() + +from django.core.management import execute_from_command_line +execute_from_command_line(['manage.py', 'runserver', '0.0.0.0:8000']) From d418cd7c0680d46cfbfd9524bef093c6763dd302 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 27 Mar 2025 13:02:19 -0400 Subject: [PATCH 011/239] new custom properties fields for m3u and program data --- ..._programdata_custom_properties_and_more.py | 22 +++++++++++++++++++ apps/epg/models.py | 1 + .../0005_m3uaccount_custom_properties.py | 18 +++++++++++++++ apps/m3u/models.py | 1 + 4 files changed, 42 insertions(+) create mode 100644 apps/epg/migrations/0005_programdata_custom_properties_and_more.py create mode 100644 apps/m3u/migrations/0005_m3uaccount_custom_properties.py diff --git a/apps/epg/migrations/0005_programdata_custom_properties_and_more.py b/apps/epg/migrations/0005_programdata_custom_properties_and_more.py new file mode 100644 index 00000000..35d8d1c3 --- /dev/null +++ b/apps/epg/migrations/0005_programdata_custom_properties_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-27 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'), + ] + + operations = [ + migrations.AddField( + model_name='programdata', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterUniqueTogether( + name='epgdata', + unique_together={('tvg_id', 'epg_source')}, + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index e5bac55f..c98e2d2b 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -44,6 +44,7 @@ class ProgramData(models.Model): sub_title = models.CharField(max_length=255, blank=True, null=True) description = models.TextField(blank=True, null=True) tvg_id = models.CharField(max_length=255, null=True, blank=True) + custom_properties = models.TextField(null=True, blank=True) def __str__(self): return f"{self.title} ({self.start_time} - {self.end_time})" diff --git a/apps/m3u/migrations/0005_m3uaccount_custom_properties.py b/apps/m3u/migrations/0005_m3uaccount_custom_properties.py new file mode 100644 index 00000000..587a3496 --- /dev/null +++ b/apps/m3u/migrations/0005_m3uaccount_custom_properties.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-27 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0004_m3uaccount_stream_profile'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccount', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index 773261df..18e21e0b 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -67,6 +67,7 @@ class M3UAccount(models.Model): blank=True, related_name='m3u_accounts' ) + custom_properties = models.TextField(null=True, blank=True) def __str__(self): return self.name From 18210a3b535b428c7b2531bdc6d0336f5a51f59f Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 27 Mar 2025 13:02:28 -0400 Subject: [PATCH 012/239] more frontend improvements --- frontend/src/components/forms/M3U.jsx | 14 ++------- frontend/src/components/tables/M3UsTable.jsx | 31 +++---------------- .../src/components/tables/StreamsTable.jsx | 17 +++++----- frontend/src/index.css | 14 +++++++++ frontend/src/pages/Guide.jsx | 2 +- 5 files changed, 29 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index b717aa3b..08e4eeff 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -18,10 +18,12 @@ import { } from '@mantine/core'; import M3UGroupFilter from './M3UGroupFilter'; import useChannelsStore from '../../store/channels'; +import usePlaylistsStore from '../../store/playlists'; const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { const { userAgents } = useUserAgentsStore(); const { fetchChannelGroups } = useChannelsStore(); + const { setRefreshProgress } = usePlaylistsStore(); const [file, setFile] = useState(null); const [profileModalOpen, setProfileModalOpen] = useState(false); @@ -61,6 +63,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { ...values, uploaded_file: file, }); + setRefreshProgress(id, 0); await fetchChannelGroups(); @@ -207,17 +210,6 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { )} - {!playlist && ( - - )} + + } readOnly value={ formik.values.epg_data_id diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index 4fd46bcb..639dd0f3 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -34,6 +34,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => { url: '', api_key: '', is_active: true, + refresh_interval: 24, }, validationSchema: Yup.object({ name: Yup.string().required('Name is required'), @@ -64,6 +65,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => { url: epg.url, api_key: epg.api_key, is_active: epg.is_active, + refresh_interval: epg.refresh_interval, }); } else { formik.resetForm(); @@ -125,6 +127,17 @@ const EPG = ({ epg = null, isOpen, onClose }) => { ]} /> + + - - - )} - - + + formik.setFieldValue('is_active', e.target.checked) + } + /> +
+ + + {playlist && ( <> - setProfileModalOpen(false)} - /> - + + )} - - + + + + {playlist && ( + <> + setProfileModalOpen(false)} + /> + + + )} + ); }; diff --git a/requirements.txt b/requirements.txt index 53b6e023..716c64be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,4 @@ channels channels-redis daphne django-filter +django-celery-beat From 2995d2c456b024cdc9564a8d4cc6ce0ee4aeb662 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 31 Mar 2025 10:03:49 -0400 Subject: [PATCH 034/239] added centralized task locking --- core/utils.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/core/utils.py b/core/utils.py index 073c2169..2132db3c 100644 --- a/core/utils.py +++ b/core/utils.py @@ -5,6 +5,7 @@ import os import threading from django.conf import settings from redis.exceptions import ConnectionError, TimeoutError +from django.core.cache import cache logger = logging.getLogger(__name__) @@ -145,6 +146,27 @@ def execute_redis_command(redis_client, command_func, default_return=None): logger.error(f"Redis command error: {e}") return default_return +def acquire_task_lock(task_name, id): + """Acquire a lock to prevent concurrent task execution.""" + redis_client = get_redis_client() + lock_id = f"task_lock_{task_name}_{id}" + + # Use the Redis SET command with NX (only set if not exists) and EX (set expiration) + lock_acquired = redis_client.set(lock_id, "locked", ex=300, nx=True) + + if not lock_acquired: + logger.warning(f"Lock for {task_name} and id={id} already acquired. Task will not proceed.") + + return lock_acquired + +def release_task_lock(task_name, id): + """Release the lock after task execution.""" + redis_client = get_redis_client() + lock_id = f"task_lock_{task_name}_{id}" + + # Remove the lock + redis_client.delete(lock_id) + # Initialize the global clients with retry logic # Skip Redis initialization if running as a management command if is_management_command(): @@ -162,4 +184,4 @@ if not is_management_command() and redis_client is not None: pubsub_manager = get_pubsub_manager(redis_client) else: logger.info("PubSub manager not initialized (running as management command or Redis not available)") - pubsub_manager = None \ No newline at end of file + pubsub_manager = None From 6175d910f446734211cb4600afc0734cb75d0dd4 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 31 Mar 2025 10:04:05 -0400 Subject: [PATCH 035/239] settings for django celery beat --- dispatcharr/settings.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 9b381bfd..ef1f6ec7 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -35,10 +35,9 @@ INSTALLED_APPS = [ 'rest_framework', 'corsheaders', 'django_filters', + 'django_celery_beat', ] - - MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -164,6 +163,10 @@ CELERY_BROKER_TRANSPORT_OPTIONS = { 'visibility_timeout': 3600, # Time in seconds that a task remains invisible during retries } +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' + +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler" CELERY_BEAT_SCHEDULE = { 'fetch-channel-statuses': { 'task': 'apps.proxy.tasks.fetch_channel_stats', From f9d5c0ff40a29e23ec30cd3bdbd6251153137be7 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 31 Mar 2025 12:46:12 -0400 Subject: [PATCH 036/239] missing numberinput --- frontend/src/components/forms/EPG.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index 639dd0f3..37ad1de2 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -12,7 +12,7 @@ import { Modal, Flex, NativeSelect, - FileInput, + NumberInput, Space, } from '@mantine/core'; From fb6921feba32c4b310c5162fdd95de1529a338d9 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 31 Mar 2025 14:26:29 -0400 Subject: [PATCH 037/239] missing id --- apps/epg/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/epg/signals.py b/apps/epg/signals.py index 148207e5..164fdb0e 100644 --- a/apps/epg/signals.py +++ b/apps/epg/signals.py @@ -9,7 +9,7 @@ import json def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs): # Trigger refresh only if the source is newly created and active if created and instance.is_active: - refresh_epg_data.delay() + refresh_epg_data.delay(instance.id) @receiver(post_save, sender=EPGSource) def create_or_update_refresh_task(sender, instance, **kwargs): From e7b8abec34fda20815bf233f3b492afdcf01dded Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 31 Mar 2025 20:03:48 -0400 Subject: [PATCH 038/239] added epg id to refresh call --- apps/epg/api_views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 571a7165..74ef9380 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -75,7 +75,7 @@ class EPGImportAPIView(APIView): ) def post(self, request, format=None): logger.info("EPGImportAPIView: Received request to import EPG data.") - refresh_epg_data.delay() # Trigger Celery task + refresh_epg_data.delay(request.data.get('id', None)) # Trigger Celery task logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.") return Response({'success': True, 'message': 'EPG data import initiated.'}, status=status.HTTP_202_ACCEPTED) @@ -90,4 +90,3 @@ class EPGDataViewSet(viewsets.ReadOnlyModelViewSet): queryset = EPGData.objects.all() serializer_class = EPGDataSerializer permission_classes = [IsAuthenticated] - From 8e43bd5e9029254b7a2b97e0b63b338ad9ac9b7c Mon Sep 17 00:00:00 2001 From: dekzter Date: Tue, 1 Apr 2025 17:43:59 -0400 Subject: [PATCH 039/239] implemented m3u stream custom properties --- apps/channels/models.py | 1 + apps/m3u/tasks.py | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/channels/models.py b/apps/channels/models.py index ceeae01e..e2178804 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -91,6 +91,7 @@ class Stream(models.Model): db_index=True, ) last_seen = models.DateTimeField(db_index=True, default=datetime.now) + custom_properties = models.TextField(null=True, blank=True) class Meta: # If you use m3u_account, you might do unique_together = ('name','url','m3u_account') diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 9646828e..5d8e337c 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -211,6 +211,7 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys): "m3u_account": account, "channel_group": existing_groups[group_title], "stream_hash": stream_hash, + "custom_properties": json.dumps(stream_info["attributes"]), } if stream_hash not in stream_hashes: From 2a2e06fa55ca326fc3b078132f4079cdbc621a6b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 1 Apr 2025 19:50:22 -0500 Subject: [PATCH 040/239] Fix clients not being separated from channels. --- frontend/src/pages/Stats.jsx | 173 ++++++++++++++++++++++------------- 1 file changed, 108 insertions(+), 65 deletions(-) diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 1428daa2..92708ebb 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -335,80 +335,123 @@ const ChannelsPage = () => { setClients(clientStats); }, [channelStats]); + const clientsColumns = useMemo( + () => [ + { + header: 'IP Address', + accessorKey: 'ip_address', + size: 50, + }, + ], + [] + ); + return ( - {Object.values(activeChannels).map((channel) => ( - - - - channel logo + {Object.values(activeChannels).map((channel) => { + // Create a clients table specific to this channel + const channelClientsTable = useMantineReactTable({ + ...TableHelper.defaultProperties, + columns: clientsColumns, + data: clients.filter(client => client.channel.channel_id === channel.channel_id), + enablePagination: false, + enableTopToolbar: false, + enableBottomToolbar: false, + enableRowSelection: false, + enableColumnFilters: false, + mantineTableBodyCellProps: { + style: { + padding: 4, + borderColor: '#444', + color: '#E0E0E0', + fontSize: '0.85rem', + }, + }, + displayColumnDefOptions: { + 'mrt-row-numbers': { + size: 15, + header: '', + }, + 'mrt-row-actions': { + size: 74, + }, + }, + }); - - - -
- - {dayjs.duration(channel.uptime, 'seconds').humanize()} -
-
-
-
- - - - - -
-
-
+ return ( + + + + channel logo - - - {channel.name} + + + +
+ + {dayjs.duration(channel.uptime, 'seconds').humanize()} +
+
+
+
+ + + + + +
+
- - -
+ + + {channel.name} + - - - - {formatSpeed(channel.bitrates.at(-1))} + + + + + + + + {formatSpeed(channel.bitrates.at(-1))} + + + Avg: {channel.avg_bitrate} + + + + {formatBytes(channel.total_bytes)} + + + + + {channel.client_count} + - Avg: {channel.avg_bitrate} - - - - {formatBytes(channel.total_bytes)} - - - - - {channel.client_count} - -
- - -
-
- ))} + +
+
+ ); + })}
); }; From 21323ec99e9acd4c5f994ca872af13a63c88dd57 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 1 Apr 2025 21:02:02 -0500 Subject: [PATCH 041/239] Refactor ChannelsPage to use separate ChannelCard components for better client handling --- frontend/src/pages/Stats.jsx | 385 ++++++++++++++--------------------- 1 file changed, 151 insertions(+), 234 deletions(-) diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 92708ebb..880f5bc2 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -73,6 +73,148 @@ const getStartDate = (uptime) => { }); }; +// Create a separate component for each channel card to properly handle the hook +const ChannelCard = ({ channel, clients, stopClient }) => { + const clientsColumns = useMemo( + () => [ + { + header: 'IP Address', + accessorKey: 'ip_address', + size: 50, + }, + ], + [] + ); + + // This hook is now at the top level of this component + const channelClientsTable = useMantineReactTable({ + ...TableHelper.defaultProperties, + columns: clientsColumns, + data: clients.filter(client => client.channel.channel_id === channel.channel_id), + enablePagination: false, + enableTopToolbar: false, + enableBottomToolbar: false, + enableRowSelection: false, + enableColumnFilters: false, + mantineTableBodyCellProps: { + style: { + padding: 4, + borderColor: '#444', + color: '#E0E0E0', + fontSize: '0.85rem', + }, + }, + enableRowActions: true, + renderRowActions: ({ row }) => ( + +
+ + stopClient(row.original.channel.uuid, row.original.client_id) + } + > + + +
+
+ ), + renderDetailPanel: ({ row }) => {row.original.user_agent}, + mantineExpandButtonProps: ({ row, table }) => ({ + size: 'xs', + style: { + transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)', + transition: 'transform 0.2s', + }, + }), + displayColumnDefOptions: { + 'mrt-row-expand': { + size: 15, + header: '', + }, + 'mrt-row-actions': { + size: 74, + }, + }, + }); + + return ( + + + + channel logo + + + + +
+ + {dayjs.duration(channel.uptime, 'seconds').humanize()} +
+
+
+
+ + + + + +
+
+
+ + + + {channel.name} + + + + + + + + + + {formatSpeed(channel.bitrates.at(-1))} + + + Avg: {channel.avg_bitrate} + + + + {formatBytes(channel.total_bytes)} + + + + + {channel.client_count} + + + + +
+
+ ); +}; + const ChannelsPage = () => { const theme = useMantineTheme(); @@ -170,125 +312,7 @@ const ChannelsPage = () => { await API.stopClient(channelId, clientId); }; - // const channelsTable = useMantineReactTable({ - // ...TableHelper.defaultProperties, - // renderTopToolbar: false, - // columns: channelsColumns, - // data: activeChannels, - // enableRowActions: true, - // mantineTableBodyCellProps: { - // style: { - // padding: 4, - // borderColor: '#444', - // color: '#E0E0E0', - // fontSize: '0.85rem', - // }, - // }, - // renderRowActions: ({ row }) => ( - // - //
- // stopChannel(row.original.uuid)} - // > - // - // - //
- //
- // ), - // }); - - const clientsTable = useMantineReactTable({ - ...TableHelper.defaultProperties, - renderTopToolbar: false, - data: clients, - columns: useMemo( - () => [ - // { - // header: 'User-Agent', - // accessorKey: 'user_agent', - // size: 250, - // mantineTableBodyCellProps: { - // style: { - // whiteSpace: 'nowrap', - // maxWidth: 400, - // paddingLeft: 10, - // paddingRight: 10, - // }, - // }, - // }, - { - header: 'IP Address', - accessorKey: 'ip_address', - size: 50, - }, - ], - [] - ), - mantineTableBodyCellProps: { - style: { - padding: 4, - borderColor: '#444', - // color: '#E0E0E0', - fontSize: '0.85rem', - }, - }, - enableRowActions: true, - renderRowActions: ({ row }) => ( - -
- - stopClient(row.original.channel.uuid, row.original.client_id) - } - > - - -
-
- ), - mantineTableContainerProps: { - style: { - height: '100%', - overflowY: 'auto', - }, - }, - renderDetailPanel: ({ row }) => {row.original.user_agent}, - mantineExpandButtonProps: ({ row, table }) => ({ - size: 'xs', - style: { - transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)', - transition: 'transform 0.2s', - }, - }), - enableExpandAll: false, - displayColumnDefOptions: { - 'mrt-row-expand': { - size: 15, - header: '', - // mantineTableHeadCellProps: { - // style: { - // padding: 0, - // minWidth: '20px !important', - // }, - // }, - // mantineTableBodyCellProps: { - // style: { - // padding: 0, - // minWidth: '20px !important', - // }, - // }, - }, - 'mrt-row-actions': { - size: 74, - }, - }, - }); + // The main clientsTable is no longer needed since each channel card has its own table useEffect(() => { if (!channelStats.channels) { @@ -335,123 +359,16 @@ const ChannelsPage = () => { setClients(clientStats); }, [channelStats]); - const clientsColumns = useMemo( - () => [ - { - header: 'IP Address', - accessorKey: 'ip_address', - size: 50, - }, - ], - [] - ); - return ( - {Object.values(activeChannels).map((channel) => { - // Create a clients table specific to this channel - const channelClientsTable = useMantineReactTable({ - ...TableHelper.defaultProperties, - columns: clientsColumns, - data: clients.filter(client => client.channel.channel_id === channel.channel_id), - enablePagination: false, - enableTopToolbar: false, - enableBottomToolbar: false, - enableRowSelection: false, - enableColumnFilters: false, - mantineTableBodyCellProps: { - style: { - padding: 4, - borderColor: '#444', - color: '#E0E0E0', - fontSize: '0.85rem', - }, - }, - displayColumnDefOptions: { - 'mrt-row-numbers': { - size: 15, - header: '', - }, - 'mrt-row-actions': { - size: 74, - }, - }, - }); - - return ( - - - - channel logo - - - - -
- - {dayjs.duration(channel.uptime, 'seconds').humanize()} -
-
-
-
- - - - - -
-
-
- - - - {channel.name} - - - - - - - - - - {formatSpeed(channel.bitrates.at(-1))} - - - Avg: {channel.avg_bitrate} - - - - {formatBytes(channel.total_bytes)} - - - - - {channel.client_count} - - - - -
-
- ); - })} + {Object.values(activeChannels).map((channel) => ( + + ))}
); }; From c5e0de5d48da76dba6a30fe693b2085fb93c5dc4 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 2 Apr 2025 16:27:28 -0400 Subject: [PATCH 042/239] logos, channel numbers, websocket regex test so we can properly test against python regex and not javascript --- apps/channels/api_urls.py | 2 + apps/channels/api_views.py | 67 +++++++++++-- .../0010_stream_custom_properties.py | 18 ++++ ..._logo_remove_channel_logo_file_and_more.py | 35 +++++++ apps/channels/models.py | 17 +++- apps/channels/serializers.py | 26 ++++- apps/channels/signals.py | 10 -- apps/channels/tasks.py | 16 +-- apps/output/views.py | 2 +- apps/proxy/ts_proxy/url_utils.py | 12 +-- core/apps.py | 21 ++++ core/utils.py | 14 ++- dispatcharr/consumers.py | 27 ++++- frontend/src/WebSocket.jsx | 8 +- frontend/src/api.js | 31 ++++++ frontend/src/components/forms/Channel.jsx | 99 ++++++++++--------- frontend/src/components/forms/M3UProfile.jsx | 60 ++++++----- .../src/components/tables/ChannelsTable.jsx | 2 +- .../src/components/tables/StreamsTable.jsx | 5 +- frontend/src/store/auth.jsx | 1 + frontend/src/store/channels.jsx | 40 ++++++++ frontend/src/store/playlists.jsx | 9 ++ frontend/vite.config.js | 2 + 23 files changed, 403 insertions(+), 121 deletions(-) create mode 100644 apps/channels/migrations/0010_stream_custom_properties.py create mode 100644 apps/channels/migrations/0011_logo_remove_channel_logo_file_and_more.py diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index b2cf387e..4be83683 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -6,6 +6,7 @@ from .api_views import ( ChannelGroupViewSet, BulkDeleteStreamsAPIView, BulkDeleteChannelsAPIView, + LogoViewSet, ) app_name = 'channels' # for DRF routing @@ -14,6 +15,7 @@ router = DefaultRouter() router.register(r'streams', StreamViewSet, basename='stream') router.register(r'groups', ChannelGroupViewSet, basename='channel-group') router.register(r'channels', ChannelViewSet, basename='channel') +router.register(r'logos', LogoViewSet, basename='logos') urlpatterns = [ # Bulk delete is a single APIView, not a ViewSet diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 061f5bca..085111c7 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -3,21 +3,38 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser, FormParser from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.shortcuts import get_object_or_404 from django.db import transaction +import os, json -from .models import Stream, Channel, ChannelGroup -from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer +from .models import Stream, Channel, ChannelGroup, Logo +from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer, LogoSerializer from .tasks import match_epg_channels import django_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter from apps.epg.models import EPGData +from django.db.models import Q from rest_framework.pagination import PageNumberPagination + +class OrInFilter(django_filters.Filter): + """ + Custom filter that handles the OR condition instead of AND. + """ + def filter(self, queryset, value): + if value: + # Create a Q object for each value and combine them with OR + query = Q() + for val in value.split(','): + query |= Q(**{self.field_name: val}) + return queryset.filter(query) + return queryset + class StreamPagination(PageNumberPagination): page_size = 25 # Default page size page_size_query_param = 'page_size' # Allow clients to specify page size @@ -25,7 +42,7 @@ class StreamPagination(PageNumberPagination): class StreamFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr='icontains') - channel_group_name = django_filters.CharFilter(field_name="channel_group__name", lookup_expr="icontains") + channel_group_name = OrInFilter(field_name="channel_group__name", lookup_expr="icontains") m3u_account = django_filters.NumberFilter(field_name="m3u_account__id") m3u_account_name = django_filters.CharFilter(field_name="m3u_account__name", lookup_expr="icontains") m3u_account_is_active = django_filters.BooleanFilter(field_name="m3u_account__is_active") @@ -64,7 +81,8 @@ class StreamViewSet(viewsets.ModelViewSet): channel_group = self.request.query_params.get('channel_group') if channel_group: - qs = qs.filter(channel_group__name=channel_group) + group_names = channel_group.split(',') + qs = qs.filter(channel_group__name__in=group_names) return qs @@ -192,15 +210,26 @@ class ChannelViewSet(viewsets.ModelViewSet): if name is None: name = stream.name + stream_custom_props = json.loads(stream.custom_properties) if stream.custom_properties else {} channel_data = { 'channel_number': channel_number, 'name': name, 'tvg_id': stream.tvg_id, 'channel_group_id': channel_group.id, - 'logo_url': stream.logo_url, - 'streams': [stream_id] + 'streams': [stream_id], } + if 'tv-chno' in stream_custom_props: + channel_data['channel_number'] = int(stream_custom_props['tv-chno']) + elif 'channel-number' in stream_custom_props: + channel_data['channel_number'] = int(stream_custom_props['channel-number']) + + if stream.logo_url: + logo, _ = Logo.objects.get_or_create(url=stream.logo_url, defaults={ + "name": stream.name or stream.tvg_id + }) + channel_data["logo_id"] = logo.id + # Attempt to find existing EPGs with the same tvg-id epgs = EPGData.objects.filter(tvg_id=stream.tvg_id) if epgs: @@ -387,3 +416,29 @@ class BulkDeleteChannelsAPIView(APIView): channel_ids = request.data.get('channel_ids', []) Channel.objects.filter(id__in=channel_ids).delete() return Response({"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT) + +class LogoViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + queryset = Logo.objects.all() + serializer_class = LogoSerializer + parser_classes = (MultiPartParser, FormParser) + + @action(detail=False, methods=['post']) + def upload(self, request): + if 'file' not in request.FILES: + return Response({'error': 'No file uploaded'}, status=status.HTTP_400_BAD_REQUEST) + + file = request.FILES['file'] + file_name = file.name + file_path = os.path.join('/data/logos', file_name) + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'wb+') as destination: + for chunk in file.chunks(): + destination.write(chunk) + + logo, _ = Logo.objects.get_or_create(url=file_path, defaults={ + "name": file_name, + }) + + return Response({'id': logo.id, 'name': logo.name, 'url': logo.url}, status=status.HTTP_201_CREATED) diff --git a/apps/channels/migrations/0010_stream_custom_properties.py b/apps/channels/migrations/0010_stream_custom_properties.py new file mode 100644 index 00000000..0c21f12f --- /dev/null +++ b/apps/channels/migrations/0010_stream_custom_properties.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-04-01 17:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0009_remove_channel_tvg_name_channel_epg_data'), + ] + + operations = [ + migrations.AddField( + model_name='stream', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/channels/migrations/0011_logo_remove_channel_logo_file_and_more.py b/apps/channels/migrations/0011_logo_remove_channel_logo_file_and_more.py new file mode 100644 index 00000000..0f0db44f --- /dev/null +++ b/apps/channels/migrations/0011_logo_remove_channel_logo_file_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.6 on 2025-04-01 22:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0010_stream_custom_properties'), + ] + + operations = [ + migrations.CreateModel( + name='Logo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('url', models.URLField(unique=True)), + ], + ), + migrations.RemoveField( + model_name='channel', + name='logo_file', + ), + migrations.RemoveField( + model_name='channel', + name='logo_url', + ), + migrations.AddField( + model_name='channel', + name='logo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='dispatcharr_channels.logo'), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index e2178804..90b9cfd3 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -211,11 +211,12 @@ class ChannelManager(models.Manager): class Channel(models.Model): channel_number = models.IntegerField() name = models.CharField(max_length=255) - logo_url = models.URLField(max_length=2000, blank=True, null=True) - logo_file = models.ImageField( - upload_to='logos/', # Will store in MEDIA_ROOT/logos + logo = models.ForeignKey( + 'Logo', + on_delete=models.SET_NULL, + null=True, blank=True, - null=True + related_name='channels', ) # M2M to Stream now in the same file @@ -379,3 +380,11 @@ class ChannelGroupM3UAccount(models.Model): def __str__(self): return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})" + + +class Logo(models.Model): + name = models.CharField(max_length=255) + url = models.URLField(unique=True) + + def __str__(self): + return self.name diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index a7d82358..91e2ce89 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount +from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount, Logo from apps.epg.serializers import EPGDataSerializer from core.models import StreamProfile from apps.epg.models import EPGData @@ -92,14 +92,21 @@ class ChannelSerializer(serializers.ModelSerializer): queryset=Stream.objects.all(), many=True, write_only=True, required=False ) + logo = serializers.SerializerMethodField() + logo_id = serializers.PrimaryKeyRelatedField( + queryset=Logo.objects.all(), + source='logo', + allow_null=True, + required=False, + write_only=True, + ) + class Meta: model = Channel fields = [ 'id', 'channel_number', 'name', - 'logo_url', - 'logo_file', 'channel_group', 'channel_group_id', 'tvg_id', @@ -109,6 +116,8 @@ class ChannelSerializer(serializers.ModelSerializer): 'stream_ids', 'stream_profile_id', 'uuid', + 'logo', + 'logo_id', ] def get_streams(self, obj): @@ -116,6 +125,9 @@ class ChannelSerializer(serializers.ModelSerializer): ordered_streams = obj.streams.all().order_by('channelstream__order') return StreamSerializer(ordered_streams, many=True).data + def get_logo(self, obj): + return LogoSerializer(obj.logo).data + # def get_stream_ids(self, obj): # """Retrieve ordered stream IDs for GET requests.""" # return list(obj.streams.all().order_by('channelstream__order').values_list('id', flat=True)) @@ -136,7 +148,6 @@ class ChannelSerializer(serializers.ModelSerializer): # Update the actual Channel fields instance.channel_number = validated_data.get('channel_number', instance.channel_number) instance.name = validated_data.get('name', instance.name) - instance.logo_url = validated_data.get('logo_url', instance.logo_url) instance.tvg_id = validated_data.get('tvg_id', instance.tvg_id) instance.epg_data = validated_data.get('epg_data', None) @@ -145,6 +156,8 @@ class ChannelSerializer(serializers.ModelSerializer): instance.channel_group = validated_data['channel_group'] if 'stream_profile' in validated_data: instance.stream_profile = validated_data['stream_profile'] + if 'logo' in validated_data: + instance.logo = validated_data['logo'] instance.save() @@ -168,3 +181,8 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): # Optionally, if you only need the id of the ChannelGroup, you can customize it like this: # channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all()) + +class LogoSerializer(serializers.ModelSerializer): + class Meta: + model = Logo + fields = ['id', 'name', 'url'] diff --git a/apps/channels/signals.py b/apps/channels/signals.py index c5379870..9e23086f 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -14,8 +14,6 @@ def update_channel_tvg_id_and_logo(sender, instance, action, reverse, model, pk_ """ Whenever streams are added to a channel: 1) If the channel doesn't have a tvg_id, fill it from the first newly-added stream that has one. - 2) If the channel doesn't have a logo_url, fill it from the first newly-added stream that has one. - This way if an M3U or EPG entry carried a logo, newly created channels automatically get that logo. """ # We only care about post_add, i.e. once the new streams are fully associated if action == "post_add": @@ -27,14 +25,6 @@ def update_channel_tvg_id_and_logo(sender, instance, action, reverse, model, pk_ instance.tvg_id = streams_with_tvg.first().tvg_id instance.save(update_fields=['tvg_id']) - # --- 2) Populate channel.logo_url if empty --- - if not instance.logo_url: - # Look for newly added streams that have a nonempty logo_url - streams_with_logo = model.objects.filter(pk__in=pk_set).exclude(logo_url__exact='') - if streams_with_logo.exists(): - instance.logo_url = streams_with_logo.first().logo_url - instance.save(update_fields=['logo_url']) - @receiver(pre_save, sender=Stream) def set_default_m3u_account(sender, instance, **kwargs): """ diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index d5cdb2c8..b1e9bab1 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -5,7 +5,7 @@ import re from celery import shared_task from rapidfuzz import fuzz -from sentence_transformers import SentenceTransformer, util +from sentence_transformers import util from django.conf import settings from django.db import transaction @@ -15,22 +15,10 @@ from core.models import CoreSettings from asgiref.sync import async_to_sync from channels.layers import get_channel_layer +from core.apps import st_model logger = logging.getLogger(__name__) -# Load the sentence-transformers model once at the module level -SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" -MODEL_PATH = os.path.join(settings.MEDIA_ROOT, "models", "all-MiniLM-L6-v2") -os.makedirs(MODEL_PATH, exist_ok=True) - -# If not present locally, download: -if not os.path.exists(os.path.join(MODEL_PATH, "config.json")): - logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...") - st_model = SentenceTransformer(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) -else: - logger.info(f"Loading local model from {MODEL_PATH}") - st_model = SentenceTransformer(MODEL_PATH) - # Thresholds BEST_FUZZY_THRESHOLD = 85 LOWER_FUZZY_THRESHOLD = 40 diff --git a/apps/output/views.py b/apps/output/views.py index 1881bf8d..717dd614 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -16,7 +16,7 @@ def generate_m3u(request): group_title = channel.channel_group.name if channel.channel_group else "Default" tvg_id = channel.tvg_id or "" tvg_name = channel.tvg_id or channel.name - tvg_logo = channel.logo_url or "" + tvg_logo = channel.logo.url if channel.logo else "" channel_number = channel.channel_number extinf_line = ( diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 04440c03..641a794e 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -82,18 +82,18 @@ def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> str: The transformed URL """ try: - logger.debug("Executing URL pattern replacement:") - logger.debug(f" base URL: {input_url}") - logger.debug(f" search: {search_pattern}") + logger.info("Executing URL pattern replacement:") + logger.info(f" base URL: {input_url}") + logger.info(f" search: {search_pattern}") # Handle backreferences in the replacement pattern safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern) - logger.debug(f" replace: {replace_pattern}") - logger.debug(f" safe replace: {safe_replace_pattern}") + logger.info(f" replace: {replace_pattern}") + logger.info(f" safe replace: {safe_replace_pattern}") # Apply the transformation stream_url = re.sub(search_pattern, safe_replace_pattern, input_url) - logger.debug(f"Generated stream url: {stream_url}") + logger.info(f"Generated stream url: {stream_url}") return stream_url except Exception as e: diff --git a/core/apps.py b/core/apps.py index 8115ae60..0d23849c 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,6 +1,27 @@ from django.apps import AppConfig +from django.conf import settings +import os, logging +logger = logging.getLogger(__name__) +st_model = None class CoreConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core' + + def ready(self): + global st_model + from sentence_transformers import SentenceTransformer + + # Load the sentence-transformers model once at the module level + SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" + MODEL_PATH = os.path.join(settings.MEDIA_ROOT, "models", "all-MiniLM-L6-v2") + os.makedirs(MODEL_PATH, exist_ok=True) + + # If not present locally, download: + if not os.path.exists(os.path.join(MODEL_PATH, "config.json")): + logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...") + st_model = SentenceTransformer(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) + else: + logger.info(f"Loading local model from {MODEL_PATH}") + st_model = SentenceTransformer(MODEL_PATH) diff --git a/core/utils.py b/core/utils.py index 2132db3c..2020c264 100644 --- a/core/utils.py +++ b/core/utils.py @@ -6,6 +6,8 @@ import threading from django.conf import settings from redis.exceptions import ConnectionError, TimeoutError from django.core.cache import cache +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer logger = logging.getLogger(__name__) @@ -167,9 +169,19 @@ def release_task_lock(task_name, id): # Remove the lock redis_client.delete(lock_id) +def send_websocket_event(event, success, data): + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": {"success": True, "type": "epg_channels"} + } + ) + # Initialize the global clients with retry logic # Skip Redis initialization if running as a management command -if is_management_command(): +if __name__ == '__main__': redis_client = None redis_pubsub_client = None logger.info("Running as management command - Redis clients set to None") diff --git a/dispatcharr/consumers.py b/dispatcharr/consumers.py index 356422d7..8d92c4fa 100644 --- a/dispatcharr/consumers.py +++ b/dispatcharr/consumers.py @@ -1,5 +1,8 @@ import json from channels.generic.websocket import AsyncWebsocketConsumer +import re, logging + +logger = logging.getLogger(__name__) class MyWebSocketConsumer(AsyncWebsocketConsumer): async def connect(self): @@ -12,7 +15,29 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer): async def receive(self, text_data): data = json.loads(text_data) - print("Received:", data) + + if data["type"] == "m3u_profile_test": + from apps.proxy.ts_proxy.url_utils import transform_url + + def replace_with_mark(match): + # Wrap the match in tags + return f"{match.group(0)}" + + # Apply the transformation using the replace_with_mark function + try: + search_preview = re.sub(data["search"], replace_with_mark, data["url"]) + except Exception as e: + search_preview = data["search"] + logger.error(f"Failed to generate replace preview: {e}") + + result = transform_url(data["url"], data["search"], data["replace"]) + await self.send(text_data=json.dumps({ + "data": { + 'type': 'm3u_profile_test', + 'search_preview': search_preview, + 'result': result, + } + })) async def update(self, event): await self.send(text_data=json.dumps(event)) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index b401f9a5..8dded3fc 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -20,7 +20,8 @@ export const WebsocketProvider = ({ children }) => { const { fetchStreams } = useStreamsStore(); const { fetchChannels, setChannelStats, fetchChannelGroups } = useChannelsStore(); - const { fetchPlaylists, setRefreshProgress } = usePlaylistsStore(); + const { fetchPlaylists, setRefreshProgress, setProfilePreview } = + usePlaylistsStore(); const { fetchEPGData } = useEPGsStore(); const ws = useRef(null); @@ -95,6 +96,9 @@ export const WebsocketProvider = ({ children }) => { fetchEPGData(); break; + case 'm3u_profile_test': + setProfilePreview(event.data.search_preview, event.data.result); + default: console.error(`Unknown websocket event type: ${event.type}`); break; @@ -108,7 +112,7 @@ export const WebsocketProvider = ({ children }) => { }; }, []); - const ret = [isReady, val, ws.current?.send.bind(ws.current)]; + const ret = [isReady, ws.current?.send.bind(ws.current), val]; return ( diff --git a/frontend/src/api.js b/frontend/src/api.js index 369c49c2..9a1f1689 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -901,4 +901,35 @@ export default class API { const retval = await response.json(); return retval; } + + static async getLogos() { + const response = await fetch(`${host}/api/channels/logos/`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${await API.getAuthToken()}`, + }, + }); + + const retval = await response.json(); + return retval; + } + + static async uploadLogo(file) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${host}/api/channels/logos/upload/`, { + method: 'POST', + headers: { + Authorization: `Bearer ${await API.getAuthToken()}`, + }, + body: formData, + }); + + const retval = await response.json(); + + useChannelsStore.getState().addLogo(retval); + + return retval; + } } diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index ff0745f2..d40aacf1 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -39,13 +39,12 @@ const Channel = ({ channel = null, isOpen, onClose }) => { const listRef = useRef(null); - const channelGroups = useChannelsStore((state) => state.channelGroups); + const { channelGroups, logos } = useChannelsStore(); const streams = useStreamsStore((state) => state.streams); const { profiles: streamProfiles } = useStreamProfilesStore(); const { playlists } = usePlaylistsStore(); const { epgs, tvgs, tvgsById } = useEPGsStore(); - const [logoFile, setLogoFile] = useState(null); const [logoPreview, setLogoPreview] = useState(null); const [channelStreams, setChannelStreams] = useState([]); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); @@ -65,13 +64,13 @@ const Channel = ({ channel = null, isOpen, onClose }) => { setChannelStreams(Array.from(streamSet)); }; - const handleLogoChange = (files) => { + const handleLogoChange = async (files) => { if (files.length === 1) { console.log(files[0]); - setLogoFile(files[0]); - setLogoPreview(URL.createObjectURL(files[0])); + const retval = await API.uploadLogo(files[0]); + setLogoPreview(retval.url); + formik.setFieldValue('logo_id', retval.id); } else { - setLogoFile(null); setLogoPreview(null); } }; @@ -84,6 +83,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { stream_profile_id: '0', tvg_id: '', epg_data_id: '', + logo_id: '', }, validationSchema: Yup.object({ name: Yup.string().required('Name is required'), @@ -95,23 +95,24 @@ const Channel = ({ channel = null, isOpen, onClose }) => { values.stream_profile_id = null; } + if (!values.logo_id) { + delete values.logo_id; + } + if (channel?.id) { await API.updateChannel({ id: channel.id, ...values, - logo_file: logoFile, streams: channelStreams.map((stream) => stream.id), }); } else { await API.addChannel({ ...values, - logo_file: logoFile, streams: channelStreams.map((stream) => stream.id), }); } resetForm(); - setLogoFile(null); setLogoPreview(null); setSubmitting(false); setTvgFilter(''); @@ -135,6 +136,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { : '0', tvg_id: channel.tvg_id, epg_data_id: channel.epg_data ? `${channel.epg_data?.id}` : '', + logo_id: `${channel.logo?.id}`, }); console.log(channel); @@ -145,6 +147,14 @@ const Channel = ({ channel = null, isOpen, onClose }) => { } }, [channel, tvgsById]); + const renderLogoOption = ({ option, checked }) => { + return ( +
+ +
+ ); + }; + // const activeStreamsTable = useMantineReactTable({ // data: channelStreams, // columns: useMemo( @@ -370,15 +380,36 @@ const Channel = ({ channel = null, isOpen, onClose }) => { - + + { searchable size="xs" nothingFound="No options" - onChange={(e, value) => { - e.stopPropagation(); - handleGroupChange(value); - }} + // onChange={(e, value) => { + // e.stopPropagation(); + // handleGroupChange(value); + // }} data={channelGroupOptions} variant="unstyled" className="table-input-header" @@ -302,12 +423,14 @@ const ChannelsTable = ({}) => { channel logo ), - meta: { - filterVariant: null, - }, }, ], - [channelGroupOptions, filterValues] + [ + channelGroupOptions, + filterValues, + selectedProfile, + selectedProfileChannels, + ] ); // Access the row virtualizer instance (optional) @@ -431,22 +554,13 @@ const ChannelsTable = ({}) => { // Example copy URLs const copyM3UUrl = () => { - handleCopy( - `${window.location.protocol}//${window.location.host}/output/m3u`, - m3uUrlRef - ); + handleCopy(m3uUrl, m3uUrlRef); }; const copyEPGUrl = () => { - handleCopy( - `${window.location.protocol}//${window.location.host}/output/epg`, - epgUrlRef - ); + handleCopy(epgUrl, epgUrlRef); }; const copyHDHRUrl = () => { - handleCopy( - `${window.location.protocol}//${window.location.host}/hdhr`, - hdhrUrlRef - ); + handleCopy(hdhrUrl, hdhrUrlRef); }; useEffect(() => { @@ -464,6 +578,31 @@ const ChannelsTable = ({}) => { ) ); + const deleteProfile = async (id) => { + await API.deleteChannelProfile(id); + }; + + const renderProfileOption = ({ option, checked }) => { + return ( + + {option.label} + {option.value != '0' && ( + { + e.stopPropagation(); + deleteProfile(option.value); + }} + > + + + )} + + ); + }; + const table = useMantineReactTable({ ...TableHelper.defaultProperties, columns, @@ -725,64 +864,85 @@ const ChannelsTable = ({}) => { }} > {/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */} - - - + + + {selectedStreamIds.length > 0 && ( + + )} + - + + + - - - + + + + + + {initialDataCount === 0 && (
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index b8ca53b5..b413d6de 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -240,7 +240,22 @@ const useChannelsStore = create((set, get) => ({ delete updatedProfiles[id]; } - return { profiles: updatedProfiles }; + let additionalUpdates = {}; + if (profileIds.includes(state.selectedProfileId)) { + additionalUpdates = { + selectedProfileId: '0', + selectedProfileChannels: [], + selectedProfile: {}, + }; + } + + return { + profiles: updatedProfiles, + selectedProfileId: profileIds.includes(state.selectedProfileId) + ? '0' + : state.selectedProfileId, + ...additionalUpdates, + }; }), updateProfileChannel: (channelId, profileId, enabled) => From 90b4d05cd072d7266275e0b9de6d5c94eb84c5ba Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 3 Apr 2025 12:41:35 -0400 Subject: [PATCH 051/239] added preferred region setting and default --- .../0010_reload_additional_settings.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 core/migrations/0010_reload_additional_settings.py diff --git a/core/migrations/0010_reload_additional_settings.py b/core/migrations/0010_reload_additional_settings.py new file mode 100644 index 00000000..5395b89a --- /dev/null +++ b/core/migrations/0010_reload_additional_settings.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from django.utils.text import slugify + +def preload_core_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + CoreSettings.objects.create( + key=slugify("Preferred Region"), + name="Preferred Region", + value="us", + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_m3u_hash_settings'), + ] + + operations = [ + migrations.RunPython(preload_core_settings), + ] From 319481f7d111bd85ecd12afd981c77b99047d8b4 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 3 Apr 2025 13:00:14 -0400 Subject: [PATCH 052/239] fixed m3u accounts not saving --- apps/m3u/api_views.py | 33 --------------------------------- apps/m3u/signals.py | 2 +- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 508b6a89..e3d3b9d1 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -29,39 +29,6 @@ class M3UAccountViewSet(viewsets.ModelViewSet): serializer_class = M3UAccountSerializer permission_classes = [IsAuthenticated] - def update(self, request, *args, **kwargs): - # Get the M3UAccount instance we're updating - instance = self.get_object() - - # Handle updates to the 'enabled' flag of the related ChannelGroupM3UAccount instances - updates = request.data.get('channel_groups', []) - - for update_data in updates: - channel_group_id = update_data.get('channel_group') - enabled = update_data.get('enabled') - - try: - # Get the specific relationship to update - relationship = ChannelGroupM3UAccount.objects.get( - m3u_account=instance, channel_group_id=channel_group_id - ) - relationship.enabled = enabled - relationship.save() - except ChannelGroupM3UAccount.DoesNotExist: - return Response( - {"error": "ChannelGroupM3UAccount not found for the given M3UAccount and ChannelGroup."}, - status=status.HTTP_400_BAD_REQUEST - ) - - # After updating the ChannelGroupM3UAccount relationships, reload the M3UAccount instance - instance.refresh_from_db() - - refresh_single_m3u_account.delay(instance.id) - - # Serialize and return the updated M3UAccount data - serializer = self.get_serializer(instance) - return Response(serializer.data) - class M3UFilterViewSet(viewsets.ModelViewSet): """Handles CRUD operations for M3U filters""" queryset = M3UFilter.objects.all() diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index 4e27370e..1a885752 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -24,7 +24,7 @@ def create_or_update_refresh_task(sender, instance, **kwargs): task_name = f"m3u_account-refresh-{instance.id}" interval, _ = IntervalSchedule.objects.get_or_create( - every=24, + every=int(instance.refresh_interval), period=IntervalSchedule.HOURS ) From 76965c368b5a3f36c54190ad9e3f1570651e32d6 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 3 Apr 2025 13:00:41 -0400 Subject: [PATCH 053/239] fixed m3u and settings not saving, updated nativeselect to select --- frontend/src/api.js | 2 +- frontend/src/components/forms/M3U.jsx | 11 +- frontend/src/pages/Settings.jsx | 548 +++++++++++++------------- 3 files changed, 281 insertions(+), 280 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index 271aa992..68caf26c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -573,7 +573,7 @@ export default class API { static async updatePlaylist(values) { const { id, ...payload } = values; const response = await fetch(`${host}/api/m3u/accounts/${id}/`, { - method: 'PUT', + method: 'PATCH', headers: { Authorization: `Bearer ${await API.getAuthToken()}`, 'Content-Type': 'application/json', diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 1e7a51ca..adc5f7ec 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -12,9 +12,8 @@ import { Checkbox, Modal, Flex, - NativeSelect, + Select, FileInput, - Space, useMantineTheme, NumberInput, Divider, @@ -30,7 +29,6 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { const { userAgents } = useUserAgentsStore(); const { fetchChannelGroups } = useChannelsStore(); - const { setRefreshProgress } = usePlaylistsStore(); const [file, setFile] = useState(null); const [profileModalOpen, setProfileModalOpen] = useState(false); @@ -60,6 +58,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { }), onSubmit: async (values, { setSubmitting, resetForm }) => { let newPlaylist; + console.log(values); if (playlist?.id) { await API.updatePlaylist({ id: playlist.id, @@ -180,7 +179,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { } /> - { { + formik.setFieldValue('refresh_interval', value); + }} error={ formik.errors.refresh_interval ? formik.touched.refresh_interval diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 338e5ce9..cb442a7a 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -7,272 +7,265 @@ import useSettingsStore from '../store/settings'; import useUserAgentsStore from '../store/userAgents'; import useStreamProfilesStore from '../store/streamProfiles'; -import { - Button, - Center, - Flex, - Paper, - NativeSelect, - Title, -} from '@mantine/core'; +import { Button, Center, Flex, Paper, Select, Title } from '@mantine/core'; const SettingsPage = () => { const { settings } = useSettingsStore(); const { userAgents } = useUserAgentsStore(); const { profiles: streamProfiles } = useStreamProfilesStore(); -const regionChoices = [ - { value: 'ad', label: 'AD' }, - { value: 'ae', label: 'AE' }, - { value: 'af', label: 'AF' }, - { value: 'ag', label: 'AG' }, - { value: 'ai', label: 'AI' }, - { value: 'al', label: 'AL' }, - { value: 'am', label: 'AM' }, - { value: 'ao', label: 'AO' }, - { value: 'aq', label: 'AQ' }, - { value: 'ar', label: 'AR' }, - { value: 'as', label: 'AS' }, - { value: 'at', label: 'AT' }, - { value: 'au', label: 'AU' }, - { value: 'aw', label: 'AW' }, - { value: 'ax', label: 'AX' }, - { value: 'az', label: 'AZ' }, - { value: 'ba', label: 'BA' }, - { value: 'bb', label: 'BB' }, - { value: 'bd', label: 'BD' }, - { value: 'be', label: 'BE' }, - { value: 'bf', label: 'BF' }, - { value: 'bg', label: 'BG' }, - { value: 'bh', label: 'BH' }, - { value: 'bi', label: 'BI' }, - { value: 'bj', label: 'BJ' }, - { value: 'bl', label: 'BL' }, - { value: 'bm', label: 'BM' }, - { value: 'bn', label: 'BN' }, - { value: 'bo', label: 'BO' }, - { value: 'bq', label: 'BQ' }, - { value: 'br', label: 'BR' }, - { value: 'bs', label: 'BS' }, - { value: 'bt', label: 'BT' }, - { value: 'bv', label: 'BV' }, - { value: 'bw', label: 'BW' }, - { value: 'by', label: 'BY' }, - { value: 'bz', label: 'BZ' }, - { value: 'ca', label: 'CA' }, - { value: 'cc', label: 'CC' }, - { value: 'cd', label: 'CD' }, - { value: 'cf', label: 'CF' }, - { value: 'cg', label: 'CG' }, - { value: 'ch', label: 'CH' }, - { value: 'ci', label: 'CI' }, - { value: 'ck', label: 'CK' }, - { value: 'cl', label: 'CL' }, - { value: 'cm', label: 'CM' }, - { value: 'cn', label: 'CN' }, - { value: 'co', label: 'CO' }, - { value: 'cr', label: 'CR' }, - { value: 'cu', label: 'CU' }, - { value: 'cv', label: 'CV' }, - { value: 'cw', label: 'CW' }, - { value: 'cx', label: 'CX' }, - { value: 'cy', label: 'CY' }, - { value: 'cz', label: 'CZ' }, - { value: 'de', label: 'DE' }, - { value: 'dj', label: 'DJ' }, - { value: 'dk', label: 'DK' }, - { value: 'dm', label: 'DM' }, - { value: 'do', label: 'DO' }, - { value: 'dz', label: 'DZ' }, - { value: 'ec', label: 'EC' }, - { value: 'ee', label: 'EE' }, - { value: 'eg', label: 'EG' }, - { value: 'eh', label: 'EH' }, - { value: 'er', label: 'ER' }, - { value: 'es', label: 'ES' }, - { value: 'et', label: 'ET' }, - { value: 'fi', label: 'FI' }, - { value: 'fj', label: 'FJ' }, - { value: 'fk', label: 'FK' }, - { value: 'fm', label: 'FM' }, - { value: 'fo', label: 'FO' }, - { value: 'fr', label: 'FR' }, - { value: 'ga', label: 'GA' }, - { value: 'gb', label: 'GB' }, - { value: 'gd', label: 'GD' }, - { value: 'ge', label: 'GE' }, - { value: 'gf', label: 'GF' }, - { value: 'gg', label: 'GG' }, - { value: 'gh', label: 'GH' }, - { value: 'gi', label: 'GI' }, - { value: 'gl', label: 'GL' }, - { value: 'gm', label: 'GM' }, - { value: 'gn', label: 'GN' }, - { value: 'gp', label: 'GP' }, - { value: 'gq', label: 'GQ' }, - { value: 'gr', label: 'GR' }, - { value: 'gs', label: 'GS' }, - { value: 'gt', label: 'GT' }, - { value: 'gu', label: 'GU' }, - { value: 'gw', label: 'GW' }, - { value: 'gy', label: 'GY' }, - { value: 'hk', label: 'HK' }, - { value: 'hm', label: 'HM' }, - { value: 'hn', label: 'HN' }, - { value: 'hr', label: 'HR' }, - { value: 'ht', label: 'HT' }, - { value: 'hu', label: 'HU' }, - { value: 'id', label: 'ID' }, - { value: 'ie', label: 'IE' }, - { value: 'il', label: 'IL' }, - { value: 'im', label: 'IM' }, - { value: 'in', label: 'IN' }, - { value: 'io', label: 'IO' }, - { value: 'iq', label: 'IQ' }, - { value: 'ir', label: 'IR' }, - { value: 'is', label: 'IS' }, - { value: 'it', label: 'IT' }, - { value: 'je', label: 'JE' }, - { value: 'jm', label: 'JM' }, - { value: 'jo', label: 'JO' }, - { value: 'jp', label: 'JP' }, - { value: 'ke', label: 'KE' }, - { value: 'kg', label: 'KG' }, - { value: 'kh', label: 'KH' }, - { value: 'ki', label: 'KI' }, - { value: 'km', label: 'KM' }, - { value: 'kn', label: 'KN' }, - { value: 'kp', label: 'KP' }, - { value: 'kr', label: 'KR' }, - { value: 'kw', label: 'KW' }, - { value: 'ky', label: 'KY' }, - { value: 'kz', label: 'KZ' }, - { value: 'la', label: 'LA' }, - { value: 'lb', label: 'LB' }, - { value: 'lc', label: 'LC' }, - { value: 'li', label: 'LI' }, - { value: 'lk', label: 'LK' }, - { value: 'lr', label: 'LR' }, - { value: 'ls', label: 'LS' }, - { value: 'lt', label: 'LT' }, - { value: 'lu', label: 'LU' }, - { value: 'lv', label: 'LV' }, - { value: 'ly', label: 'LY' }, - { value: 'ma', label: 'MA' }, - { value: 'mc', label: 'MC' }, - { value: 'md', label: 'MD' }, - { value: 'me', label: 'ME' }, - { value: 'mf', label: 'MF' }, - { value: 'mg', label: 'MG' }, - { value: 'mh', label: 'MH' }, - { value: 'ml', label: 'ML' }, - { value: 'mm', label: 'MM' }, - { value: 'mn', label: 'MN' }, - { value: 'mo', label: 'MO' }, - { value: 'mp', label: 'MP' }, - { value: 'mq', label: 'MQ' }, - { value: 'mr', label: 'MR' }, - { value: 'ms', label: 'MS' }, - { value: 'mt', label: 'MT' }, - { value: 'mu', label: 'MU' }, - { value: 'mv', label: 'MV' }, - { value: 'mw', label: 'MW' }, - { value: 'mx', label: 'MX' }, - { value: 'my', label: 'MY' }, - { value: 'mz', label: 'MZ' }, - { value: 'na', label: 'NA' }, - { value: 'nc', label: 'NC' }, - { value: 'ne', label: 'NE' }, - { value: 'nf', label: 'NF' }, - { value: 'ng', label: 'NG' }, - { value: 'ni', label: 'NI' }, - { value: 'nl', label: 'NL' }, - { value: 'no', label: 'NO' }, - { value: 'np', label: 'NP' }, - { value: 'nr', label: 'NR' }, - { value: 'nu', label: 'NU' }, - { value: 'nz', label: 'NZ' }, - { value: 'om', label: 'OM' }, - { value: 'pa', label: 'PA' }, - { value: 'pe', label: 'PE' }, - { value: 'pf', label: 'PF' }, - { value: 'pg', label: 'PG' }, - { value: 'ph', label: 'PH' }, - { value: 'pk', label: 'PK' }, - { value: 'pl', label: 'PL' }, - { value: 'pm', label: 'PM' }, - { value: 'pn', label: 'PN' }, - { value: 'pr', label: 'PR' }, - { value: 'ps', label: 'PS' }, - { value: 'pt', label: 'PT' }, - { value: 'pw', label: 'PW' }, - { value: 'py', label: 'PY' }, - { value: 'qa', label: 'QA' }, - { value: 're', label: 'RE' }, - { value: 'ro', label: 'RO' }, - { value: 'rs', label: 'RS' }, - { value: 'ru', label: 'RU' }, - { value: 'rw', label: 'RW' }, - { value: 'sa', label: 'SA' }, - { value: 'sb', label: 'SB' }, - { value: 'sc', label: 'SC' }, - { value: 'sd', label: 'SD' }, - { value: 'se', label: 'SE' }, - { value: 'sg', label: 'SG' }, - { value: 'sh', label: 'SH' }, - { value: 'si', label: 'SI' }, - { value: 'sj', label: 'SJ' }, - { value: 'sk', label: 'SK' }, - { value: 'sl', label: 'SL' }, - { value: 'sm', label: 'SM' }, - { value: 'sn', label: 'SN' }, - { value: 'so', label: 'SO' }, - { value: 'sr', label: 'SR' }, - { value: 'ss', label: 'SS' }, - { value: 'st', label: 'ST' }, - { value: 'sv', label: 'SV' }, - { value: 'sx', label: 'SX' }, - { value: 'sy', label: 'SY' }, - { value: 'sz', label: 'SZ' }, - { value: 'tc', label: 'TC' }, - { value: 'td', label: 'TD' }, - { value: 'tf', label: 'TF' }, - { value: 'tg', label: 'TG' }, - { value: 'th', label: 'TH' }, - { value: 'tj', label: 'TJ' }, - { value: 'tk', label: 'TK' }, - { value: 'tl', label: 'TL' }, - { value: 'tm', label: 'TM' }, - { value: 'tn', label: 'TN' }, - { value: 'to', label: 'TO' }, - { value: 'tr', label: 'TR' }, - { value: 'tt', label: 'TT' }, - { value: 'tv', label: 'TV' }, - { value: 'tw', label: 'TW' }, - { value: 'tz', label: 'TZ' }, - { value: 'ua', label: 'UA' }, - { value: 'ug', label: 'UG' }, - { value: 'um', label: 'UM' }, - { value: 'us', label: 'US' }, - { value: 'uy', label: 'UY' }, - { value: 'uz', label: 'UZ' }, - { value: 'va', label: 'VA' }, - { value: 'vc', label: 'VC' }, - { value: 've', label: 'VE' }, - { value: 'vg', label: 'VG' }, - { value: 'vi', label: 'VI' }, - { value: 'vn', label: 'VN' }, - { value: 'vu', label: 'VU' }, - { value: 'wf', label: 'WF' }, - { value: 'ws', label: 'WS' }, - { value: 'ye', label: 'YE' }, - { value: 'yt', label: 'YT' }, - { value: 'za', label: 'ZA' }, - { value: 'zm', label: 'ZM' }, - { value: 'zw', label: 'ZW' } -]; - + const regionChoices = [ + { value: 'ad', label: 'AD' }, + { value: 'ae', label: 'AE' }, + { value: 'af', label: 'AF' }, + { value: 'ag', label: 'AG' }, + { value: 'ai', label: 'AI' }, + { value: 'al', label: 'AL' }, + { value: 'am', label: 'AM' }, + { value: 'ao', label: 'AO' }, + { value: 'aq', label: 'AQ' }, + { value: 'ar', label: 'AR' }, + { value: 'as', label: 'AS' }, + { value: 'at', label: 'AT' }, + { value: 'au', label: 'AU' }, + { value: 'aw', label: 'AW' }, + { value: 'ax', label: 'AX' }, + { value: 'az', label: 'AZ' }, + { value: 'ba', label: 'BA' }, + { value: 'bb', label: 'BB' }, + { value: 'bd', label: 'BD' }, + { value: 'be', label: 'BE' }, + { value: 'bf', label: 'BF' }, + { value: 'bg', label: 'BG' }, + { value: 'bh', label: 'BH' }, + { value: 'bi', label: 'BI' }, + { value: 'bj', label: 'BJ' }, + { value: 'bl', label: 'BL' }, + { value: 'bm', label: 'BM' }, + { value: 'bn', label: 'BN' }, + { value: 'bo', label: 'BO' }, + { value: 'bq', label: 'BQ' }, + { value: 'br', label: 'BR' }, + { value: 'bs', label: 'BS' }, + { value: 'bt', label: 'BT' }, + { value: 'bv', label: 'BV' }, + { value: 'bw', label: 'BW' }, + { value: 'by', label: 'BY' }, + { value: 'bz', label: 'BZ' }, + { value: 'ca', label: 'CA' }, + { value: 'cc', label: 'CC' }, + { value: 'cd', label: 'CD' }, + { value: 'cf', label: 'CF' }, + { value: 'cg', label: 'CG' }, + { value: 'ch', label: 'CH' }, + { value: 'ci', label: 'CI' }, + { value: 'ck', label: 'CK' }, + { value: 'cl', label: 'CL' }, + { value: 'cm', label: 'CM' }, + { value: 'cn', label: 'CN' }, + { value: 'co', label: 'CO' }, + { value: 'cr', label: 'CR' }, + { value: 'cu', label: 'CU' }, + { value: 'cv', label: 'CV' }, + { value: 'cw', label: 'CW' }, + { value: 'cx', label: 'CX' }, + { value: 'cy', label: 'CY' }, + { value: 'cz', label: 'CZ' }, + { value: 'de', label: 'DE' }, + { value: 'dj', label: 'DJ' }, + { value: 'dk', label: 'DK' }, + { value: 'dm', label: 'DM' }, + { value: 'do', label: 'DO' }, + { value: 'dz', label: 'DZ' }, + { value: 'ec', label: 'EC' }, + { value: 'ee', label: 'EE' }, + { value: 'eg', label: 'EG' }, + { value: 'eh', label: 'EH' }, + { value: 'er', label: 'ER' }, + { value: 'es', label: 'ES' }, + { value: 'et', label: 'ET' }, + { value: 'fi', label: 'FI' }, + { value: 'fj', label: 'FJ' }, + { value: 'fk', label: 'FK' }, + { value: 'fm', label: 'FM' }, + { value: 'fo', label: 'FO' }, + { value: 'fr', label: 'FR' }, + { value: 'ga', label: 'GA' }, + { value: 'gb', label: 'GB' }, + { value: 'gd', label: 'GD' }, + { value: 'ge', label: 'GE' }, + { value: 'gf', label: 'GF' }, + { value: 'gg', label: 'GG' }, + { value: 'gh', label: 'GH' }, + { value: 'gi', label: 'GI' }, + { value: 'gl', label: 'GL' }, + { value: 'gm', label: 'GM' }, + { value: 'gn', label: 'GN' }, + { value: 'gp', label: 'GP' }, + { value: 'gq', label: 'GQ' }, + { value: 'gr', label: 'GR' }, + { value: 'gs', label: 'GS' }, + { value: 'gt', label: 'GT' }, + { value: 'gu', label: 'GU' }, + { value: 'gw', label: 'GW' }, + { value: 'gy', label: 'GY' }, + { value: 'hk', label: 'HK' }, + { value: 'hm', label: 'HM' }, + { value: 'hn', label: 'HN' }, + { value: 'hr', label: 'HR' }, + { value: 'ht', label: 'HT' }, + { value: 'hu', label: 'HU' }, + { value: 'id', label: 'ID' }, + { value: 'ie', label: 'IE' }, + { value: 'il', label: 'IL' }, + { value: 'im', label: 'IM' }, + { value: 'in', label: 'IN' }, + { value: 'io', label: 'IO' }, + { value: 'iq', label: 'IQ' }, + { value: 'ir', label: 'IR' }, + { value: 'is', label: 'IS' }, + { value: 'it', label: 'IT' }, + { value: 'je', label: 'JE' }, + { value: 'jm', label: 'JM' }, + { value: 'jo', label: 'JO' }, + { value: 'jp', label: 'JP' }, + { value: 'ke', label: 'KE' }, + { value: 'kg', label: 'KG' }, + { value: 'kh', label: 'KH' }, + { value: 'ki', label: 'KI' }, + { value: 'km', label: 'KM' }, + { value: 'kn', label: 'KN' }, + { value: 'kp', label: 'KP' }, + { value: 'kr', label: 'KR' }, + { value: 'kw', label: 'KW' }, + { value: 'ky', label: 'KY' }, + { value: 'kz', label: 'KZ' }, + { value: 'la', label: 'LA' }, + { value: 'lb', label: 'LB' }, + { value: 'lc', label: 'LC' }, + { value: 'li', label: 'LI' }, + { value: 'lk', label: 'LK' }, + { value: 'lr', label: 'LR' }, + { value: 'ls', label: 'LS' }, + { value: 'lt', label: 'LT' }, + { value: 'lu', label: 'LU' }, + { value: 'lv', label: 'LV' }, + { value: 'ly', label: 'LY' }, + { value: 'ma', label: 'MA' }, + { value: 'mc', label: 'MC' }, + { value: 'md', label: 'MD' }, + { value: 'me', label: 'ME' }, + { value: 'mf', label: 'MF' }, + { value: 'mg', label: 'MG' }, + { value: 'mh', label: 'MH' }, + { value: 'ml', label: 'ML' }, + { value: 'mm', label: 'MM' }, + { value: 'mn', label: 'MN' }, + { value: 'mo', label: 'MO' }, + { value: 'mp', label: 'MP' }, + { value: 'mq', label: 'MQ' }, + { value: 'mr', label: 'MR' }, + { value: 'ms', label: 'MS' }, + { value: 'mt', label: 'MT' }, + { value: 'mu', label: 'MU' }, + { value: 'mv', label: 'MV' }, + { value: 'mw', label: 'MW' }, + { value: 'mx', label: 'MX' }, + { value: 'my', label: 'MY' }, + { value: 'mz', label: 'MZ' }, + { value: 'na', label: 'NA' }, + { value: 'nc', label: 'NC' }, + { value: 'ne', label: 'NE' }, + { value: 'nf', label: 'NF' }, + { value: 'ng', label: 'NG' }, + { value: 'ni', label: 'NI' }, + { value: 'nl', label: 'NL' }, + { value: 'no', label: 'NO' }, + { value: 'np', label: 'NP' }, + { value: 'nr', label: 'NR' }, + { value: 'nu', label: 'NU' }, + { value: 'nz', label: 'NZ' }, + { value: 'om', label: 'OM' }, + { value: 'pa', label: 'PA' }, + { value: 'pe', label: 'PE' }, + { value: 'pf', label: 'PF' }, + { value: 'pg', label: 'PG' }, + { value: 'ph', label: 'PH' }, + { value: 'pk', label: 'PK' }, + { value: 'pl', label: 'PL' }, + { value: 'pm', label: 'PM' }, + { value: 'pn', label: 'PN' }, + { value: 'pr', label: 'PR' }, + { value: 'ps', label: 'PS' }, + { value: 'pt', label: 'PT' }, + { value: 'pw', label: 'PW' }, + { value: 'py', label: 'PY' }, + { value: 'qa', label: 'QA' }, + { value: 're', label: 'RE' }, + { value: 'ro', label: 'RO' }, + { value: 'rs', label: 'RS' }, + { value: 'ru', label: 'RU' }, + { value: 'rw', label: 'RW' }, + { value: 'sa', label: 'SA' }, + { value: 'sb', label: 'SB' }, + { value: 'sc', label: 'SC' }, + { value: 'sd', label: 'SD' }, + { value: 'se', label: 'SE' }, + { value: 'sg', label: 'SG' }, + { value: 'sh', label: 'SH' }, + { value: 'si', label: 'SI' }, + { value: 'sj', label: 'SJ' }, + { value: 'sk', label: 'SK' }, + { value: 'sl', label: 'SL' }, + { value: 'sm', label: 'SM' }, + { value: 'sn', label: 'SN' }, + { value: 'so', label: 'SO' }, + { value: 'sr', label: 'SR' }, + { value: 'ss', label: 'SS' }, + { value: 'st', label: 'ST' }, + { value: 'sv', label: 'SV' }, + { value: 'sx', label: 'SX' }, + { value: 'sy', label: 'SY' }, + { value: 'sz', label: 'SZ' }, + { value: 'tc', label: 'TC' }, + { value: 'td', label: 'TD' }, + { value: 'tf', label: 'TF' }, + { value: 'tg', label: 'TG' }, + { value: 'th', label: 'TH' }, + { value: 'tj', label: 'TJ' }, + { value: 'tk', label: 'TK' }, + { value: 'tl', label: 'TL' }, + { value: 'tm', label: 'TM' }, + { value: 'tn', label: 'TN' }, + { value: 'to', label: 'TO' }, + { value: 'tr', label: 'TR' }, + { value: 'tt', label: 'TT' }, + { value: 'tv', label: 'TV' }, + { value: 'tw', label: 'TW' }, + { value: 'tz', label: 'TZ' }, + { value: 'ua', label: 'UA' }, + { value: 'ug', label: 'UG' }, + { value: 'um', label: 'UM' }, + { value: 'us', label: 'US' }, + { value: 'uy', label: 'UY' }, + { value: 'uz', label: 'UZ' }, + { value: 'va', label: 'VA' }, + { value: 'vc', label: 'VC' }, + { value: 've', label: 'VE' }, + { value: 'vg', label: 'VG' }, + { value: 'vi', label: 'VI' }, + { value: 'vn', label: 'VN' }, + { value: 'vu', label: 'VU' }, + { value: 'wf', label: 'WF' }, + { value: 'ws', label: 'WS' }, + { value: 'ye', label: 'YE' }, + { value: 'yt', label: 'YT' }, + { value: 'za', label: 'ZA' }, + { value: 'zm', label: 'ZM' }, + { value: 'zw', label: 'ZW' }, + ]; + console.log(settings); const formik = useFormik({ initialValues: { 'default-user-agent': `${settings['default-user-agent'].id}`, @@ -338,13 +331,23 @@ const regionChoices = [ Settings -
- { + e.preventDefault(); // Prevents default form behavior + console.log('Form submission triggered'); + console.log('Formik Errors before submit:', formik.errors); + formik.handleSubmit(e); + console.log('After formik.handleSubmit call'); + }} + > + ({ + value: `${channel.id}`, + label: channel.name, + }))} + /> + + + + + + + + + + + ); +}; + +export default DVR; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index dc9f7b38..949e9173 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -4,6 +4,7 @@ import useChannelsStore from '../../store/channels'; import { notifications } from '@mantine/notifications'; import API from '../../api'; import ChannelForm from '../forms/Channel'; +import RecordingForm from '../forms/Recording'; import { TableHelper } from '../../helpers'; import utils from '../../utils'; import logo from '../../images/logo.png'; @@ -23,6 +24,8 @@ import { Copy, CircleCheck, ScanEye, + EllipsisVertical, + CircleEllipsis, } from 'lucide-react'; import ghostImage from '../../images/ghost.svg'; import { @@ -42,6 +45,7 @@ import { Center, Container, Switch, + Menu, } from '@mantine/core'; const ChannelStreams = ({ channel, isExpanded }) => { @@ -250,8 +254,13 @@ const ChannelsTable = ({ }) => { channelsPageSelection, } = useChannelsStore(); + const { + environment: { env_mode }, + } = useSettingsStore(); + const [channel, setChannel] = useState(null); const [channelModalOpen, setChannelModalOpen] = useState(false); + const [recordingModalOpen, setRecordingModalOpen] = useState(false); const [rowSelection, setRowSelection] = useState([]); const [channelGroupOptions, setChannelGroupOptions] = useState([]); const [selectedProfile, setSelectedProfile] = useState( @@ -259,6 +268,8 @@ const ChannelsTable = ({ }) => { ); const [channelsEnabledHeaderSwitch, setChannelsEnabledHeaderSwitch] = useState(false); + const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null); + const [actionsOpenRow, setActionsOpenRow] = useState(null); const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase); const [epgUrl, setEPGUrl] = useState(epgUrlBase); @@ -322,30 +333,22 @@ const ChannelsTable = ({ }) => { id: 'enabled', Header: () => { if (Object.values(rowSelection).length == 0) { - return ( - - - - ); + return ; } return ( - - { - console.log(channelsPageSelection); - toggleChannelEnabled( - channelsPageSelection.map((row) => row.id), - !channelsEnabledHeaderSwitch - ); - }} - disabled={selectedProfileId == '0'} - /> - + { + console.log(channelsPageSelection); + toggleChannelEnabled( + channelsPageSelection.map((row) => row.id), + !channelsEnabledHeaderSwitch + ); + }} + disabled={selectedProfileId == '0'} + /> ); }, enableSorting: false, @@ -357,25 +360,32 @@ const ChannelsTable = ({ }) => { return selectedProfileChannels.find((channel) => row.id == channel.id) .enabled; }, - size: 20, mantineTableHeadCellProps: { - // align: 'center', + align: 'right', style: { backgroundColor: '#3F3F46', - minWidth: '20px', - width: '50px !important', - justifyContent: 'center', - // paddingLeft: 8, - paddingRight: 0, + width: '40px', + minWidth: '40px', + maxWidth: '40px', + // // minWidth: '20px', + // // width: '50px !important', + // // justifyContent: 'center', + padding: 0, + // // paddingLeft: 8, + // // paddingRight: 0, }, }, mantineTableBodyCellProps: { - // align: 'center', + align: 'right', style: { - minWidth: '20px', - justifyContent: 'center', - paddingLeft: 0, - paddingRight: 0, + width: '40px', + minWidth: '40px', + maxWidth: '40px', + // // minWidth: '20px', + // // justifyContent: 'center', + // // paddingLeft: 0, + // // paddingRight: 0, + padding: 0, }, }, Cell: ({ row, cell }) => ( @@ -391,25 +401,27 @@ const ChannelsTable = ({ }) => { }, { header: '#', - size: 30, + size: 50, + maxSize: 50, accessorKey: 'channel_number', mantineTableHeadCellProps: { - style: { - backgroundColor: '#3F3F46', - minWidth: '20px', - justifyContent: 'center', - paddingLeft: 15, - paddingRight: 0, - }, + align: 'right', + // // style: { + // // backgroundColor: '#3F3F46', + // // // minWidth: '20px', + // // // justifyContent: 'center', + // // // paddingLeft: 15, + // // paddingRight: 0, + // // }, }, mantineTableBodyCellProps: { - align: 'center', - style: { - minWidth: '20px', - justifyContent: 'center', - paddingLeft: 0, - paddingRight: 0, - }, + align: 'right', + // // style: { + // // minWidth: '20px', + // // // justifyContent: 'center', + // // paddingLeft: 0, + // // paddingRight: 0, + // // }, }, }, { @@ -481,6 +493,9 @@ const ChannelsTable = ({ }) => { size: 55, mantineTableBodyCellProps: { align: 'center', + style: { + maxWidth: '55px', + }, }, Cell: ({ cell }) => ( { await API.deleteChannel(id); }; + const createRecording = (channel) => { + setChannel(channel); + setRecordingModalOpen(true); + }; + function handleWatchStream(channelNumber) { let vidUrl = `/proxy/ts/stream/${channelNumber}`; if (env_mode == 'dev') { @@ -602,6 +622,11 @@ const ChannelsTable = ({ }) => { setChannelModalOpen(false); }; + const closeRecordingForm = () => { + // setChannel(null); + setRecordingModalOpen(false); + }; + useEffect(() => { if (typeof window !== 'undefined') { setIsLoading(false); @@ -699,6 +724,11 @@ const ChannelsTable = ({ }) => { ); }; + const handleMoreActionsClick = (event, rowId) => { + setMoreActionsAnchorEl(event.currentTarget); + setActionsOpenRow(rowId); + }; + const table = useMantineReactTable({ ...TableHelper.defaultProperties, columns, @@ -734,27 +764,30 @@ const ChannelsTable = ({ }) => { enableExpandAll: false, displayColumnDefOptions: { 'mrt-row-select': { - // size: 20, + size: 10, + maxSize: 10, mantineTableHeadCellProps: { - // align: 'center', + align: 'right', style: { - paddingLeft: 7, - width: '30px', - minWidth: '30px', + paddding: 0, + // paddingLeft: 7, + width: '20px', + minWidth: '20px', backgroundColor: '#3F3F46', }, }, mantineTableBodyCellProps: { - align: 'center', + align: 'right', style: { - // paddingLeft: 10, - width: '30px', - minWidth: '30px', + paddingLeft: 0, + width: '20px', + minWidth: '20px', }, }, }, 'mrt-row-expand': { size: 20, + maxSize: 20, header: '', mantineTableHeadCellProps: { style: { @@ -762,6 +795,7 @@ const ChannelsTable = ({ }) => { paddingLeft: 2, width: '20px', minWidth: '20px', + maxWidth: '20px', backgroundColor: '#3F3F46', }, }, @@ -771,14 +805,19 @@ const ChannelsTable = ({ }) => { paddingLeft: 2, width: '20px', minWidth: '20px', + maxWidth: '20px', }, }, }, 'mrt-row-actions': { - size: 60, + size: 85, + maxWidth: 85, mantineTableHeadCellProps: { + align: 'center', style: { - paddingLeft: 10, + minWidth: '85px', + maxWidth: '85px', + paddingRight: 40, fontWeight: 'normal', color: 'rgb(207,207,207)', backgroundColor: '#3F3F46', @@ -786,6 +825,9 @@ const ChannelsTable = ({ }) => { }, mantineTableBodyCellProps: { style: { + minWidth: '85px', + maxWidth: '85px', + paddingLeft: 0, paddingRight: 10, }, }, @@ -810,7 +852,7 @@ const ChannelsTable = ({ }) => {
{ @@ -823,7 +865,7 @@ const ChannelsTable = ({ }) => { deleteChannel(row.original.id)} @@ -834,7 +876,7 @@ const ChannelsTable = ({ }) => { handleWatchStream(row.original.uuid)} @@ -842,6 +884,41 @@ const ChannelsTable = ({ }) => { + + {env_mode == 'dev' && ( + + + + handleMoreActionsClick(event, row.original.id) + } + variant="transparent" + size="sm" + > + + + + + + createRecording(row.original)} + leftSection={ +
+ } + > + Record +
+
+
+ )}
), @@ -1170,6 +1247,12 @@ const ChannelsTable = ({ }) => { isOpen={channelModalOpen} onClose={closeChannelForm} /> + + ); }; diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 1f7ac452..f566cd69 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -312,7 +312,7 @@ export default function TVChannelGuide({ startDate, endDate }) { }} > {channel.name} Date: Sat, 5 Apr 2025 19:23:59 -0400 Subject: [PATCH 091/239] removed dup var declaration --- frontend/src/components/tables/ChannelsTable.jsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 949e9173..df95f00b 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -184,7 +184,7 @@ const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/ const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`; const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`; -const CreateProfilePopover = ({ }) => { +const CreateProfilePopover = ({}) => { const [opened, setOpened] = useState(false); const [name, setName] = useState(''); const theme = useMantineTheme(); @@ -241,7 +241,7 @@ const CreateProfilePopover = ({ }) => { ); }; -const ChannelsTable = ({ }) => { +const ChannelsTable = ({}) => { const { channels, isLoading: channelsLoading, @@ -313,10 +313,6 @@ const ChannelsTable = ({ }) => { const m3uUrlRef = useRef(null); const epgUrlRef = useRef(null); - const { - environment: { env_mode }, - } = useSettingsStore(); - const toggleChannelEnabled = async (channelIds, enabled) => { if (channelIds.length == 1) { await API.updateProfileChannel(channelIds[0], selectedProfileId, enabled); @@ -537,7 +533,7 @@ const ChannelsTable = ({ }) => { const [isLoading, setIsLoading] = useState(true); const [sorting, setSorting] = useState([ { id: 'channel_number', desc: false }, - { id: 'name', desc: false } + { id: 'name', desc: false }, ]); const editChannel = async (ch = null) => { From cdf9df03bd1c2629d44293dd95f3ff7b461bc41c Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 5 Apr 2025 20:05:45 -0400 Subject: [PATCH 092/239] lazy-load sentencetransformer instance --- apps/channels/tasks.py | 4 +++- core/apps.py | 20 -------------------- core/utils.py | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 9ac4d475..4fd11adf 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -19,7 +19,7 @@ from core.models import CoreSettings from asgiref.sync import async_to_sync from channels.layers import get_channel_layer -from core.apps import st_model +from core.utils import SentenceTransformer logger = logging.getLogger(__name__) @@ -69,6 +69,8 @@ def match_epg_channels(): """ logger.info("Starting EPG matching logic...") + st_model = SentenceTransformer.get_model() + # Attempt to retrieve a "preferred-region" if configured try: region_obj = CoreSettings.objects.get(key="preferred-region") diff --git a/core/apps.py b/core/apps.py index 0d23849c..3a01f0bd 100644 --- a/core/apps.py +++ b/core/apps.py @@ -2,26 +2,6 @@ from django.apps import AppConfig from django.conf import settings import os, logging -logger = logging.getLogger(__name__) -st_model = None - class CoreConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core' - - def ready(self): - global st_model - from sentence_transformers import SentenceTransformer - - # Load the sentence-transformers model once at the module level - SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" - MODEL_PATH = os.path.join(settings.MEDIA_ROOT, "models", "all-MiniLM-L6-v2") - os.makedirs(MODEL_PATH, exist_ok=True) - - # If not present locally, download: - if not os.path.exists(os.path.join(MODEL_PATH, "config.json")): - logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...") - st_model = SentenceTransformer(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) - else: - logger.info(f"Loading local model from {MODEL_PATH}") - st_model = SentenceTransformer(MODEL_PATH) diff --git a/core/utils.py b/core/utils.py index ca6fa75f..16b96a18 100644 --- a/core/utils.py +++ b/core/utils.py @@ -159,3 +159,24 @@ def send_websocket_event(event, success, data): "data": {"success": True, "type": "epg_channels"} } ) + +class SentenceTransformer + _instance = None + + @classmethod + def get_model(cls): + if cls._instance is None: + from sentence_transformers import SentenceTransformer as st + + # Load the sentence-transformers model once at the module level + SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" + MODEL_PATH = os.path.join(settings.MEDIA_ROOT, "models", "all-MiniLM-L6-v2") + os.makedirs(MODEL_PATH, exist_ok=True) + + # If not present locally, download: + if not os.path.exists(os.path.join(MODEL_PATH, "config.json")): + logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...") + st_model = st(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) + else: + logger.info(f"Loading local model from {MODEL_PATH}") + st_model = st(MODEL_PATH) From a38930b0c9b3c2068c0d498d0636acd9216e0cb0 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 5 Apr 2025 20:10:12 -0400 Subject: [PATCH 093/239] syntax error --- core/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/utils.py b/core/utils.py index 16b96a18..dcf973fb 100644 --- a/core/utils.py +++ b/core/utils.py @@ -160,7 +160,7 @@ def send_websocket_event(event, success, data): } ) -class SentenceTransformer +class SentenceTransformer: _instance = None @classmethod From f565e1fadeb81519406267cc6ebd602eadd65cb2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 5 Apr 2025 20:26:46 -0500 Subject: [PATCH 094/239] Refactor DEBUG setting in settings.py and optimize Dockerfile build process --- dispatcharr/settings.py | 7 ++++- docker/Dockerfile | 66 ++++++++++++++++++++++------------------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 7001e03b..1e45dd5a 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -9,7 +9,12 @@ SECRET_KEY = 'REPLACE_ME_WITH_A_REAL_SECRET' REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_DB = os.environ.get("REDIS_DB", "0") -DEBUG = True +# Set DEBUG to True for development, False for production +if os.environ.get('DISPATCHARR_DEBUG', 'False').lower() == 'true': + DEBUG = True +else: + DEBUG = False + ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ diff --git a/docker/Dockerfile b/docker/Dockerfile index fd910759..640e043e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,34 +12,36 @@ ENV PATH="/dispatcharrpy/bin:$PATH" \ PYTHONUNBUFFERED=1 \ DISPATCHARR_BUILD=1 +# Set the working directory +WORKDIR /app + +# Copy the application source code from the parent directory +COPY . /app/ + RUN apt-get update && \ apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - gcc \ - git \ - libpcre3 \ - libpcre3-dev \ - python3-dev \ - wget && \ - echo "=== setting up nodejs ===" && \ - curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh && \ - bash /tmp/nodesource_setup.sh && \ - apt-get update && \ - apt-get install -y --no-install-recommends \ - nodejs && \ + build-essential \ + curl \ + gcc \ + git \ + libpcre3 \ + libpcre3-dev \ + python3-dev \ + wget && \ python -m pip install virtualenv && \ virtualenv /dispatcharrpy && \ git clone -b ${BRANCH} ${REPO_URL} /app && \ cd /app && \ rm -rf .git && \ cd /app && \ - pip install --no-cache-dir -r requirements.txt && \ - python manage.py collectstatic --noinput && \ - cd /app/frontend && \ - npm install --legacy-peer-deps && \ - npm run build && \ - find . -maxdepth 1 ! -name '.' ! -name 'dist' -exec rm -rf '{}' \; + pip install --no-cache-dir -r requirements.txt + +# Use a dedicated Node.js stage for frontend building +FROM node:20-slim AS frontend-builder +WORKDIR /app/frontend +COPY --from=builder /app /app +RUN npm install --legacy-peer-deps && \ + npm run build FROM python:3.13-slim @@ -51,20 +53,24 @@ ENV PATH="/dispatcharrpy/bin:$PATH" \ # Copy the virtual environment and application from the builder stage COPY --from=builder /dispatcharrpy /dispatcharrpy COPY --from=builder /app /app +COPY --from=frontend-builder /app/frontend /app/frontend + +# Run collectstatic after frontend assets are copied +RUN cd /app && python manage.py collectstatic --noinput # Install base dependencies with memory optimization RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - curl \ - ffmpeg \ - libpcre3 \ - libpq-dev \ - nginx \ - procps \ - streamlink \ - wget \ - gnupg2 \ - lsb-release && \ + curl \ + ffmpeg \ + libpcre3 \ + libpq-dev \ + nginx \ + procps \ + streamlink \ + wget \ + gnupg2 \ + lsb-release && \ cp /app/docker/nginx.conf /etc/nginx/sites-enabled/default && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* From 21a9cb65281b8b22b2eda343421a5b7ae523cba2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 5 Apr 2025 20:30:39 -0500 Subject: [PATCH 095/239] Whoops. --- docker/Dockerfile | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 640e043e..cdc9068b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,12 +12,6 @@ ENV PATH="/dispatcharrpy/bin:$PATH" \ PYTHONUNBUFFERED=1 \ DISPATCHARR_BUILD=1 -# Set the working directory -WORKDIR /app - -# Copy the application source code from the parent directory -COPY . /app/ - RUN apt-get update && \ apt-get install -y --no-install-recommends \ build-essential \ From 472c20627cbf89b8770b3dfaccd7b87a840d9a15 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 5 Apr 2025 21:56:58 -0500 Subject: [PATCH 096/239] Update Docker images to use ghcr.io and clean up ownership commands in init script --- docker/docker-compose.aio.yml | 2 +- docker/docker-compose.dev.yml | 2 +- docker/init/03-init-dispatcharr.sh | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index 34d098da..77b9bec1 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -3,7 +3,7 @@ services: # build: # context: . # dockerfile: Dockerfile - image: dispatcharr/dispatcharr:latest + image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr ports: - 9191:9191 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 35c5087b..89abc535 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -3,7 +3,7 @@ services: # build: # context: .. # dockerfile: docker/Dockerfile.dev - image: dispatcharr/dispatcharr + image: ghcr.io/dispatcharr/dispatcharr:dev container_name: dispatcharr_dev ports: - 5656:5656 diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 0525d26f..3497c1d6 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -11,10 +11,7 @@ sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default # if this script is running as root if [ "$(id -u)" = "0" ]; then touch /app/uwsgi.sock - chown -R $PUID:$PGID /app chown $PUID:$PGID /app/uwsgi.sock - - chown -R $PUID:$PGID /app # Needs to own ALL of /data except db, we handle that below chown -R $PUID:$PGID /data From ecc96f8b69102a89b82c34525d65d53611d07349 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 6 Apr 2025 11:36:16 -0400 Subject: [PATCH 097/239] memory optimization, m3u processing, re-added group filtering before m3u ingestion --- apps/channels/tasks.py | 6 +- apps/m3u/signals.py | 2 +- apps/m3u/tasks.py | 84 +++++++++---------- core/utils.py | 15 +++- dispatcharr/settings.py | 1 - docker/entrypoint.sh | 42 ++++++++-- docker/init/03-init-dispatcharr.sh | 5 +- docker/nginx.conf | 4 +- frontend/src/WebSocket.jsx | 2 +- frontend/src/components/forms/M3U.jsx | 3 +- .../src/components/forms/M3UGroupFilter.jsx | 2 +- .../src/components/tables/ChannelsTable.jsx | 15 +--- .../src/components/tables/StreamsTable.jsx | 25 +----- requirements.txt | 5 +- 14 files changed, 108 insertions(+), 103 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 4fd11adf..ce08e57c 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -4,11 +4,11 @@ import os import re import requests import time +import gc from datetime import datetime from celery import shared_task from rapidfuzz import fuzz -from sentence_transformers import util from django.conf import settings from django.db import transaction from django.utils.text import slugify @@ -67,6 +67,8 @@ def match_epg_channels(): 4) If a match is found, we set channel.tvg_id 5) Summarize and log results. """ + from sentence_transformers import util + logger.info("Starting EPG matching logic...") st_model = SentenceTransformer.get_model() @@ -222,6 +224,8 @@ def match_epg_channels(): } ) + SentenceTransformer.clear() + gc.collect() return f"Done. Matched {total_matched} channel(s)." @shared_task diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index 1a885752..07e774b2 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -14,7 +14,7 @@ def refresh_account_on_save(sender, instance, created, **kwargs): if it is active or newly created. """ if created: - refresh_single_m3u_account.delay(instance.id) + refresh_m3u_groups(instance.id) @receiver(post_save, sender=M3UAccount) def create_or_update_refresh_task(sender, instance, **kwargs): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index b99105c7..b5b4fd10 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -3,6 +3,7 @@ import logging import re import requests import os +import gc from celery.app.control import Inspect from celery.result import AsyncResult from celery import shared_task, current_app, group @@ -160,21 +161,15 @@ def process_groups(account, group_names): ) @shared_task -def process_m3u_batch(account_id, batch, group_names, hash_keys): +def process_m3u_batch(account_id, batch, groups, hash_keys): """Processes a batch of M3U streams using bulk operations.""" account = M3UAccount.objects.get(id=account_id) - existing_groups = {group.name: group for group in ChannelGroup.objects.filter( - m3u_account__m3u_account=account, # Filter by the M3UAccount - m3u_account__enabled=True # Filter by the enabled flag in the join table - )} streams_to_create = [] streams_to_update = [] stream_hashes = {} # compiled_filters = [(f.filter_type, re.compile(f.regex_pattern, re.IGNORECASE)) for f in filters] - redis_client = RedisClient.get_client() - logger.debug(f"Processing batch of {len(batch)}") for stream_info in batch: name, url = stream_info["name"], stream_info["url"] @@ -182,7 +177,7 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys): group_title = stream_info["attributes"].get("group-title", "Default Group") # Filter out disabled groups for this account - if group_title not in existing_groups: + if group_title not in groups: logger.debug(f"Skipping stream in disabled group: {group_title}") continue @@ -199,18 +194,18 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys): try: stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys) - if redis_client.exists(f"m3u_refresh:{stream_hash}"): - # duplicate already processed by another batch - continue + # if redis_client.exists(f"m3u_refresh:{stream_hash}"): + # # duplicate already processed by another batch + # continue - redis_client.set(f"m3u_refresh:{stream_hash}", "true") + # redis_client.set(f"m3u_refresh:{stream_hash}", "true") stream_props = { "name": name, "url": url, "logo_url": tvg_logo, "tvg_id": tvg_id, "m3u_account": account, - "channel_group": existing_groups[group_title], + "channel_group_id": int(groups.get(group_title)), "stream_hash": stream_hash, "custom_properties": json.dumps(stream_info["attributes"]), } @@ -226,15 +221,13 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys): for stream_hash, stream_props in stream_hashes.items(): if stream_hash in existing_streams: obj = existing_streams[stream_hash] - changed = False - for key, value in stream_props.items(): - if hasattr(obj, key) and getattr(obj, key) == value: - continue - changed = True - setattr(obj, key, value) + existing_attr = {field.name: getattr(obj, field.name) for field in Stream._meta.fields if field != 'channel_group_id'} + changed = any(existing_attr[key] != value for key, value in stream_props.items() if key != 'channel_group_id') - obj.last_seen = timezone.now() if changed: + for key, value in stream_props.items(): + setattr(obj, key, value) + obj.last_seen = timezone.now() streams_to_update.append(obj) del existing_streams[stream_hash] else: @@ -244,15 +237,19 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys): streams_to_create.append(Stream(**stream_props)) try: - if streams_to_create: - Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True) - if streams_to_update: - Stream.objects.bulk_update(streams_to_update, stream_props.keys()) - if len(existing_streams.keys()) > 0: - Stream.objects.bulk_update(existing_streams.values(), ["last_seen"]) + with transaction.atomic(): + if streams_to_create: + Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True) + if streams_to_update: + Stream.objects.bulk_update(streams_to_update, { key for key in stream_props.keys() if key not in ["m3u_account", "stream_hash"] and key not in hash_keys}) + # if len(existing_streams.keys()) > 0: + # Stream.objects.bulk_update(existing_streams.values(), ["last_seen"]) except Exception as e: logger.error(f"Bulk create failed: {str(e)}") + # Aggressive garbage collection + del streams_to_create, streams_to_update, stream_hash, existing_streams + gc.collect() return f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." @@ -276,26 +273,20 @@ def cleanup_streams(account_id): logger.info(f"Cleanup complete") -def refresh_m3u_groups(account_id): +def refresh_m3u_groups(account_id, use_cache=False): if not acquire_task_lock('refresh_m3u_account_groups', account_id): return f"Task already running for account_id={account_id}.", None - # Record start time - start_time = time.time() - try: account = M3UAccount.objects.get(id=account_id, is_active=True) except M3UAccount.DoesNotExist: release_task_lock('refresh_m3u_account_groups', account_id) return f"M3UAccount with ID={account_id} not found or inactive.", None - send_progress_update(0, account_id) - - lines = fetch_m3u_lines(account) extinf_data = [] groups = set(["Default Group"]) - for line in lines: + for line in fetch_m3u_lines(account, use_cache): line = line.strip() if line.startswith("#EXTINF"): parsed = parse_extinf_line(line) @@ -365,9 +356,14 @@ def refresh_single_m3u_account(account_id, use_cache=False): hash_keys = CoreSettings.get_m3u_hash_key().split(",") + existing_groups = {group.name: group.id for group in ChannelGroup.objects.filter( + m3u_account__m3u_account=account, # Filter by the M3UAccount + m3u_account__enabled=True # Filter by the enabled flag in the join table + )} + # 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, groups, hash_keys) for batch in batches) + task_group = group(process_m3u_batch.s(account_id, batch, existing_groups, hash_keys) for batch in batches) total_batches = len(batches) completed_batches = 0 @@ -409,15 +405,19 @@ def refresh_single_m3u_account(account_id, use_cache=False): print(f"Function took {elapsed_time} seconds to execute.") + # Aggressive garbage collection + del existing_groups, extinf_data, groups, batches + gc.collect() + release_task_lock('refresh_single_m3u_account', account_id) - cursor = 0 - while True: - cursor, keys = redis_client.scan(cursor, match=f"m3u_refresh:*", count=BATCH_SIZE) - if keys: - redis_client.delete(*keys) # Delete the matching keys - if cursor == 0: - break + # cursor = 0 + # while True: + # cursor, keys = redis_client.scan(cursor, match=f"m3u_refresh:*", count=BATCH_SIZE) + # if keys: + # redis_client.delete(*keys) # Delete the matching keys + # if cursor == 0: + # break return f"Dispatched jobs complete." diff --git a/core/utils.py b/core/utils.py index dcf973fb..d6f0b446 100644 --- a/core/utils.py +++ b/core/utils.py @@ -8,6 +8,7 @@ from redis.exceptions import ConnectionError, TimeoutError from django.core.cache import cache from asgiref.sync import async_to_sync from channels.layers import get_channel_layer +import gc logger = logging.getLogger(__name__) @@ -176,7 +177,17 @@ class SentenceTransformer: # If not present locally, download: if not os.path.exists(os.path.join(MODEL_PATH, "config.json")): logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...") - st_model = st(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) + cls._instance = st(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) else: logger.info(f"Loading local model from {MODEL_PATH}") - st_model = st(MODEL_PATH) + cls._instance = st(MODEL_PATH) + + return cls._instance + + @classmethod + def clear(cls): + """Clear the model instance and release memory.""" + if cls._instance is not None: + del cls._instance + cls._instance = None + gc.collect() diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 1e45dd5a..59b3af0c 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -30,7 +30,6 @@ INSTALLED_APPS = [ 'apps.proxy.ts_proxy', 'core', 'drf_yasg', - 'daphne', 'channels', 'django.contrib.admin', 'django.contrib.auth', diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e6622206..d4f4007a 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -89,10 +89,13 @@ elif [ "$DISPATCHARR_DEBUG" = "true" ]; then uwsgi_file="/app/docker/uwsgi.debug.ini" fi - if [[ "$DISPATCHARR_ENV" = "dev" ]]; then . /app/docker/init/99-init-dev.sh - + echo "Starting frontend dev environment" + su - $POSTGRES_USER -c "cd /app/frontend && npm run dev &" + npm_pid=$(pgrep vite | sort | head -n1) + echo "✅ vite started with PID $npm_pid" + pids+=("$npm_pid") else echo "🚀 Starting nginx..." nginx @@ -105,11 +108,36 @@ cd /app python manage.py migrate --noinput python manage.py collectstatic --noinput -echo "🚀 Starting uwsgi..." -su - $POSTGRES_USER -c "cd /app && uwsgi --ini $uwsgi_file &" -uwsgi_pid=$(pgrep uwsgi | sort | head -n1) -echo "✅ uwsgi started with PID $uwsgi_pid" -pids+=("$uwsgi_pid") +sed -i 's/protected-mode yes/protected-mode no/g' /etc/redis/redis.conf +su - $POSTGRES_USER -c "redis-server --protected-mode no &" +redis_pid=$(pgrep redis) +echo "✅ redis started with PID $redis_pid" +pids+=("$redis_pid") + +echo "🚀 Starting gunicorn..." +su - $POSTGRES_USER -c "cd /app && gunicorn dispatcharr.asgi:application \ + --bind 0.0.0.0:5656 \ + --worker-class uvicorn.workers.UvicornWorker \ + --workers 2 \ + --threads 1 \ + --timeout 600 \ + --keep-alive 30 \ + --access-logfile - \ + --error-logfile - &" +gunicorn_pid=$(pgrep gunicorn | sort | head -n1) +echo "✅ gunicorn started with PID $gunicorn_pid" +pids+=("$gunicorn_pid") + +echo "Starting celery and beat..." +su - $POSTGRES_USER -c "cd /app && celery -A dispatcharr worker -l info --autoscale=8,2 &" +celery_pid=$(pgrep celery | sort | head -n1) +echo "✅ celery started with PID $celery_pid" +pids+=("$celery_pid") + +su - $POSTGRES_USER -c "cd /app && celery -A dispatcharr beat -l info &" +beat_pid=$(pgrep beat | sort | head -n1) +echo "✅ celery beat started with PID $beat_pid" +pids+=("$beat_pid") # Wait for at least one process to exit and log the process that exited first if [ ${#pids[@]} -gt 0 ]; then diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 3497c1d6..78417d35 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -10,11 +10,12 @@ sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default # NOTE: mac doesn't run as root, so only manage permissions # if this script is running as root if [ "$(id -u)" = "0" ]; then - touch /app/uwsgi.sock - chown $PUID:$PGID /app/uwsgi.sock # Needs to own ALL of /data except db, we handle that below chown -R $PUID:$PGID /data + chown -R $PUID:$PGID /app/logo_cache + chown -R $PUID:$PGID /app/media + # Permissions chown -R postgres:postgres /data/db chmod +x /data diff --git a/docker/nginx.conf b/docker/nginx.conf index 4c26dc1c..71f17438 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -11,7 +11,7 @@ server { # Serve Django via uWSGI location / { include uwsgi_params; - uwsgi_pass unix:/app/uwsgi.sock; + proxy_pass http://127.0.0.1:5656; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; @@ -62,7 +62,7 @@ server { # WebSockets for real-time communication location /ws/ { - proxy_pass http://127.0.0.1:8001; + proxy_pass http://127.0.0.1:5656; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 133f749e..616236cc 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -29,7 +29,7 @@ export const WebsocketProvider = ({ children }) => { useEffect(() => { let wsUrl = `${window.location.host}/ws/`; if (import.meta.env.DEV) { - wsUrl = `${window.location.hostname}:8001/ws/`; + wsUrl = `${window.location.hostname}:5656/ws/`; } if (window.location.protocol.match(/https/)) { diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index adc5f7ec..f8c70698 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -58,7 +58,6 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { }), onSubmit: async (values, { setSubmitting, resetForm }) => { let newPlaylist; - console.log(values); if (playlist?.id) { await API.updatePlaylist({ id: playlist.id, @@ -66,6 +65,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { uploaded_file: file, }); } else { + setLoadingText('Fetching groups'); newPlaylist = await API.addPlaylist({ ...values, uploaded_file: file, @@ -75,7 +75,6 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { // Don't prompt for group filters, but keeping this here // in case we want to revive it - newPlaylist = null; } resetForm(); diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 89ffc0e4..2fe221de 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -59,7 +59,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { channel_groups: groupStates, }); setIsLoading(false); - + API.refreshPlaylist(playlist.id); onClose(); }; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index df95f00b..c69cc0a8 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -268,8 +268,6 @@ const ChannelsTable = ({}) => { ); const [channelsEnabledHeaderSwitch, setChannelsEnabledHeaderSwitch] = useState(false); - const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null); - const [actionsOpenRow, setActionsOpenRow] = useState(null); const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase); const [epgUrl, setEPGUrl] = useState(epgUrlBase); @@ -720,11 +718,6 @@ const ChannelsTable = ({}) => { ); }; - const handleMoreActionsClick = (event, rowId) => { - setMoreActionsAnchorEl(event.currentTarget); - setActionsOpenRow(rowId); - }; - const table = useMantineReactTable({ ...TableHelper.defaultProperties, columns, @@ -884,13 +877,7 @@ const ChannelsTable = ({}) => { {env_mode == 'dev' && ( - - handleMoreActionsClick(event, row.original.id) - } - variant="transparent" - size="sm" - > + diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 43766129..9e431d17 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -49,10 +49,7 @@ const StreamsTable = ({}) => { const [rowSelection, setRowSelection] = useState([]); const [stream, setStream] = useState(null); const [modalOpen, setModalOpen] = useState(false); - const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null); const [groupOptions, setGroupOptions] = useState([]); - const [m3uOptions, setM3uOptions] = useState([]); - const [actionsOpenRow, setActionsOpenRow] = useState(null); const [initialDataCount, setInitialDataCount] = useState(null); const [data, setData] = useState([]); // Holds fetched data @@ -62,7 +59,6 @@ const StreamsTable = ({}) => { const [isLoading, setIsLoading] = useState(true); const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]); const [selectedStreamIds, setSelectedStreamIds] = useState([]); - const [unselectedStreamIds, setUnselectedStreamIds] = useState([]); // const [allRowsSelected, setAllRowsSelected] = useState(false); const [pagination, setPagination] = useState({ pageIndex: 0, @@ -74,7 +70,6 @@ const StreamsTable = ({}) => { m3u_account: '', }); const debouncedFilters = useDebounce(filters, 500); - const hasData = data.length > 0; const navigate = useNavigate(); @@ -92,8 +87,6 @@ const StreamsTable = ({}) => { } = useSettingsStore(); const { showVideo } = useVideoStore(); - const isMoreActionsOpen = Boolean(moreActionsAnchorEl); - // Access the row virtualizer instance (optional) const rowVirtualizerInstanceRef = useRef(null); @@ -354,16 +347,6 @@ const StreamsTable = ({}) => { }); }; - const handleMoreActionsClick = (event, rowId) => { - setMoreActionsAnchorEl(event.currentTarget); - setActionsOpenRow(rowId); - }; - - const handleMoreActionsClose = () => { - setMoreActionsAnchorEl(null); - setActionsOpenRow(null); - }; - const onRowSelectionChange = (updater) => { setRowSelection((prevRowSelection) => { const newRowSelection = @@ -550,13 +533,7 @@ const StreamsTable = ({}) => { - - handleMoreActionsClick(event, row.original.id) - } - variant="transparent" - size="sm" - > + diff --git a/requirements.txt b/requirements.txt index 716c64be..f22703d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,6 @@ drf-yasg>=1.20.0 streamlink python-vlc yt-dlp -gevent==24.11.1 django-cors-headers djangorestframework-simplejwt m3u8 @@ -22,9 +21,9 @@ torch==2.6.0+cpu # ML/NLP dependencies sentence-transformers==3.4.1 -uwsgi channels channels-redis -daphne django-filter django-celery-beat +gunicorn +uvicorn[standard] From 702c85e01b9cc9e4c46a731e522ac76c4bd71ee8 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 6 Apr 2025 12:35:43 -0400 Subject: [PATCH 098/239] more garbage collection, better use of m3u cache file usage --- apps/m3u/signals.py | 10 +++++++--- apps/m3u/tasks.py | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index 07e774b2..22e723e0 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -2,9 +2,9 @@ from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from .models import M3UAccount -from .tasks import refresh_single_m3u_account, refresh_m3u_groups +from .tasks import refresh_m3u_groups from django_celery_beat.models import PeriodicTask, IntervalSchedule -import json +import json, gc @receiver(post_save, sender=M3UAccount) def refresh_account_on_save(sender, instance, created, **kwargs): @@ -14,7 +14,11 @@ def refresh_account_on_save(sender, instance, created, **kwargs): if it is active or newly created. """ if created: - refresh_m3u_groups(instance.id) + extinf_data, groups = refresh_m3u_groups(instance.id) + + # Aggresive GC since we pulled in the whole file + del extinf_data, groups + gc.collect() @receiver(post_save, sender=M3UAccount) def create_or_update_refresh_task(sender, instance, **kwargs): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index b5b4fd10..59cf34b2 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -314,12 +314,12 @@ def refresh_m3u_groups(account_id, use_cache=False): return extinf_data, groups @shared_task -def refresh_single_m3u_account(account_id, use_cache=False): +def refresh_single_m3u_account(account_id): """Splits M3U processing into chunks and dispatches them as parallel tasks.""" if not acquire_task_lock('refresh_single_m3u_account', account_id): return f"Task already running for account_id={account_id}." - redis_client = RedisClient.get_client() + # redis_client = RedisClient.get_client() # Record start time start_time = time.time() send_progress_update(0, account_id) @@ -337,7 +337,7 @@ def refresh_single_m3u_account(account_id, use_cache=False): groups = None cache_path = os.path.join(m3u_dir, f"{account_id}.json") - if use_cache and os.path.exists(cache_path): + if os.path.exists(cache_path): with open(cache_path, 'r') as file: data = json.load(file) @@ -409,6 +409,10 @@ def refresh_single_m3u_account(account_id, use_cache=False): del existing_groups, extinf_data, groups, batches gc.collect() + # Clean up cache file since we've fully processed it + if os.path.exists(cache_path): + os.remove(cache_path) + release_task_lock('refresh_single_m3u_account', account_id) # cursor = 0 From e185fbcda6ec0815092e0fd22efd79e62a6abf32 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 6 Apr 2025 13:11:28 -0400 Subject: [PATCH 099/239] process groups to allow filtering before any stream ingestion, do this in background for better experience --- apps/m3u/signals.py | 10 +++------- apps/m3u/tasks.py | 15 +++++++++++++-- frontend/src/WebSocket.jsx | 11 +++++++++++ frontend/src/components/forms/M3U.jsx | 8 +++++++- frontend/src/components/tables/ChannelsTable.jsx | 16 +++++++++++++++- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index 22e723e0..6e46a0ff 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -2,9 +2,9 @@ from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from .models import M3UAccount -from .tasks import refresh_m3u_groups +from .tasks import refresh_single_m3u_account, refresh_m3u_groups from django_celery_beat.models import PeriodicTask, IntervalSchedule -import json, gc +import json @receiver(post_save, sender=M3UAccount) def refresh_account_on_save(sender, instance, created, **kwargs): @@ -14,11 +14,7 @@ def refresh_account_on_save(sender, instance, created, **kwargs): if it is active or newly created. """ if created: - extinf_data, groups = refresh_m3u_groups(instance.id) - - # Aggresive GC since we pulled in the whole file - del extinf_data, groups - gc.collect() + refresh_m3u_groups.delay(instance.id) @receiver(post_save, sender=M3UAccount) def create_or_update_refresh_task(sender, instance, **kwargs): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 59cf34b2..460bb181 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -273,7 +273,8 @@ def cleanup_streams(account_id): logger.info(f"Cleanup complete") -def refresh_m3u_groups(account_id, use_cache=False): +@shared_task +def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): if not acquire_task_lock('refresh_m3u_account_groups', account_id): return f"Task already running for account_id={account_id}.", None @@ -311,6 +312,16 @@ def refresh_m3u_groups(account_id, use_cache=False): release_task_lock('refresh_m3u_account_groups', account_id) + if not full_refresh: + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": {"success": True, "type": "m3u_group_refresh", "account": account_id} + } + ) + return extinf_data, groups @shared_task @@ -346,7 +357,7 @@ def refresh_single_m3u_account(account_id): if not extinf_data: try: - extinf_data, groups = refresh_m3u_groups(account_id) + extinf_data, groups = refresh_m3u_groups(account_id, full_refresh=True) if not extinf_data or not groups: release_task_lock('refresh_single_m3u_account', account_id) return "Failed to update m3u account, task may already be running" diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 616236cc..4fa08216 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -57,6 +57,17 @@ export const WebsocketProvider = ({ children }) => { socket.onmessage = async (event) => { event = JSON.parse(event.data); switch (event.data.type) { + case 'm3u_group_refresh': + fetchChannelGroups(); + fetchPlaylists(); + + notifications.show({ + title: 'Group processing finished!', + message: 'Refresh M3U or filter out groups to pull in streams.', + color: 'green.5', + }); + break; + case 'm3u_refresh': if (event.data.success) { fetchStreams(); diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index f8c70698..f0feb28a 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -23,6 +23,7 @@ import { import M3UGroupFilter from './M3UGroupFilter'; import useChannelsStore from '../../store/channels'; import usePlaylistsStore from '../../store/playlists'; +import { notifications } from '@mantine/notifications'; const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { const theme = useMantineTheme(); @@ -71,10 +72,15 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { uploaded_file: file, }); - await fetchChannelGroups(); + notifications.show({ + title: 'Fetching M3U Groups', + message: 'Filter out groups or refresh M3U once complete.', + // color: 'green.5', + }); // Don't prompt for group filters, but keeping this here // in case we want to revive it + newPlaylist = null; } resetForm(); diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index c69cc0a8..599e7c4f 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -26,6 +26,7 @@ import { ScanEye, EllipsisVertical, CircleEllipsis, + CopyMinus, } from 'lucide-react'; import ghostImage from '../../images/ghost.svg'; import { @@ -540,6 +541,9 @@ const ChannelsTable = ({}) => { }; const deleteChannel = async (id) => { + if (channelsPageSelection.length > 0) { + return deleteChannels(); + } await API.deleteChannel(id); }; @@ -858,8 +862,18 @@ const ChannelsTable = ({}) => { variant="transparent" color={theme.tailwind.red[6]} onClick={() => deleteChannel(row.original.id)} + disabled={ + channelsPageSelection.length > 0 && + !channelsPageSelection + .map((row) => row.id) + .includes(row.original.id) + } > - + {channelsPageSelection.length === 0 ? ( + + ) : ( + + )} From 354cd84c884fcf894495bf6d553c111c00bcb27d Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 6 Apr 2025 15:58:55 -0400 Subject: [PATCH 100/239] filesystem watch and process of m3u and epg --- apps/epg/api_views.py | 26 ++- apps/epg/tasks.py | 3 + apps/m3u/api_views.py | 25 +++ ...ount_uploaded_file_m3uaccount_file_path.py | 22 +++ apps/m3u/models.py | 4 +- apps/m3u/serializers.py | 2 +- apps/m3u/tasks.py | 43 +++-- core/tasks.py | 159 ++++++++++++++++++ dispatcharr/settings.py | 2 +- docker/init/03-init-dispatcharr.sh | 4 + frontend/src/WebSocket.jsx | 18 +- frontend/src/api.js | 6 +- frontend/src/components/forms/M3U.jsx | 9 +- 13 files changed, 296 insertions(+), 27 deletions(-) create mode 100644 apps/m3u/migrations/0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path.py create mode 100644 core/tasks.py diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 74ef9380..f0bf4792 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -1,8 +1,9 @@ -import logging +import logging, os from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.utils import timezone @@ -26,6 +27,29 @@ class EPGSourceViewSet(viewsets.ModelViewSet): logger.debug("Listing all EPG sources.") return super().list(request, *args, **kwargs) + @action(detail=False, methods=['post']) + def upload(self, request): + if 'file' not in request.FILES: + return Response({'error': 'No file uploaded'}, status=status.HTTP_400_BAD_REQUEST) + + file = request.FILES['file'] + file_name = file.name + file_path = os.path.join('/data/uploads/epgs', file_name) + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'wb+') as destination: + for chunk in file.chunks(): + destination.write(chunk) + + new_obj_data = request.data.copy() + new_obj_data['file_path'] = file_path + + serializer = self.get_serializer(data=new_obj_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + # ───────────────────────────── # 2) Program API (CRUD) # ───────────────────────────── diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index fd75ec81..3b84df6d 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -53,6 +53,9 @@ def refresh_epg_data(source_id): release_task_lock('refresh_epg_data', source_id) def fetch_xmltv(source): + if not source.url: + return + logger.info(f"Fetching XMLTV data from source: {source.name}") try: response = requests.get(source.url, timeout=30) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index e3d3b9d1..8737a07a 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -7,6 +7,8 @@ from drf_yasg import openapi from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.core.cache import cache +import os +from rest_framework.decorators import action # Import all models, including UserAgent. from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile @@ -29,6 +31,29 @@ class M3UAccountViewSet(viewsets.ModelViewSet): serializer_class = M3UAccountSerializer permission_classes = [IsAuthenticated] + @action(detail=False, methods=['post']) + def upload(self, request): + if 'file' not in request.FILES: + return Response({'error': 'No file uploaded'}, status=status.HTTP_400_BAD_REQUEST) + + file = request.FILES['file'] + file_name = file.name + file_path = os.path.join('/data/uploads/m3us', file_name) + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'wb+') as destination: + for chunk in file.chunks(): + destination.write(chunk) + + new_obj_data = request.data.copy() + new_obj_data['file_path'] = file_path + + serializer = self.get_serializer(data=new_obj_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + class M3UFilterViewSet(viewsets.ModelViewSet): """Handles CRUD operations for M3U filters""" queryset = M3UFilter.objects.all() diff --git a/apps/m3u/migrations/0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path.py b/apps/m3u/migrations/0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path.py new file mode 100644 index 00000000..086eff29 --- /dev/null +++ b/apps/m3u/migrations/0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-04-06 19:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0006_populate_periodic_tasks'), + ] + + operations = [ + migrations.RemoveField( + model_name='m3uaccount', + name='uploaded_file', + ), + migrations.AddField( + model_name='m3uaccount', + name='file_path', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index e324e690..cc84d768 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -20,8 +20,8 @@ class M3UAccount(models.Model): null=True, help_text="The base URL of the M3U server (optional if a file is uploaded)" ) - uploaded_file = models.FileField( - upload_to='m3u_uploads/', + file_path = models.CharField( + max_length=255, blank=True, null=True ) diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index b977486a..e7dbfcea 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -63,7 +63,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): class Meta: model = M3UAccount fields = [ - 'id', 'name', 'server_url', 'uploaded_file', 'server_group', + 'id', 'name', 'server_url', 'file_path', 'server_group', 'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent', 'profiles', 'locked', 'channel_groups', 'refresh_interval' ] diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 460bb181..e2de2af3 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -4,6 +4,7 @@ import re import requests import os import gc +import gzip, zipfile from celery.app.control import Inspect from celery.result import AsyncResult from celery import shared_task, current_app, group @@ -38,26 +39,40 @@ def fetch_m3u_lines(account, use_cache=False): logger.info(f"Fetching from URL {account.server_url}") try: response = requests.get(account.server_url, headers=headers, stream=True) - response.raise_for_status() # This will raise an HTTPError if the status is not 200 + response.raise_for_status() with open(file_path, 'wb') as file: - # Stream the content in chunks and write to the file - for chunk in response.iter_content(chunk_size=8192): # You can adjust the chunk size - if chunk: # Ensure chunk is not empty + for chunk in response.iter_content(chunk_size=8192): + if chunk: file.write(chunk) except requests.exceptions.RequestException as e: logger.error(f"Error fetching M3U from URL {account.server_url}: {e}") - return [] # Return an empty list in case of error + return [] with open(file_path, 'r', encoding='utf-8') as f: return f.readlines() - elif account.uploaded_file: + elif account.file_path: try: - # Open the file and return the lines as a list or iterator - with open(account.uploaded_file.path, 'r', encoding='utf-8') as f: - return f.readlines() # Ensure you return lines from the file, not the file object - except IOError as e: - logger.error(f"Error opening file {account.uploaded_file.path}: {e}") - return [] # Return an empty list in case of error + if account.file_path.endswith('.gz'): + with gzip.open(account.file_path, 'rt', encoding='utf-8') as f: + return f.readlines() + + elif account.file_path.endswith('.zip'): + with zipfile.ZipFile(account.file_path, 'r') as zip_file: + for name in zip_file.namelist(): + if name.endswith('.m3u'): + with zip_file.open(name) as f: + return [line.decode('utf-8') for line in f.readlines()] + logger.warning(f"No .m3u file found in ZIP archive: {account.file_path}") + return [] + + else: + with open(account.file_path, 'r', encoding='utf-8') as f: + return f.readlines() + + except (IOError, OSError, zipfile.BadZipFile, gzip.BadGzipFile) as e: + logger.error(f"Error opening file {account.file_path}: {e}") + return [] + # Return an empty list if neither server_url nor uploaded_file is available return [] @@ -247,11 +262,13 @@ 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." + # Aggressive garbage collection del streams_to_create, streams_to_update, stream_hash, existing_streams gc.collect() - return f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." + return retval def cleanup_streams(account_id): account = M3UAccount.objects.get(id=account_id, is_active=True) diff --git a/core/tasks.py b/core/tasks.py new file mode 100644 index 00000000..62c20b3e --- /dev/null +++ b/core/tasks.py @@ -0,0 +1,159 @@ +# yourapp/tasks.py +from celery import shared_task +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync +import redis +import json +import logging +import re +import time +import os +from core.utils import RedisClient +from apps.proxy.ts_proxy.channel_status import ChannelStatus +from apps.m3u.models import M3UAccount +from apps.epg.models import EPGSource +from apps.m3u.tasks import refresh_single_m3u_account +from apps.epg.tasks import refresh_epg_data + +logger = logging.getLogger(__name__) + +EPG_WATCH_DIR = '/data/epgs' +M3U_WATCH_DIR = '/data/m3us' +MIN_AGE_SECONDS = 6 +STARTUP_SKIP_AGE = 30 +REDIS_PREFIX = "processed_file:" +REDIS_TTL = 60 * 60 * 24 * 3 # expire keys after 3 days (optional) + +# Store the last known value to compare with new data +last_known_data = {} + +@shared_task +def beat_periodic_task(): + fetch_channel_stats() + scan_and_process_files() + +@shared_task +def scan_and_process_files(): + redis_client = RedisClient.get_client() + now = time.time() + + for filename in os.listdir(M3U_WATCH_DIR): + filepath = os.path.join(M3U_WATCH_DIR, filename) + + if not os.path.isfile(filepath): + continue + + mtime = os.path.getmtime(filepath) + age = now - mtime + redis_key = REDIS_PREFIX + filepath + stored_mtime = redis_client.get(redis_key) + + # Startup safety: skip old untracked files + if not stored_mtime and age > STARTUP_SKIP_AGE: + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + continue # Assume already processed before startup + + # File too new — probably still being written + if age < MIN_AGE_SECONDS: + continue + + # Skip if we've already processed this mtime + if stored_mtime and float(stored_mtime) >= mtime: + continue + + + m3u_account, _ = M3UAccount.objects.get_or_create(file_path=filepath, defaults={ + "name": filename, + }) + + refresh_single_m3u_account.delay(m3u_account.id) + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "m3u_file", "filename": filename} + }, + ) + + for filename in os.listdir(EPG_WATCH_DIR): + filepath = os.path.join(EPG_WATCH_DIR, filename) + + if not os.path.isfile(filepath): + continue + + mtime = os.path.getmtime(filepath) + age = now - mtime + redis_key = REDIS_PREFIX + filepath + stored_mtime = redis_client.get(redis_key) + + # Startup safety: skip old untracked files + if not stored_mtime and age > STARTUP_SKIP_AGE: + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + continue # Assume already processed before startup + + # File too new — probably still being written + if age < MIN_AGE_SECONDS: + continue + + # Skip if we've already processed this mtime + if stored_mtime and float(stored_mtime) >= mtime: + continue + + epg_source, _ = EPGSource.objects.get_or_create(file_path=filepath, defaults={ + "name": filename, + "source_type": "xmltv", + }) + + refresh_epg_data.delay(epg_source.id) # Trigger Celery task + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "epg_file", "filename": filename} + }, + ) + +def fetch_channel_stats(): + redis_client = RedisClient.get_client() + + try: + # Basic info for all channels + channel_pattern = "ts_proxy:channel:*:metadata" + all_channels = [] + + # Extract channel IDs from keys + cursor = 0 + while True: + cursor, keys = redis_client.scan(cursor, match=channel_pattern) + for key in keys: + channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key.decode('utf-8')) + if channel_id_match: + ch_id = channel_id_match.group(1) + channel_info = ChannelStatus.get_basic_channel_info(ch_id) + if channel_info: + all_channels.append(channel_info) + + if cursor == 0: + break + + except Exception as e: + logger.error(f"Error in channel_status: {e}", exc_info=True) + return + # return JsonResponse({'error': str(e)}, status=500) + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "channel_stats", "stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})} + }, + ) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 59b3af0c..92f77eb9 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -174,7 +174,7 @@ CELERY_TASK_SERIALIZER = 'json' CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler" CELERY_BEAT_SCHEDULE = { 'fetch-channel-statuses': { - 'task': 'apps.proxy.tasks.fetch_channel_stats', + 'task': 'core.tasks.beat_periodic_task', 'schedule': 2.0, }, } diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 78417d35..9417acd8 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -2,6 +2,10 @@ mkdir -p /data/logos mkdir -p /data/recordings +mkdir -p /data/uploads/m3us +mkdir -p /data/uploads/epgs +mkdir -p /data/m3us +mkdir -p /data/epgs mkdir -p /app/logo_cache mkdir -p /app/media diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 4fa08216..73939cf3 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -22,7 +22,7 @@ export const WebsocketProvider = ({ children }) => { useChannelsStore(); const { fetchPlaylists, setRefreshProgress, setProfilePreview } = usePlaylistsStore(); - const { fetchEPGData } = useEPGsStore(); + const { fetchEPGData, fetchEPGs } = useEPGsStore(); const ws = useRef(null); @@ -57,6 +57,22 @@ export const WebsocketProvider = ({ children }) => { socket.onmessage = async (event) => { event = JSON.parse(event.data); switch (event.data.type) { + case 'epg_file': + fetchEPGs(); + notifications.show({ + title: 'EPG File Detected', + message: `Processing ${event.data.filename}`, + }); + break; + + case 'm3u_file': + fetchPlaylists(); + notifications.show({ + title: 'M3U File Detected', + message: `Processing ${event.data.filename}`, + }); + break; + case 'm3u_group_refresh': fetchChannelGroups(); fetchPlaylists(); diff --git a/frontend/src/api.js b/frontend/src/api.js index 2d40959f..9e1dfd55 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -500,14 +500,14 @@ export default class API { static async addPlaylist(values) { let body = null; - if (values.uploaded_file) { + if (values.file) { body = new FormData(); for (const prop in values) { body.append(prop, values[prop]); } } else { body = { ...values }; - delete body.uploaded_file; + delete body.file; body = JSON.stringify(body); } @@ -515,7 +515,7 @@ export default class API { method: 'POST', headers: { Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.uploaded_file + ...(values.file ? {} : { 'Content-Type': 'application/json', diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index f0feb28a..0b3ce020 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -63,13 +63,12 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { await API.updatePlaylist({ id: playlist.id, ...values, - uploaded_file: file, + file, }); } else { - setLoadingText('Fetching groups'); newPlaylist = await API.addPlaylist({ ...values, - uploaded_file: file, + file, }); notifications.show({ @@ -160,10 +159,10 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { /> From 8807b442dbb97a4899d3363a992fd9bd3b13ad10 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 6 Apr 2025 16:40:00 -0400 Subject: [PATCH 101/239] reverted to uwsgi for now - gunicorn will need work to keep the proxy working --- dispatcharr/settings.py | 1 + docker/entrypoint.sh | 65 ++++++++++++++++++++++++----------------- requirements.txt | 2 ++ 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 92f77eb9..8aa2c8ca 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -29,6 +29,7 @@ INSTALLED_APPS = [ 'apps.proxy.apps.ProxyConfig', 'apps.proxy.ts_proxy', 'core', + 'daphne', 'drf_yasg', 'channels', 'django.contrib.admin', diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index d4f4007a..8442c4a1 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -108,36 +108,47 @@ cd /app python manage.py migrate --noinput python manage.py collectstatic --noinput -sed -i 's/protected-mode yes/protected-mode no/g' /etc/redis/redis.conf -su - $POSTGRES_USER -c "redis-server --protected-mode no &" -redis_pid=$(pgrep redis) -echo "✅ redis started with PID $redis_pid" -pids+=("$redis_pid") +uwsgi_file="/app/docker/uwsgi.ini" +if [ "$DISPATCHARR_ENV" = "dev" ]; then + uwsgi_file="/app/docker/uwsgi.dev.ini" +fi -echo "🚀 Starting gunicorn..." -su - $POSTGRES_USER -c "cd /app && gunicorn dispatcharr.asgi:application \ - --bind 0.0.0.0:5656 \ - --worker-class uvicorn.workers.UvicornWorker \ - --workers 2 \ - --threads 1 \ - --timeout 600 \ - --keep-alive 30 \ - --access-logfile - \ - --error-logfile - &" -gunicorn_pid=$(pgrep gunicorn | sort | head -n1) -echo "✅ gunicorn started with PID $gunicorn_pid" -pids+=("$gunicorn_pid") +echo "🚀 Starting uwsgi..." +su - $POSTGRES_USER -c "cd /app && uwsgi --ini $uwsgi_file &" +uwsgi_pid=$(pgrep uwsgi | sort | head -n1) +echo "✅ uwsgi started with PID $uwsgi_pid" +pids+=("$uwsgi_pid") -echo "Starting celery and beat..." -su - $POSTGRES_USER -c "cd /app && celery -A dispatcharr worker -l info --autoscale=8,2 &" -celery_pid=$(pgrep celery | sort | head -n1) -echo "✅ celery started with PID $celery_pid" -pids+=("$celery_pid") +# sed -i 's/protected-mode yes/protected-mode no/g' /etc/redis/redis.conf +# su - $POSTGRES_USER -c "redis-server --protected-mode no &" +# redis_pid=$(pgrep redis) +# echo "✅ redis started with PID $redis_pid" +# pids+=("$redis_pid") -su - $POSTGRES_USER -c "cd /app && celery -A dispatcharr beat -l info &" -beat_pid=$(pgrep beat | sort | head -n1) -echo "✅ celery beat started with PID $beat_pid" -pids+=("$beat_pid") +# echo "🚀 Starting gunicorn..." +# su - $POSTGRES_USER -c "cd /app && gunicorn dispatcharr.asgi:application \ +# --bind 0.0.0.0:5656 \ +# --worker-class uvicorn.workers.UvicornWorker \ +# --workers 2 \ +# --threads 1 \ +# --timeout 0 \ +# --keep-alive 30 \ +# --access-logfile - \ +# --error-logfile - &" +# gunicorn_pid=$(pgrep gunicorn | sort | head -n1) +# echo "✅ gunicorn started with PID $gunicorn_pid" +# pids+=("$gunicorn_pid") + +# echo "Starting celery and beat..." +# su - $POSTGRES_USER -c "cd /app && celery -A dispatcharr worker -l info --autoscale=8,2 &" +# celery_pid=$(pgrep celery | sort | head -n1) +# echo "✅ celery started with PID $celery_pid" +# pids+=("$celery_pid") + +# su - $POSTGRES_USER -c "cd /app && celery -A dispatcharr beat -l info &" +# beat_pid=$(pgrep beat | sort | head -n1) +# echo "✅ celery beat started with PID $beat_pid" +# pids+=("$beat_pid") # Wait for at least one process to exit and log the process that exited first if [ ${#pids[@]} -gt 0 ]; then diff --git a/requirements.txt b/requirements.txt index f22703d6..897748dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,8 @@ drf-yasg>=1.20.0 streamlink python-vlc yt-dlp +gevent==24.11.1 +daphne django-cors-headers djangorestframework-simplejwt m3u8 From b3551fe32fb767051e57ec0ef7eb7888fdcfc3f7 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sun, 6 Apr 2025 19:14:32 -0400 Subject: [PATCH 102/239] fixed potential timezone issue --- apps/channels/serializers.py | 19 +++++++++++++++++++ apps/channels/signals.py | 32 ++++++++++++++++++++++++++++---- apps/channels/tasks.py | 21 +++++++++++++++++++++ frontend/src/WebSocket.jsx | 14 ++++++++++++++ frontend/src/pages/Guide.jsx | 16 ++++++++++++++++ 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 3ee336a7..e08f0466 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -4,6 +4,8 @@ from apps.epg.serializers import EPGDataSerializer from core.models import StreamProfile from apps.epg.models import EPGData from django.urls import reverse +from rest_framework import serializers +from django.utils import timezone class LogoSerializer(serializers.ModelSerializer): cache_url = serializers.SerializerMethodField() @@ -239,3 +241,20 @@ class RecordingSerializer(serializers.ModelSerializer): model = Recording fields = '__all__' read_only_fields = ['task_id'] + + def validate(self, data): + start_time = data.get('start_time') + end_time = data.get('end_time') + + now = timezone.now() # timezone-aware current time + + if end_time < now: + raise serializers.ValidationError("End time must be in the future.") + + if start_time < now: + # Optional: Adjust start_time if it's in the past but end_time is in the future + data['start_time'] = now # or: timezone.now() + timedelta(seconds=1) + if end_time <= data['start_time']: + raise serializers.ValidationError("End time must be after start time.") + + return data diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 076f9876..660de04c 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -9,6 +9,8 @@ from apps.m3u.models import M3UAccount from apps.epg.tasks import parse_programs_for_tvg_id import logging, requests, time from .tasks import run_recording +from django.utils.timezone import now, is_aware, make_aware +from datetime import timedelta logger = logging.getLogger(__name__) @@ -96,10 +98,32 @@ def revoke_old_task_on_update(sender, instance, **kwargs): @receiver(post_save, sender=Recording) def schedule_task_on_save(sender, instance, created, **kwargs): - if not instance.task_id and instance.start_time > now(): - task_id = schedule_recording_task(instance) - instance.task_id = task_id - instance.save(update_fields=['task_id']) + try: + if not instance.task_id: + start_time = instance.start_time + + # Make both datetimes aware (in UTC) + if not is_aware(start_time): + print("Start time was not aware, making aware") + start_time = make_aware(start_time) + + current_time = now() + + # Debug log + print(f"Start time: {start_time}, Now: {current_time}") + + # Optionally allow slight fudge factor (1 second) to ensure scheduling happens + if start_time > current_time - timedelta(seconds=1): + print("Scheduling recording task!") + task_id = schedule_recording_task(instance) + instance.task_id = task_id + instance.save(update_fields=['task_id']) + else: + print("Start time is in the past. Not scheduling.") + except Exception as e: + import traceback + print("Error in post_save signal:", e) + traceback.print_exc() @receiver(post_delete, sender=Recording) def revoke_task_on_delete(sender, instance, **kwargs): diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index ce08e57c..7e271846 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -17,6 +17,9 @@ from apps.channels.models import Channel from apps.epg.models import EPGData, EPGSource from core.models import CoreSettings +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from core.utils import SentenceTransformer @@ -238,6 +241,16 @@ def run_recording(channel_id, start_time_str, end_time_str): duration_seconds = int((end_time - start_time).total_seconds()) filename = f'{slugify(channel.name)}-{start_time.strftime("%Y-%m-%d_%H-%M-%S")}.mp4' + channel_layer = get_channel_layer() + + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "recording_started", "channel": channel.name} + }, + ) + logger.info(f"Starting recording for channel {channel.name}") with requests.get(f"http://localhost:5656/proxy/ts/stream/{channel.uuid}", headers={ 'User-Agent': 'Dispatcharr-DVR', @@ -255,5 +268,13 @@ def run_recording(channel_id, start_time_str, end_time_str): # Write the chunk to the file file.write(chunk) + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "recording_ended", "channel": channel.name} + }, + ) + # After the loop, the file and response are closed automatically. logger.info(f"Finished recording for channel {channel.name}") diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 73939cf3..6bd048a4 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -127,6 +127,20 @@ export const WebsocketProvider = ({ children }) => { setProfilePreview(event.data.search_preview, event.data.result); break; + case 'recording_started': + notifications.show({ + title: 'Recording started!', + message: `Started recording channel ${event.data.channel}`, + }); + break; + + case 'recording_ended': + notifications.show({ + title: 'Recording finished!', + message: `Stopped recording channel ${event.data.channel}`, + }); + break; + default: console.error(`Unknown websocket event type: ${event.type}`); break; diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index f566cd69..5f22d1d4 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -158,6 +158,15 @@ export default function TVChannelGuide({ startDate, endDate }) { return guideChannels.find((ch) => ch.epg_data?.tvg_id === tvgId); } + const record = (program) => { + const channel = findChannelByTvgId(program.tvg_id); + API.createRecording({ + channel: `${channel.id}`, + start_time: program.start_time, + end_time: program.end_time, + }); + }; + // The “Watch Now” click => show floating video const { showVideo } = useVideoStore(); // or useVideoStore() function handleWatchStream(program) { @@ -457,6 +466,13 @@ export default function TVChannelGuide({ startDate, endDate }) { {now.isAfter(dayjs(selectedProgram.start_time)) && now.isBefore(dayjs(selectedProgram.end_time)) && ( + + + + + {now.isAfter(dayjs(selectedProgram.start_time)) && + now.isBefore(dayjs(selectedProgram.end_time)) && ( - - )} + )} + )} From c22a8095bd627f0654e7aa9a3a0e3f5773fb806b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 6 Apr 2025 20:35:58 -0500 Subject: [PATCH 104/239] Added uwsgi. --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 897748dd..7d7117f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ python-vlc yt-dlp gevent==24.11.1 daphne +uwsgi django-cors-headers djangorestframework-simplejwt m3u8 @@ -27,5 +28,3 @@ channels channels-redis django-filter django-celery-beat -gunicorn -uvicorn[standard] From 67f282c682355e9973b545fe8cbf138e0329b6c1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 6 Apr 2025 21:01:24 -0500 Subject: [PATCH 105/239] Refactor uwsgi configuration selection in entrypoint script --- docker/entrypoint.sh | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8442c4a1..cbb3ed8b 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -82,13 +82,6 @@ postgres_pid=$(su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D ${POSTGRES echo "✅ Postgres started with PID $postgres_pid" pids+=("$postgres_pid") -uwsgi_file="/app/docker/uwsgi.ini" -if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then - uwsgi_file="/app/docker/uwsgi.dev.ini" -elif [ "$DISPATCHARR_DEBUG" = "true" ]; then - uwsgi_file="/app/docker/uwsgi.debug.ini" -fi - if [[ "$DISPATCHARR_ENV" = "dev" ]]; then . /app/docker/init/99-init-dev.sh echo "Starting frontend dev environment" @@ -108,12 +101,18 @@ cd /app python manage.py migrate --noinput python manage.py collectstatic --noinput -uwsgi_file="/app/docker/uwsgi.ini" -if [ "$DISPATCHARR_ENV" = "dev" ]; then +# Select proper uwsgi config based on environment +if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then + echo "🚀 Starting uwsgi in dev mode..." uwsgi_file="/app/docker/uwsgi.dev.ini" +elif [ "$DISPATCHARR_DEBUG" = "true" ]; then + echo "🚀 Starting uwsgi in debug mode..." + uwsgi_file="/app/docker/uwsgi.debug.ini" +else + echo "🚀 Starting uwsgi in production mode..." + uwsgi_file="/app/docker/uwsgi.ini" fi -echo "🚀 Starting uwsgi..." su - $POSTGRES_USER -c "cd /app && uwsgi --ini $uwsgi_file &" uwsgi_pid=$(pgrep uwsgi | sort | head -n1) echo "✅ uwsgi started with PID $uwsgi_pid" From 7a90cc8ae33bb7c867dbd231ed63e2f7f2f9027f Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 7 Apr 2025 08:36:11 -0400 Subject: [PATCH 106/239] re-adding in removal of frontend artifacts from dockerfile, re-added chown of /app directory --- docker/Dockerfile | 3 ++- docker/init/03-init-dispatcharr.sh | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index cdc9068b..9a576eeb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,7 +35,8 @@ FROM node:20-slim AS frontend-builder WORKDIR /app/frontend COPY --from=builder /app /app RUN npm install --legacy-peer-deps && \ - npm run build + npm run build && \ + find . -maxdepth 1 ! -name '.' ! -name 'dist' -exec rm -rf '{}' \; FROM python:3.13-slim diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 9417acd8..b9c3c63b 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -16,9 +16,7 @@ sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default if [ "$(id -u)" = "0" ]; then # Needs to own ALL of /data except db, we handle that below chown -R $PUID:$PGID /data - - chown -R $PUID:$PGID /app/logo_cache - chown -R $PUID:$PGID /app/media + chown -R $PUID:$PGID /app # Permissions chown -R postgres:postgres /data/db From 557056296085cac63ad4695e9053546b15381999 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 7 Apr 2025 11:57:00 -0400 Subject: [PATCH 107/239] epg match run externally to keep memory usage low --- apps/channels/tasks.py | 174 +++++++++++----------------------------- core/utils.py | 31 ------- dispatcharr/settings.py | 1 - scripts/epg_match.py | 159 ++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 160 deletions(-) create mode 100644 scripts/epg_match.py diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 7e271846..2cecbf04 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -4,17 +4,15 @@ import os import re import requests import time -import gc +import json +import subprocess from datetime import datetime from celery import shared_task -from rapidfuzz import fuzz -from django.conf import settings -from django.db import transaction from django.utils.text import slugify from apps.channels.models import Channel -from apps.epg.models import EPGData, EPGSource +from apps.epg.models import EPGData from core.models import CoreSettings from channels.layers import get_channel_layer @@ -22,15 +20,10 @@ from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync from channels.layers import get_channel_layer -from core.utils import SentenceTransformer +import tempfile logger = logging.getLogger(__name__) -# Thresholds -BEST_FUZZY_THRESHOLD = 85 -LOWER_FUZZY_THRESHOLD = 40 -EMBED_SIM_THRESHOLD = 0.65 - # Words we remove to help with fuzzy + embedding matching COMMON_EXTRANEOUS_WORDS = [ "tv", "channel", "network", "television", @@ -70,12 +63,8 @@ def match_epg_channels(): 4) If a match is found, we set channel.tvg_id 5) Summarize and log results. """ - from sentence_transformers import util - logger.info("Starting EPG matching logic...") - st_model = SentenceTransformer.get_model() - # Attempt to retrieve a "preferred-region" if configured try: region_obj = CoreSettings.objects.get(key="preferred-region") @@ -83,130 +72,61 @@ def match_epg_channels(): except CoreSettings.DoesNotExist: region_code = None - # Gather EPGData rows so we can do fuzzy matching in memory - all_epg = {e.id: e for e in EPGData.objects.all()} - - epg_rows = [] - for e in list(all_epg.values()): - epg_rows.append({ - "epg_id": e.id, - "tvg_id": e.tvg_id or "", - "raw_name": e.name, - "norm_name": normalize_name(e.name), - }) - - epg_embeddings = None - if any(row["norm_name"] for row in epg_rows): - epg_embeddings = st_model.encode( - [row["norm_name"] for row in epg_rows], - convert_to_tensor=True - ) - matched_channels = [] channels_to_update = [] - source = EPGSource.objects.filter(is_active=True).first() - epg_file_path = getattr(source, 'file_path', None) if source else None + channels_json = [{ + "id": channel.id, + "name": channel.name, + "tvg_id": channel.tvg_id, + "fallback_name": channel.tvg_id.strip() if channel.tvg_id else channel.name, + "norm_chan": normalize_name(channel.tvg_id.strip() if channel.tvg_id else channel.name) + } for channel in Channel.objects.all() if not channel.epg_data] - with transaction.atomic(): - for chan in Channel.objects.all(): - # skip if channel already assigned an EPG - if chan.epg_data: - continue + epg_json = [{ + 'id': epg.id, + 'tvg_id': epg.tvg_id, + 'name': epg.name, + 'norm_name': normalize_name(epg.name), + 'epg_source_id': epg.epg_source.id, + } for epg in EPGData.objects.all()] - # If channel has a tvg_id that doesn't exist in EPGData, do direct check. - # I don't THINK this should happen now that we assign EPG on channel creation. - if chan.tvg_id: - epg_match = EPGData.objects.filter(tvg_id=chan.tvg_id).first() - if epg_match: - chan.epg_data = epg_match - logger.info(f"Channel {chan.id} '{chan.name}' => EPG found by tvg_id={chan.tvg_id}") - channels_to_update.append(chan) - continue + payload = { + "channels": channels_json, + "epg_data": epg_json, + "region_code": region_code, + } - # C) Perform name-based fuzzy matching - fallback_name = chan.tvg_id.strip() if chan.tvg_id else chan.name - norm_chan = normalize_name(fallback_name) - if not norm_chan: - logger.info(f"Channel {chan.id} '{chan.name}' => empty after normalization, skipping") - continue + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(json.dumps(payload).encode('utf-8')) + temp_file_path = temp_file.name - best_score = 0 - best_epg = None - for row in epg_rows: - if not row["norm_name"]: - continue - base_score = fuzz.ratio(norm_chan, row["norm_name"]) - bonus = 0 - # Region-based bonus/penalty - combined_text = row["tvg_id"].lower() + " " + row["raw_name"].lower() - dot_regions = re.findall(r'\.([a-z]{2})', combined_text) - if region_code: - if dot_regions: - if region_code in dot_regions: - bonus = 30 # bigger bonus if .us or .ca matches - else: - bonus = -15 - elif region_code in combined_text: - bonus = 15 - score = base_score + bonus + process = subprocess.Popen( + ['python', '/app/scripts/epg_match.py', temp_file_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) - logger.debug( - f"Channel {chan.id} '{fallback_name}' => EPG row {row['epg_id']}: " - f"raw_name='{row['raw_name']}', norm_name='{row['norm_name']}', " - f"combined_text='{combined_text}', dot_regions={dot_regions}, " - f"base_score={base_score}, bonus={bonus}, total_score={score}" - ) + # Log stderr in real-time + for line in iter(process.stderr.readline, ''): + if line: + logger.info(line.strip()) - if score > best_score: - best_score = score - best_epg = row + process.stderr.close() + stdout, stderr = process.communicate() - # If no best match was found, skip - if not best_epg: - logger.info(f"Channel {chan.id} '{fallback_name}' => no EPG match at all.") - continue + os.remove(temp_file_path) - # If best_score is above BEST_FUZZY_THRESHOLD => direct accept - if best_score >= BEST_FUZZY_THRESHOLD: - chan.epg_data = all_epg[best_epg["epg_id"]] - chan.save() + if process.returncode != 0: + return f"Failed to process EPG matching: {stderr}" - matched_channels.append((chan.id, fallback_name, best_epg["tvg_id"])) - logger.info( - f"Channel {chan.id} '{fallback_name}' => matched tvg_id={best_epg['tvg_id']} " - f"(score={best_score})" - ) + result = json.loads(stdout) + channels_to_update = result["channels_to_update"] + matched_channels = result["matched_channels"] - # If best_score is in the “middle range,” do embedding check - elif best_score >= LOWER_FUZZY_THRESHOLD and epg_embeddings is not None: - chan_embedding = st_model.encode(norm_chan, convert_to_tensor=True) - sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0] - top_index = int(sim_scores.argmax()) - top_value = float(sim_scores[top_index]) - if top_value >= EMBED_SIM_THRESHOLD: - matched_epg = epg_rows[top_index] - chan.epg_data = all_epg[matched_epg["epg_id"]] - chan.save() - - matched_channels.append((chan.id, fallback_name, matched_epg["tvg_id"])) - logger.info( - f"Channel {chan.id} '{fallback_name}' => matched EPG tvg_id={matched_epg['tvg_id']} " - f"(fuzzy={best_score}, cos-sim={top_value:.2f})" - ) - else: - logger.info( - f"Channel {chan.id} '{fallback_name}' => fuzzy={best_score}, " - f"cos-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, skipping" - ) - else: - logger.info( - f"Channel {chan.id} '{fallback_name}' => fuzzy={best_score} < " - f"{LOWER_FUZZY_THRESHOLD}, skipping" - ) - - if channels_to_update: - Channel.objects.bulk_update(channels_to_update, ['epg_data']) + if channels_to_update: + Channel.objects.bulk_update(channels_to_update, ['epg_data']) total_matched = len(matched_channels) if total_matched: @@ -227,8 +147,6 @@ def match_epg_channels(): } ) - SentenceTransformer.clear() - gc.collect() return f"Done. Matched {total_matched} channel(s)." @shared_task diff --git a/core/utils.py b/core/utils.py index d6f0b446..3a5d84f4 100644 --- a/core/utils.py +++ b/core/utils.py @@ -160,34 +160,3 @@ def send_websocket_event(event, success, data): "data": {"success": True, "type": "epg_channels"} } ) - -class SentenceTransformer: - _instance = None - - @classmethod - def get_model(cls): - if cls._instance is None: - from sentence_transformers import SentenceTransformer as st - - # Load the sentence-transformers model once at the module level - SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" - MODEL_PATH = os.path.join(settings.MEDIA_ROOT, "models", "all-MiniLM-L6-v2") - os.makedirs(MODEL_PATH, exist_ok=True) - - # If not present locally, download: - if not os.path.exists(os.path.join(MODEL_PATH, "config.json")): - logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...") - cls._instance = st(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) - else: - logger.info(f"Loading local model from {MODEL_PATH}") - cls._instance = st(MODEL_PATH) - - return cls._instance - - @classmethod - def clear(cls): - """Clear the model instance and release memory.""" - if cls._instance is not None: - del cls._instance - cls._instance = None - gc.collect() diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 8aa2c8ca..96bda89b 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -1,7 +1,6 @@ import os from pathlib import Path from datetime import timedelta -from celery.schedules import crontab BASE_DIR = Path(__file__).resolve().parent.parent diff --git a/scripts/epg_match.py b/scripts/epg_match.py new file mode 100644 index 00000000..bfeecd16 --- /dev/null +++ b/scripts/epg_match.py @@ -0,0 +1,159 @@ +# ml_model.py + +import sys +import json +import re +import os +import sys +from rapidfuzz import fuzz +from sentence_transformers import util +from sentence_transformers import SentenceTransformer as st + +# Load the sentence-transformers model once at the module level +SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" +MODEL_PATH = os.path.join("/app/media", "models", "all-MiniLM-L6-v2") + +# Thresholds +BEST_FUZZY_THRESHOLD = 85 +LOWER_FUZZY_THRESHOLD = 40 +EMBED_SIM_THRESHOLD = 0.65 + +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + +def process_data(input_data): + os.makedirs(MODEL_PATH, exist_ok=True) + + # If not present locally, download: + if not os.path.exists(os.path.join(MODEL_PATH, "config.json")): + eprint(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...") + st_model = st(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) + else: + eprint(f"Loading local model from {MODEL_PATH}") + st_model = st(MODEL_PATH) + + channels = input_data["channels"] + epg_data = input_data["epg_data"] + region_code = input_data["region_code"] + + epg_embeddings = None + if any(row["norm_name"] for row in epg_data): + epg_embeddings = st_model.encode( + [row["norm_name"] for row in epg_data], + convert_to_tensor=True + ) + + channels_to_update = [] + matched_channels = [] + + for chan in channels: + # If channel has a tvg_id that doesn't exist in EPGData, do direct check. + # I don't THINK this should happen now that we assign EPG on channel creation. + if chan["tvg_id"]: + epg_match = [epg["id"] for epg in epg_data if epg["tvg_id"] == chan["tvg_id"]] + if epg_match: + chan["epg_data_id"] = epg_match[0]["id"] + eprint(f"Channel {chan['id']} '{chan['name']}' => EPG found by tvg_id={chan['tvg_id']}") + channels_to_update.append(chan) + continue + + # C) Perform name-based fuzzy matching + fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"] + if not chan["norm_chan"]: + eprint(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping") + continue + + best_score = 0 + best_epg = None + for row in epg_data: + if not row["norm_name"]: + continue + + base_score = fuzz.ratio(chan["norm_chan"], row["norm_name"]) + bonus = 0 + # Region-based bonus/penalty + combined_text = row["tvg_id"].lower() + " " + row["name"].lower() + dot_regions = re.findall(r'\.([a-z]{2})', combined_text) + if region_code: + if dot_regions: + if region_code in dot_regions: + bonus = 30 # bigger bonus if .us or .ca matches + else: + bonus = -15 + elif region_code in combined_text: + bonus = 15 + score = base_score + bonus + + eprint( + f"Channel {chan['id']} '{fallback_name}' => EPG row {row['id']}: " + f"name='{row['name']}', norm_name='{row['norm_name']}', " + f"combined_text='{combined_text}', dot_regions={dot_regions}, " + f"base_score={base_score}, bonus={bonus}, total_score={score}" + ) + + if score > best_score: + best_score = score + best_epg = row + + # If no best match was found, skip + if not best_epg: + eprint(f"Channel {chan['id']} '{fallback_name}' => no EPG match at all.") + continue + + # If best_score is above BEST_FUZZY_THRESHOLD => direct accept + if best_score >= BEST_FUZZY_THRESHOLD: + chan["epg_data_id"] = best_epg["id"] + channels_to_update.append(chan) + + matched_channels.append((chan['id'], fallback_name, best_epg["tvg_id"])) + eprint( + f"Channel {chan['id']} '{fallback_name}' => matched tvg_id={best_epg['tvg_id']} " + f"(score={best_score})" + ) + + # If best_score is in the “middle range,” do embedding check + elif best_score >= LOWER_FUZZY_THRESHOLD and epg_embeddings is not None: + chan_embedding = st_model.encode(chan["norm_chan"], convert_to_tensor=True) + sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0] + top_index = int(sim_scores.argmax()) + top_value = float(sim_scores[top_index]) + if top_value >= EMBED_SIM_THRESHOLD: + matched_epg = epg_data[top_index] + chan["epg_data_id"] = matched_epg["id"] + channels_to_update.append(chan) + + matched_channels.append((chan['id'], fallback_name, matched_epg["tvg_id"])) + eprint( + f"Channel {chan['id']} '{fallback_name}' => matched EPG tvg_id={matched_epg['tvg_id']} " + f"(fuzzy={best_score}, cos-sim={top_value:.2f})" + ) + else: + eprint( + f"Channel {chan['id']} '{fallback_name}' => fuzzy={best_score}, " + f"cos-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, skipping" + ) + else: + eprint( + f"Channel {chan['id']} '{fallback_name}' => fuzzy={best_score} < " + f"{LOWER_FUZZY_THRESHOLD}, skipping" + ) + + return { + "channels_to_update": channels_to_update, + "matched_channels": matched_channels, + } + +def main(): + # Read input data from a file + input_file_path = sys.argv[1] + with open(input_file_path, 'r') as f: + input_data = json.load(f) + + # Process data with the ML model (or your logic) + result = process_data(input_data) + + # Output result to stdout + print(json.dumps(result)) + +if __name__ == "__main__": + main() From 20c8ff21792941259c3219956e1f92bf439d97b8 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 7 Apr 2025 12:20:46 -0400 Subject: [PATCH 108/239] file extension check for m3u and epg watcher --- core/tasks.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/tasks.py b/core/tasks.py index 62c20b3e..061dd1a5 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -43,6 +43,9 @@ def scan_and_process_files(): if not os.path.isfile(filepath): continue + if not filename.endswith('.m3u') and not filename.endswith('.m3u8'): + continue + mtime = os.path.getmtime(filepath) age = now - mtime redis_key = REDIS_PREFIX + filepath @@ -85,6 +88,9 @@ def scan_and_process_files(): if not os.path.isfile(filepath): continue + if not filename.endswith('.xml') and not filename.endswith('.gz'): + continue + mtime = os.path.getmtime(filepath) age = now - mtime redis_key = REDIS_PREFIX + filepath From e507c6f23c35eae45baf58fbfa29799bc4655990 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 7 Apr 2025 12:46:45 -0400 Subject: [PATCH 109/239] updated timestamp and extension checks for m3 uand epg --- ...gsource_created_at_epgsource_updated_at.py | 24 ++++++++++ ...009_alter_epgsource_created_at_and_more.py | 23 ++++++++++ apps/epg/models.py | 8 ++++ apps/epg/serializers.py | 3 +- apps/epg/tasks.py | 2 + apps/m3u/serializers.py | 2 +- apps/m3u/tasks.py | 1 + docker/Dockerfile | 2 +- .../src/components/forms/ChannelGroup.jsx | 46 ++++++++----------- frontend/src/components/forms/Recording.jsx | 28 ++++------- .../src/components/tables/ChannelsTable.jsx | 2 +- frontend/src/components/tables/EPGsTable.jsx | 6 +++ frontend/src/components/tables/M3UsTable.jsx | 6 +++ .../src/components/tables/StreamsTable.jsx | 6 +-- 14 files changed, 106 insertions(+), 53 deletions(-) create mode 100644 apps/epg/migrations/0008_epgsource_created_at_epgsource_updated_at.py create mode 100644 apps/epg/migrations/0009_alter_epgsource_created_at_and_more.py diff --git a/apps/epg/migrations/0008_epgsource_created_at_epgsource_updated_at.py b/apps/epg/migrations/0008_epgsource_created_at_epgsource_updated_at.py new file mode 100644 index 00000000..1dcfeed0 --- /dev/null +++ b/apps/epg/migrations/0008_epgsource_created_at_epgsource_updated_at.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-04-07 16:29 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0007_populate_periodic_tasks'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Time when this source was created'), + ), + migrations.AddField( + model_name='epgsource', + name='updated_at', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Time when this source was last updated'), + ), + ] diff --git a/apps/epg/migrations/0009_alter_epgsource_created_at_and_more.py b/apps/epg/migrations/0009_alter_epgsource_created_at_and_more.py new file mode 100644 index 00000000..cb8088eb --- /dev/null +++ b/apps/epg/migrations/0009_alter_epgsource_created_at_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-04-07 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0008_epgsource_created_at_epgsource_updated_at'), + ] + + operations = [ + migrations.AlterField( + model_name='epgsource', + name='created_at', + field=models.DateTimeField(auto_now_add=True, help_text='Time when this source was created'), + ), + migrations.AlterField( + model_name='epgsource', + name='updated_at', + field=models.DateTimeField(auto_now=True, help_text='Time when this source was last updated'), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index 3f9b018d..09986bfe 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -17,6 +17,14 @@ class EPGSource(models.Model): refresh_task = models.ForeignKey( PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True ) + created_at = models.DateTimeField( + auto_now_add=True, + help_text="Time when this source was created" + ) + updated_at = models.DateTimeField( + auto_now=True, + help_text="Time when this source was last updated" + ) def __str__(self): return self.name diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index e4a2a4b3..e4ff932e 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -4,10 +4,11 @@ from apps.channels.models import Channel class EPGSourceSerializer(serializers.ModelSerializer): epg_data_ids = serializers.SerializerMethodField() + read_only_fields = ['created_at', 'updated_at'] class Meta: model = EPGSource - fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active', 'epg_data_ids', 'refresh_interval'] + fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active', 'epg_data_ids', 'refresh_interval', 'created_at', 'updated_at'] def get_epg_data_ids(self, obj): return list(obj.epgs.values_list('id', flat=True)) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 3b84df6d..33e981a6 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -50,6 +50,8 @@ def refresh_epg_data(source_id): elif source.source_type == 'schedules_direct': fetch_schedules_direct(source) + source.save(update_fields=['updated_at']) + release_task_lock('refresh_epg_data', source_id) def fetch_xmltv(source): diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index e7dbfcea..d3948145 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -56,7 +56,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): required=True ) profiles = M3UAccountProfileSerializer(many=True, read_only=True) - read_only_fields = ['locked'] + read_only_fields = ['locked', 'created_at', 'updated_at'] # channel_groups = serializers.SerializerMethodField() channel_groups = ChannelGroupM3UAccountSerializer(source='channel_group', many=True, required=False) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index e2de2af3..82dd7864 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -430,6 +430,7 @@ def refresh_single_m3u_account(account_id): # Calculate elapsed time elapsed_time = end_time - start_time + account.save(update_fields=['updated_at']) print(f"Function took {elapsed_time} seconds to execute.") diff --git a/docker/Dockerfile b/docker/Dockerfile index 9a576eeb..e3f8a165 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -48,7 +48,7 @@ ENV PATH="/dispatcharrpy/bin:$PATH" \ # Copy the virtual environment and application from the builder stage COPY --from=builder /dispatcharrpy /dispatcharrpy COPY --from=builder /app /app -COPY --from=frontend-builder /app/frontend /app/frontend +COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist # Run collectstatic after frontend assets are copied RUN cd /app && python manage.py collectstatic --noinput diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx index 93741ef1..2429d1d9 100644 --- a/frontend/src/components/forms/ChannelGroup.jsx +++ b/frontend/src/components/forms/ChannelGroup.jsx @@ -1,40 +1,31 @@ // Modal.js -import React, { useEffect } from 'react'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; +import React from 'react'; import API from '../../api'; import { Flex, TextInput, Button, Modal } from '@mantine/core'; +import { isNotEmpty, useForm } from '@mantine/form'; const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { - const formik = useFormik({ + const form = useForm({ + mode: 'uncontrolled', initialValues: { - name: '', + name: channelGroup ? channelGroup.name : '', }, - validationSchema: Yup.object({ - name: Yup.string().required('Name is required'), - }), - onSubmit: async (values, { setSubmitting, resetForm }) => { - if (channelGroup?.id) { - await API.updateChannelGroup({ id: channelGroup.id, ...values }); - } else { - await API.addChannelGroup(values); - } - resetForm(); - setSubmitting(false); - onClose(); + validate: { + name: isNotEmpty('Specify a name'), }, }); - useEffect(() => { + const onSubmit = async () => { + const values = form.getValues(); if (channelGroup) { - formik.setValues({ - name: channelGroup.name, - }); + await API.updateChannelGroup({ id: channelGroup.id, ...values }); } else { - formik.resetForm(); + await API.addChannelGroup(values); } - }, [channelGroup]); + + return form.reset(); + }; if (!isOpen) { return <>; @@ -42,14 +33,13 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { return ( -
+ @@ -57,7 +47,7 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { type="submit" variant="contained" color="primary" - disabled={formik.isSubmitting} + disabled={form.submitting} size="small" > Submit diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index db19e4a1..a4aaf266 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -1,22 +1,7 @@ // Modal.js -import React, { useState, useEffect } from 'react'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; +import React from 'react'; import API from '../../api'; -import useEPGsStore from '../../store/epgs'; -import { - LoadingOverlay, - TextInput, - Button, - Checkbox, - Modal, - Flex, - NativeSelect, - NumberInput, - Space, - Select, - Alert, -} from '@mantine/core'; +import { Button, Modal, Flex, Select, Alert } from '@mantine/core'; import useChannelsStore from '../../store/channels'; import { DateTimePicker } from '@mantine/dates'; import { CircleAlert } from 'lucide-react'; @@ -61,6 +46,8 @@ const DVR = ({ recording = null, channel = null, isOpen, onClose }) => { ...values, channel: channel_id, }); + + form.reset(); onClose(); }; @@ -110,7 +97,12 @@ const DVR = ({ recording = null, channel = null, isOpen, onClose }) => { /> - diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 599e7c4f..d2520cf7 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -892,7 +892,7 @@ const ChannelsTable = ({}) => { - + diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index b3ad03f7..12d542ae 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -17,6 +17,7 @@ import { import { notifications } from '@mantine/notifications'; import { IconSquarePlus } from '@tabler/icons-react'; import { RefreshCcw, SquareMinus, SquarePen } from 'lucide-react'; +import dayjs from 'dayjs'; const EPGsTable = () => { const [epg, setEPG] = useState(null); @@ -44,6 +45,11 @@ const EPGsTable = () => { accessorKey: 'max_streams', enableSorting: false, }, + { + header: 'Updated', + accessorFn: (row) => dayjs(row.updated_at).format('MMMM D, YYYY h:mma'), + enableSorting: false, + }, ], [] ); diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index f079c98a..95ba9e93 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -16,6 +16,7 @@ import { } from '@mantine/core'; import { SquareMinus, SquarePen, RefreshCcw, Check, X } from 'lucide-react'; import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons +import dayjs from 'dayjs'; const M3UTable = () => { const [playlist, setPlaylist] = useState(null); @@ -70,6 +71,11 @@ const M3UTable = () => { ), }, + { + header: 'Updated', + accessorFn: (row) => dayjs(row.updated_at).format('MMMM D, YYYY h:mma'), + enableSorting: false, + }, ], [] ); diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 9e431d17..4cfecab0 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -503,7 +503,7 @@ const StreamsTable = ({}) => { <> addStreamToChannel(row.original.id)} @@ -522,7 +522,7 @@ const StreamsTable = ({}) => { createChannelFromStream(row.original)} @@ -533,7 +533,7 @@ const StreamsTable = ({}) => { - + From e2850441aeb681c540675dbcb88ebc6422e78286 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 7 Apr 2025 15:01:44 -0400 Subject: [PATCH 110/239] basic DVR UI, custom properties for recordings --- apps/channels/models.py | 1 + frontend/src/App.jsx | 2 + frontend/src/api.js | 26 ++++++ frontend/src/components/Sidebar.jsx | 2 + frontend/src/pages/DVR.jsx | 135 ++++++++++++++++++++++++++++ frontend/src/pages/Guide.jsx | 66 +++++++++++--- frontend/src/store/auth.jsx | 1 + frontend/src/store/channels.jsx | 13 +++ 8 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/DVR.jsx diff --git a/apps/channels/models.py b/apps/channels/models.py index abcecb77..9f1b641e 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -410,6 +410,7 @@ class Recording(models.Model): start_time = models.DateTimeField() end_time = models.DateTimeField() task_id = models.CharField(max_length=255, null=True, blank=True) + custom_properties = models.TextField(null=True, blank=True) def __str__(self): return f"{self.channel.name} - {self.start_time} to {self.end_time}" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4fa2b9a9..a641a53b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import M3U from './pages/M3U'; import EPG from './pages/EPG'; import Guide from './pages/Guide'; import Stats from './pages/Stats'; +import DVR from './pages/DVR'; import Settings from './pages/Settings'; import StreamProfiles from './pages/StreamProfiles'; import useAuthStore from './store/auth'; @@ -127,6 +128,7 @@ const App = () => { element={} /> } /> + } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 9e1dfd55..9d8bf746 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1035,6 +1035,19 @@ export default class API { .updateProfileChannels(channelIds, profileId, enabled); } + static async getRecordings() { + const response = await fetch(`${host}/api/channels/recordings/`, { + headers: { + Authorization: `Bearer ${await API.getAuthToken()}`, + 'Content-Type': 'application/json', + }, + }); + + const retval = await response.json(); + + return retval; + } + static async createRecording(values) { const response = await fetch(`${host}/api/channels/recordings/`, { method: 'POST', @@ -1046,7 +1059,20 @@ export default class API { }); const retval = await response.json(); + useChannelsStore.getState().fetchRecordings(); return retval; } + + static async deleteRecording(id) { + const response = await fetch(`${host}/api/channels/recordings/${id}/`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${await API.getAuthToken()}`, + 'Content-Type': 'application/json', + }, + }); + + useChannelsStore.getState().fetchRecordings(); + } } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 157381f0..00fb4045 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -9,6 +9,7 @@ import { Settings as LucideSettings, Copy, ChartLine, + Video, } from 'lucide-react'; import { Avatar, @@ -80,6 +81,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { path: '/stream-profiles', }, { label: 'TV Guide', icon: , path: '/guide' }, + { label: 'DVR', icon:
- Channel: {channels[recording.channel].name} - - Start: {dayjs(recording.start_time).format('MMMM D, YYYY h:MMa')} - End: {dayjs(recording.end_time).format('MMMM D, YYYY h:MMa')} - + + Channel: + {channels[recording.channel].name} + + + + Start: + + {dayjs(new Date(recording.start_time)).format('MMMM D, YYYY h:MMa')} + + + + End: + + {dayjs(new Date(recording.end_time)).format('MMMM D, YYYY h:MMa')} + +
); }; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index cb442a7a..83e5a2eb 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,13 +1,10 @@ import React, { useEffect } from 'react'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; - import API from '../api'; import useSettingsStore from '../store/settings'; import useUserAgentsStore from '../store/userAgents'; - import useStreamProfilesStore from '../store/streamProfiles'; import { Button, Center, Flex, Paper, Select, Title } from '@mantine/core'; +import { isNotEmpty, useForm } from '@mantine/form'; const SettingsPage = () => { const { settings } = useSettingsStore(); @@ -265,58 +262,51 @@ const SettingsPage = () => { { value: 'zw', label: 'ZW' }, ]; - console.log(settings); - const formik = useFormik({ + const form = useForm({ + mode: 'uncontrolled', initialValues: { - 'default-user-agent': `${settings['default-user-agent'].id}`, - 'default-stream-profile': `${settings['default-stream-profile'].id}`, - 'preferred-region': settings['preferred-region']?.value || 'us', + 'default-user-agent': '', + 'default-stream-profile': '', + 'preferred-region': '', }, - validationSchema: Yup.object({ - 'default-user-agent': Yup.string().required('User-Agent is required'), - 'default-stream-profile': Yup.string().required( - 'Stream Profile is required' - ), - 'preferred-region': Yup.string().required('Region is required'), - }), - onSubmit: async (values, { setSubmitting, resetForm }) => { - console.log(values); - const changedSettings = {}; - for (const settingKey in values) { - // If the user changed the setting’s value from what’s in the DB: - if (String(values[settingKey]) !== String(settings[settingKey].value)) { - changedSettings[settingKey] = values[settingKey]; - } - } - // Update each changed setting in the backend - for (const updatedKey in changedSettings) { - await API.updateSetting({ - ...settings[updatedKey], - value: changedSettings[updatedKey], - }); - } - - setSubmitting(false); - // Don’t necessarily resetForm, in case the user wants to see new values + validate: { + 'default-user-agent': isNotEmpty('Select a channel'), + 'default-stream-profile': isNotEmpty('Select a start time'), + 'preferred-region': isNotEmpty('Select an end time'), }, }); - // Initialize form values once settings / userAgents / profiles are loaded useEffect(() => { - formik.setValues( - Object.values(settings).reduce((acc, setting) => { - // If the setting’s value is numeric, parse it - // Otherwise, just store as string - const possibleNumber = parseInt(setting.value, 10); - acc[setting.key] = isNaN(possibleNumber) - ? setting.value - : possibleNumber; - return acc; - }, {}) - ); - // eslint-disable-next-line - }, [settings, userAgents, streamProfiles]); + if (settings) { + form.setInitialValues( + Object.entries(settings).reduce((acc, [key, value]) => { + // Modify each value based on its own properties + acc[key] = value.value; + return acc; + }, {}) + ); + } + }, [settings]); + + const onSubmit = async () => { + const values = form.getValues(); + const changedSettings = {}; + for (const settingKey in values) { + // If the user changed the setting’s value from what’s in the DB: + if (String(values[settingKey]) !== String(settings[settingKey].value)) { + changedSettings[settingKey] = values[settingKey]; + } + } + + // Update each changed setting in the backend + for (const updatedKey in changedSettings) { + await API.updateSetting({ + ...settings[updatedKey], + value: changedSettings[updatedKey], + }); + } + }; return (
{ Settings - { - e.preventDefault(); // Prevents default form behavior - console.log('Form submission triggered'); - console.log('Formik Errors before submit:', formik.errors); - formik.handleSubmit(e); - console.log('After formik.handleSubmit call'); - }} - > + { - formik.setFieldValue('default-stream-profile', value); - }} - error={formik.errors['default-stream-profile']} data={streamProfiles.map((option) => ({ value: `${option.id}`, label: option.name, }))} /> { - formik.setValues('user_agent', value); - }} - error={formik.errors.user_agent ? formik.touched.user_agent : ''} + {...form.getInputProps('user_agent')} + key={form.key('user_agent')} data={userAgents.map((ua) => ({ label: ua.name, value: `${ua.id}`, @@ -200,24 +193,14 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { { - formik.setFieldValue('refresh_interval', value); - }} - error={ - formik.errors.refresh_interval - ? formik.touched.refresh_interval - : '' - } + {...form.getInputProps('refresh_interval')} + key={form.key('refresh_interval')} /> - formik.setFieldValue('is_active', e.target.checked) - } + {...form.getInputProps('is_active', { type: 'checkbox' })} + key={form.key('is_active')} /> @@ -247,8 +230,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { + + ), color: 'green.5', }); break; case 'm3u_refresh': - if (event.data.success) { - fetchStreams(); - notifications.show({ - message: event.data.message, - color: 'green.5', - }); - } else if (event.data.progress !== undefined) { - if (event.data.progress == 100) { - fetchStreams(); - fetchChannelGroups(); - fetchEPGData(); - fetchPlaylists(); - } - setRefreshProgress(event.data.account, event.data.progress); - } + setRefreshProgress(event.data); break; case 'channel_stats': @@ -154,7 +159,9 @@ export const WebsocketProvider = ({ children }) => { }; }, []); - const ret = [isReady, ws.current?.send.bind(ws.current), val]; + const ret = useMemo(() => { + return [isReady, ws.current?.send.bind(ws.current), val]; + }, [isReady, val]); return ( diff --git a/frontend/src/api.js b/frontend/src/api.js index 5890d731..94183a10 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -7,12 +7,76 @@ import useEPGsStore from './store/epgs'; import useStreamsStore from './store/streams'; import useStreamProfilesStore from './store/streamProfiles'; import useSettingsStore from './store/settings'; +import { notifications } from '@mantine/notifications'; // If needed, you can set a base host or keep it empty if relative requests const host = import.meta.env.DEV ? `http://${window.location.hostname}:5656` : ''; +const errorNotification = (message, error) => { + message = + `${message}: ` + + (error.status ? `${error.status} - ${error.body}` : error.message); + + notifications.show({ + title: 'Error', + message, + autoClose: false, + color: 'red', + }); + + throw error; +}; + +const request = async (url, options = {}) => { + if ( + options.body && + !(options.body instanceof FormData) && + typeof options.body === 'object' + ) { + options.body = JSON.stringify(options.body); + options.headers = { + ...options.headers, + 'Content-Type': 'application/json', + }; + } + + if (options.auth !== false) { + options.headers = { + ...options.headers, + Authorization: `Bearer ${await API.getAuthToken()}`, + }; + } + + const response = await fetch(url, options); + + if (!response.ok) { + const error = new Error(`HTTP error! Status: ${response.status}`); + + let errorBody = await response.text(); + + try { + errorBody = JSON.parse(errorBody); + } catch (e) { + // If parsing fails, leave errorBody as the raw text + } + + error.status = response.status; + error.response = response; + error.body = errorBody; + + throw error; + } + + try { + const retval = await response.json(); + return retval; + } catch (e) { + return ''; + } +}; + export default class API { /** * A static method so we can do: await API.getAuthToken() @@ -22,1136 +86,1051 @@ export default class API { } static async fetchSuperUser() { - const response = await fetch(`${host}/api/accounts/initialize-superuser/`); - return await response.json(); + try { + const response = await request( + `${host}/api/accounts/initialize-superuser/`, + { auth: false } + ); + + return response; + } catch (e) { + errorNotification('Failed to fetch superuser', e); + } } static async createSuperUser({ username, email, password }) { - const response = await fetch(`${host}/api/accounts/initialize-superuser/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username, - password, - email, - }), - }); + try { + const response = await request( + `${host}/api/accounts/initialize-superuser/`, + { + auth: false, + method: 'POST', + body: { + username, + password, + email, + }, + } + ); - return await response.json(); + return response; + } catch (e) { + errorNotification('Failed to create superuser', e); + } } static async login(username, password) { - const response = await fetch(`${host}/api/accounts/token/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ username, password }), - }); + try { + const response = await request(`${host}/api/accounts/token/`, { + auth: false, + method: 'POST', + body: { username, password }, + }); - return await response.json(); + return response; + } catch (e) { + errorNotification('Login failed', e); + } } static async refreshToken(refresh) { - const response = await fetch(`${host}/api/accounts/token/refresh/`, { + return await request(`${host}/api/accounts/token/refresh/`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh }), + body: { auth: false, refresh }, }); - - const retval = await response.json(); - return retval; } static async logout() { - const response = await fetch(`${host}/api/accounts/auth/logout/`, { + return await request(`${host}/api/accounts/auth/logout/`, { + auth: false, method: 'POST', }); - - return response.data.data; } static async getChannels() { - const response = await fetch(`${host}/api/channels/channels/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/channels/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve channels', e); + } + } + + static async queryChannels(params) { + try { + const response = await request( + `${host}/api/channels/channels/?${params.toString()}` + ); + + return response; + } catch (e) { + errorNotification('Failed to fetch channels', e); + } + } + + static async getAllChannelIds(params) { + try { + const response = await request( + `${host}/api/channels/channels/ids/?${params.toString()}` + ); + + return response; + } catch (e) { + errorNotification('Failed to fetch channel IDs', e); + } } static async getChannelGroups() { - const response = await fetch(`${host}/api/channels/groups/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/groups/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve channel groups', e); + } } static async addChannelGroup(values) { - const response = await fetch(`${host}/api/channels/groups/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/channels/groups/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().addChannelGroup(retval); + if (response.id) { + useChannelsStore.getState().addChannelGroup(response); + } + + return response; + } catch (e) { + errorNotification('Failed to create channel group', e); } - - return retval; } static async updateChannelGroup(values) { - const { id, ...payload } = values; - const response = await fetch(`${host}/api/channels/groups/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + try { + const { id, ...payload } = values; + const response = await request(`${host}/api/channels/groups/${id}/`, { + method: 'PUT', + body: payload, + }); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().updateChannelGroup(retval); + if (response.id) { + useChannelsStore.getState().updateChannelGroup(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update channel group', e); } - - return retval; } static async addChannel(channel) { - let body = null; - if (channel.logo_file) { - // Must send FormData for file upload - body = new FormData(); - for (const prop in channel) { - body.append(prop, channel[prop]); + try { + let body = null; + if (channel.logo_file) { + // Must send FormData for file upload + body = new FormData(); + for (const prop in channel) { + body.append(prop, channel[prop]); + } + } else { + body = { ...channel }; + delete body.logo_file; } - } else { - body = { ...channel }; - delete body.logo_file; - body = JSON.stringify(body); + + const response = await request(`${host}/api/channels/channels/`, { + method: 'POST', + body: body, + }); + + if (response.id) { + useChannelsStore.getState().addChannel(response); + } + + return response; + } catch (e) { + errorNotification('Failed to create channel', e); } - - const response = await fetch(`${host}/api/channels/channels/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(channel.logo_file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body: body, - }); - - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().addChannel(retval); - } - - return retval; } static async deleteChannel(id) { - const response = await fetch(`${host}/api/channels/channels/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/channels/channels/${id}/`, { + method: 'DELETE', + }); - useChannelsStore.getState().removeChannels([id]); + useChannelsStore.getState().removeChannels([id]); + } catch (e) { + errorNotification('Failed to delete channel', e); + } } // @TODO: the bulk delete endpoint is currently broken static async deleteChannels(channel_ids) { - const response = await fetch(`${host}/api/channels/channels/bulk-delete/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ channel_ids }), - }); + try { + await request(`${host}/api/channels/channels/bulk-delete/`, { + method: 'DELETE', + body: { channel_ids }, + }); - useChannelsStore.getState().removeChannels(channel_ids); + useChannelsStore.getState().removeChannels(channel_ids); + } catch (e) { + errorNotification('Failed to delete channels', e); + } } static async updateChannel(values) { - const { id, ...payload } = values; + try { + const { id, ...payload } = values; - let body = null; - if (values.logo_file) { - // Must send FormData for file upload - body = new FormData(); - for (const prop in values) { - body.append(prop, values[prop]); + let body = null; + if (payload.logo_file) { + // Must send FormData for file upload + body = new FormData(); + for (const prop in payload) { + body.append(prop, payload[prop]); + } + } else { + body = { ...payload }; + delete body.logo_file; } - } else { - body = { ...values }; - delete body.logo_file; - body = JSON.stringify(body); + + const response = await request(`${host}/api/channels/channels/${id}/`, { + method: 'PUT', + body, + }); + + if (response.id) { + useChannelsStore.getState().updateChannel(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update channel', e); } - - console.log(body); - - const response = await fetch(`${host}/api/channels/channels/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.logo_file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body: body, - }); - - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().updateChannel(retval); - } - - return retval; } static async assignChannelNumbers(channelIds) { - // Make the request - const response = await fetch(`${host}/api/channels/channels/assign/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ channel_order: channelIds }), - }); + try { + const response = await request(`${host}/api/channels/channels/assign/`, { + method: 'POST', + body: { channel_order: channelIds }, + }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`Assign channels failed: ${response.status} => ${text}`); + // Optionally refesh the channel list in Zustand + await useChannelsStore.getState().fetchChannels(); + + return response; + } catch (e) { + errorNotification('Failed to assign channel #s', e); } - - const retval = await response.json(); - - // Optionally refresh the channel list in Zustand - await useChannelsStore.getState().fetchChannels(); - - return retval; } static async createChannelFromStream(values) { - const response = await fetch(`${host}/api/channels/channels/from-stream/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request( + `${host}/api/channels/channels/from-stream/`, + { + method: 'POST', + body: values, + } + ); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().addChannel(retval); + if (response.id) { + useChannelsStore.getState().addChannel(response); + } + + return response; + } catch (e) { + errorNotification('Failed to create channel', e); } - - return retval; } static async createChannelsFromStreams(values) { - const response = await fetch( - `${host}/api/channels/channels/from-stream/bulk/`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), + try { + const response = await request( + `${host}/api/channels/channels/from-stream/bulk/`, + { + method: 'POST', + body: values, + } + ); + + if (response.created.length > 0) { + useChannelsStore.getState().addChannels(response.created); } - ); - const retval = await response.json(); - if (retval.created.length > 0) { - useChannelsStore.getState().addChannels(retval.created); + return response; + } catch (e) { + errorNotification('Failed to create channels', e); } - - return retval; } static async getStreams() { - const response = await fetch(`${host}/api/channels/streams/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/streams/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve streams', e); + } } static async queryStreams(params) { - const response = await fetch( - `${host}/api/channels/streams/?${params.toString()}`, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - } - ); + try { + const response = await request( + `${host}/api/channels/streams/?${params.toString()}` + ); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to fetch streams', e); + } } static async getAllStreamIds(params) { - const response = await fetch( - `${host}/api/channels/streams/ids/?${params.toString()}`, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - } - ); + try { + const response = await request( + `${host}/api/channels/streams/ids/?${params.toString()}` + ); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to fetch stream IDs', e); + } } static async getStreamGroups() { - const response = await fetch(`${host}/api/channels/streams/groups/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/streams/groups/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve stream groups', e); + } } static async addStream(values) { - const response = await fetch(`${host}/api/channels/streams/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/channels/streams/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useStreamsStore.getState().addStream(retval); + if (response.id) { + useStreamsStore.getState().addStream(response); + } + + return response; + } catch (e) { + errorNotification('Failed to add stream', e); } - - return retval; } static async updateStream(values) { - const { id, ...payload } = values; - const response = await fetch(`${host}/api/channels/streams/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + try { + const { id, ...payload } = values; + const response = await request(`${host}/api/channels/streams/${id}/`, { + method: 'PUT', + body: payload, + }); - const retval = await response.json(); - if (retval.id) { - useStreamsStore.getState().updateStream(retval); + if (response.id) { + useStreamsStore.getState().updateStream(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update stream', e); } - - return retval; } static async deleteStream(id) { - const response = await fetch(`${host}/api/channels/streams/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/channels/streams/${id}/`, { + method: 'DELETE', + }); - useStreamsStore.getState().removeStreams([id]); + useStreamsStore.getState().removeStreams([id]); + } catch (e) { + errorNotification('Failed to delete stream', e); + } } static async deleteStreams(ids) { - const response = await fetch(`${host}/api/channels/streams/bulk-delete/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ stream_ids: ids }), - }); + try { + await request(`${host}/api/channels/streams/bulk-delete/`, { + method: 'DELETE', + body: { stream_ids: ids }, + }); - useStreamsStore.getState().removeStreams(ids); + useStreamsStore.getState().removeStreams(ids); + } catch (e) { + errorNotification('Failed to delete streams', e); + } } static async getUserAgents() { - const response = await fetch(`${host}/api/core/useragents/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/core/useragents/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve user-agents', e); + } } static async addUserAgent(values) { - const response = await fetch(`${host}/api/core/useragents/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/core/useragents/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useUserAgentsStore.getState().addUserAgent(retval); + useUserAgentsStore.getState().addUserAgent(response); + + return response; + } catch (e) { + errorNotification('Failed to create user-agent', e); } - - return retval; } static async updateUserAgent(values) { - const { id, ...payload } = values; - const response = await fetch(`${host}/api/core/useragents/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + try { + const { id, ...payload } = values; + const response = await request(`${host}/api/core/useragents/${id}/`, { + method: 'PUT', + body: payload, + }); - const retval = await response.json(); - if (retval.id) { - useUserAgentsStore.getState().updateUserAgent(retval); + useUserAgentsStore.getState().updateUserAgent(response); + + return response; + } catch (e) { + errorNotification('Failed to update user-agent', e); } - - return retval; } static async deleteUserAgent(id) { - const response = await fetch(`${host}/api/core/useragents/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/core/useragents/${id}/`, { + method: 'DELETE', + }); - useUserAgentsStore.getState().removeUserAgents([id]); + useUserAgentsStore.getState().removeUserAgents([id]); + } catch (e) { + errorNotification('Failed to delete user-agent', e); + } } static async getPlaylist(id) { - const response = await fetch(`${host}/api/m3u/accounts/${id}/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/m3u/accounts/${id}/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification(`Failed to retrieve M3U account ${id}`, e); + } } static async getPlaylists() { - const response = await fetch(`${host}/api/m3u/accounts/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/m3u/accounts/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve M3U accounts', e); + } } static async addPlaylist(values) { - let body = null; - if (values.file) { - body = new FormData(); - for (const prop in values) { - body.append(prop, values[prop]); + try { + let body = null; + if (values.file) { + body = new FormData(); + for (const prop in values) { + body.append(prop, values[prop]); + } + } else { + body = { ...values }; + delete body.file; } - } else { - body = { ...values }; - delete body.file; - body = JSON.stringify(body); + + const response = await request(`${host}/api/m3u/accounts/`, { + method: 'POST', + body, + }); + + usePlaylistsStore.getState().addPlaylist(response); + + return response; + } catch (e) { + errorNotification('Failed to create M3U account', e); } - - const response = await fetch(`${host}/api/m3u/accounts/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body, - }); - - const retval = await response.json(); - if (retval.id) { - usePlaylistsStore.getState().addPlaylist(retval); - } - - return retval; } static async refreshPlaylist(id) { - const response = await fetch(`${host}/api/m3u/refresh/${id}/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/m3u/refresh/${id}/`, { + method: 'POST', + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to refresh M3U account', e); + } } static async refreshAllPlaylist() { - const response = await fetch(`${host}/api/m3u/refresh/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/m3u/refresh/`, { + method: 'POST', + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to refresh all M3U accounts', e); + } } static async deletePlaylist(id) { - const response = await fetch(`${host}/api/m3u/accounts/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/m3u/accounts/${id}/`, { + method: 'DELETE', + }); - usePlaylistsStore.getState().removePlaylists([id]); - // @TODO: MIGHT need to optimize this later if someone has thousands of channels - // but I'm feeling laze right now - useChannelsStore.getState().fetchChannels(); + usePlaylistsStore.getState().removePlaylists([id]); + // @TODO: MIGHT need to optimize this later if someone has thousands of channels + // but I'm feeling laze right now + useChannelsStore.getState().fetchChannels(); + } catch (e) { + errorNotification(`Failed to delete playlist ${id}`, e); + } } static async updatePlaylist(values) { const { id, ...payload } = values; - let body = null; - if (payload.file) { - delete payload.server_url; + try { + let body = null; + if (payload.file) { + delete payload.server_url; - body = new FormData(); - for (const prop in values) { - body.append(prop, values[prop]); - } - } else { - delete payload.file; - if (!payload.server_url) { - delete payload.sever_url; + body = new FormData(); + for (const prop in values) { + body.append(prop, values[prop]); + } + } else { + delete payload.file; + if (!payload.server_url) { + delete payload.sever_url; + } + + body = { ...payload }; + delete body.file; } - body = { ...payload }; - delete body.file; - body = JSON.stringify(body); + const response = await request(`${host}/api/m3u/accounts/${id}/`, { + method: 'PATCH', + body, + }); + + usePlaylistsStore.getState().updatePlaylist(response); + + return response; + } catch (e) { + errorNotification(`Failed to update M3U account ${id}`, e); } - - const response = await fetch(`${host}/api/m3u/accounts/${id}/`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body, - }); - - const retval = await response.json(); - if (retval.id) { - usePlaylistsStore.getState().updatePlaylist(retval); - } - - return retval; } static async getEPGs() { - const response = await fetch(`${host}/api/epg/sources/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/epg/sources/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve EPGs', e); + } } static async getEPGData() { - const response = await fetch(`${host}/api/epg/epgdata/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/epg/epgdata/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve EPG data', e); + } } // Notice there's a duplicated "refreshPlaylist" method above; // you might want to rename or remove one if it's not needed. static async addEPG(values) { - let body = null; - if (values.files) { - body = new FormData(); - for (const prop in values) { - body.append(prop, values[prop]); + try { + let body = null; + if (values.files) { + body = new FormData(); + for (const prop in values) { + body.append(prop, values[prop]); + } + } else { + body = { ...values }; + delete body.file; } - } else { - body = { ...values }; - delete body.file; - body = JSON.stringify(body); + + const response = await request(`${host}/api/epg/sources/`, { + method: 'POST', + body, + }); + + useEPGsStore.getState().addEPG(response); + + return response; + } catch (e) { + errorNotification('Failed to create EPG', e); } - - const response = await fetch(`${host}/api/epg/sources/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.epg_file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body, - }); - - const retval = await response.json(); - if (retval.id) { - useEPGsStore.getState().addEPG(retval); - } - - return retval; } static async updateEPG(values) { const { id, ...payload } = values; - let body = null; - if (payload.files) { - body = new FormData(); - for (const prop in payload) { - if (prop == 'url') { - continue; + try { + let body = null; + if (payload.files) { + body = new FormData(); + for (const prop in payload) { + if (prop == 'url') { + continue; + } + body.append(prop, payload[prop]); + } + } else { + delete payload.file; + if (!payload.url) { + delete payload.url; } - body.append(prop, payload[prop]); } - } else { - delete payload.file; - if (!payload.url) { - delete payload.url; - } - body = JSON.stringify(payload); + + const response = await request(`${host}/api/epg/sources/${id}/`, { + method: 'PATCH', + body, + }); + + useEPGsStore.getState().updateEPG(response); + + return response; + } catch (e) { + errorNotification(`Failed to update EPG ${id}`, e); } - - const response = await fetch(`${host}/api/epg/sources/${id}/`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.epg_file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body, - }); - - const retval = await response.json(); - if (retval.id) { - useEPGsStore.getState().updateEPG(retval); - } - - return retval; } static async deleteEPG(id) { - const response = await fetch(`${host}/api/epg/sources/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/epg/sources/${id}/`, { + method: 'DELETE', + }); - useEPGsStore.getState().removeEPGs([id]); + useEPGsStore.getState().removeEPGs([id]); + } catch (e) { + errorNotification(`Failed to delete EPG ${id}`, e); + } } static async refreshEPG(id) { - const response = await fetch(`${host}/api/epg/import/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id }), - }); + try { + const response = await request(`${host}/api/epg/import/`, { + method: 'POST', + body: { id }, + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification(`Failed to refresh EPG ${id}`, e); + } } static async getStreamProfiles() { - const response = await fetch(`${host}/api/core/streamprofiles/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/core/streamprofiles/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve sream profiles', e); + } } static async addStreamProfile(values) { - const response = await fetch(`${host}/api/core/streamprofiles/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/core/streamprofiles/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useStreamProfilesStore.getState().addStreamProfile(retval); + useStreamProfilesStore.getState().addStreamProfile(response); + + return response; + } catch (e) { + errorNotification('Failed to create stream profile', e); } - return retval; } static async updateStreamProfile(values) { const { id, ...payload } = values; - const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - const retval = await response.json(); - if (retval.id) { - useStreamProfilesStore.getState().updateStreamProfile(retval); + try { + const response = await request(`${host}/api/core/streamprofiles/${id}/`, { + method: 'PUT', + body: payload, + }); + + useStreamProfilesStore.getState().updateStreamProfile(response); + + return response; + } catch (e) { + errorNotification(`Failed to update stream profile ${id}`, e); } - - return retval; } static async deleteStreamProfile(id) { - const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/core/streamprofiles/${id}/`, { + method: 'DELETE', + }); - useStreamProfilesStore.getState().removeStreamProfiles([id]); + useStreamProfilesStore.getState().removeStreamProfiles([id]); + } catch (e) { + errorNotification(`Failed to delete stream propfile ${id}`, e); + } } static async getGrid() { - const response = await fetch(`${host}/api/epg/grid/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/epg/grid/`); - const retval = await response.json(); - return retval.data; + return response.data; + } catch (e) { + errorNotification('Failed to retrieve program grid', e); + } } static async addM3UProfile(accountId, values) { - const response = await fetch( - `${host}/api/m3u/accounts/${accountId}/profiles/`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - } - ); + try { + const response = await request( + `${host}/api/m3u/accounts/${accountId}/profiles/`, + { + method: 'POST', + body: values, + } + ); - const retval = await response.json(); - if (retval.id) { // Refresh the playlist const playlist = await API.getPlaylist(accountId); usePlaylistsStore .getState() .updateProfiles(playlist.id, playlist.profiles); - } - return retval; + return response; + } catch (e) { + errorNotification(`Failed to add profile to account ${accountId}`, e); + } } static async deleteM3UProfile(accountId, id) { - const response = await fetch( - `${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, - { + try { + await request(`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, { method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - } - ); + }); - const playlist = await API.getPlaylist(accountId); - usePlaylistsStore.getState().updatePlaylist(playlist); + const playlist = await API.getPlaylist(accountId); + usePlaylistsStore.getState().updatePlaylist(playlist); + } catch (e) { + errorNotification(`Failed to delete profile for account ${accountId}`, e); + } } static async updateM3UProfile(accountId, values) { const { id, ...payload } = values; - const response = await fetch( - `${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - } - ); - const playlist = await API.getPlaylist(accountId); - usePlaylistsStore.getState().updateProfiles(playlist.id, playlist.profiles); + try { + await request(`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, { + method: 'PUT', + body: payload, + }); + + const playlist = await API.getPlaylist(accountId); + usePlaylistsStore + .getState() + .updateProfiles(playlist.id, playlist.profiles); + } catch (e) { + errorNotification(`Failed to update profile for account ${accountId}`, e); + } } static async getSettings() { - const response = await fetch(`${host}/api/core/settings/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/core/settings/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve settings', e); + } } static async getEnvironmentSettings() { - const response = await fetch(`${host}/api/core/settings/env/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/core/settings/env/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve environment settings', e); + } } static async getVersion() { - const response = await fetch(`${host}/api/core/version/`, { - headers: { - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/core/version/`, { + auth: false, + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve version', e); + } } static async updateSetting(values) { const { id, ...payload } = values; - const response = await fetch(`${host}/api/core/settings/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - const retval = await response.json(); - if (retval.id) { - useSettingsStore.getState().updateSetting(retval); + try { + const response = await request(`${host}/api/core/settings/${id}/`, { + method: 'PUT', + body: payload, + }); + + useSettingsStore.getState().updateSetting(response); + + return response; + } catch (e) { + errorNotification('Failed to update settings', e); } - - return retval; } static async getChannelStats(uuid = null) { - const response = await fetch(`${host}/proxy/ts/status`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/proxy/ts/status`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve channel stats', e); + } } static async stopChannel(id) { - const response = await fetch(`${host}/proxy/ts/stop/${id}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/proxy/ts/stop/${id}`, { + method: 'POST', + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to stop channel', e); + } } static async stopClient(channelId, clientId) { - const response = await fetch(`${host}/proxy/ts/stop_client/${channelId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - body: JSON.stringify({ client_id: clientId }), - }); + try { + const response = await request( + `${host}/proxy/ts/stop_client/${channelId}`, + { + method: 'POST', + body: { client_id: clientId }, + } + ); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to stop client', e); + } } static async matchEpg() { - const response = await fetch(`${host}/api/channels/channels/match-epg/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request( + `${host}/api/channels/channels/match-epg/`, + { + method: 'POST', + } + ); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to run EPG auto-match', e); + } } static async getLogos() { - const response = await fetch(`${host}/api/channels/logos/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/logos/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve logos', e); + } } static async uploadLogo(file) { - const formData = new FormData(); - formData.append('file', file); + try { + const formData = new FormData(); + formData.append('file', file); - const response = await fetch(`${host}/api/channels/logos/upload/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - body: formData, - }); + const response = await request(`${host}/api/channels/logos/upload/`, { + method: 'POST', + body: formData, + }); - const retval = await response.json(); + useChannelsStore.getState().addLogo(response); - useChannelsStore.getState().addLogo(retval); - - return retval; + return response; + } catch (e) { + errorNotification('Failed to upload logo', e); + } } static async getChannelProfiles() { - const response = await fetch(`${host}/api/channels/profiles/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/profiles/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to get channel profiles', e); + } } static async addChannelProfile(values) { - const response = await fetch(`${host}/api/channels/profiles/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/channels/profiles/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().addProfile(retval); + useChannelsStore.getState().addProfile(response); + + return response; + } catch (e) { + errorNotification('Failed to create channle profile', e); } - - return retval; } static async updateChannelProfile(values) { const { id, ...payload } = values; - const response = await fetch(`${host}/api/channels/profiles/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().updateProfile(retval); + try { + const response = await request(`${host}/api/channels/profiles/${id}/`, { + method: 'PUT', + body: payload, + }); + + useChannelsStore.getState().updateProfile(response); + + return response; + } catch (e) { + errorNotification('Failed to update channel profile', e); } - - return retval; } static async deleteChannelProfile(id) { - const response = await fetch(`${host}/api/channels/profiles/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/channels/profiles/${id}/`, { + method: 'DELETE', + }); - useChannelsStore.getState().removeProfiles([id]); + useChannelsStore.getState().removeProfiles([id]); + } catch (e) { + errorNotification(`Failed to delete channel profile ${id}`, e); + } } static async updateProfileChannel(channelId, profileId, enabled) { - const response = await fetch( - `${host}/api/channels/profiles/${profileId}/channels/${channelId}/`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ enabled }), - } - ); + try { + await request( + `${host}/api/channels/profiles/${profileId}/channels/${channelId}/`, + { + method: 'PATCH', + body: { enabled }, + } + ); - useChannelsStore - .getState() - .updateProfileChannels([channelId], profileId, enabled); + useChannelsStore + .getState() + .updateProfileChannels([channelId], profileId, enabled); + } catch (e) { + errorNotification(`Failed to update channel for profile ${profileId}`, e); + } } static async updateProfileChannels(channelIds, profileId, enabled) { - const response = await fetch( - `${host}/api/channels/profiles/${profileId}/channels/bulk-update/`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - channels: channelIds.map((id) => ({ - channel_id: id, - enabled, - })), - }), - } - ); + try { + await request( + `${host}/api/channels/profiles/${profileId}/channels/bulk-update/`, + { + method: 'PATCH', + body: { + channels: channelIds.map((id) => ({ + channel_id: id, + enabled, + })), + }, + } + ); - useChannelsStore - .getState() - .updateProfileChannels(channelIds, profileId, enabled); + useChannelsStore + .getState() + .updateProfileChannels(channelIds, profileId, enabled); + } catch (e) { + errorNotification( + `Failed to bulk update channels for profile ${profileId}`, + e + ); + } } static async getRecordings() { - const response = await fetch(`${host}/api/channels/recordings/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/channels/recordings/`); - const retval = await response.json(); - - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve recordings', e); + } } static async createRecording(values) { - const response = await fetch(`${host}/api/channels/recordings/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/channels/recordings/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - useChannelsStore.getState().fetchRecordings(); + useChannelsStore.getState().fetchRecordings(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to create recording', e); + } } static async deleteRecording(id) { - const response = await fetch(`${host}/api/channels/recordings/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/channels/recordings/${id}/`, { + method: 'DELETE', + }); - useChannelsStore.getState().fetchRecordings(); + useChannelsStore.getState().fetchRecordings(); + } catch (e) { + errorNotification(`Failed to delete recording ${id}`, e); + } } } diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 71894c3a..90123dc4 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -1,81 +1,85 @@ // frontend/src/components/FloatingVideo.js -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import usePlaylistsStore from '../store/playlists'; import { notifications } from '@mantine/notifications'; import { IconCheck } from '@tabler/icons-react'; +import useStreamsStore from '../store/streams'; +import useChannelsStore from '../store/channels'; +import useEPGsStore from '../store/epgs'; export default function M3URefreshNotification() { - const { playlists, refreshProgress, removeRefreshProgress } = - usePlaylistsStore(); - const [progress, setProgress] = useState({}); + const { playlists, refreshProgress } = usePlaylistsStore(); + const { fetchStreams } = useStreamsStore(); + const { fetchChannelGroups } = useChannelsStore(); + const { fetchPlaylists } = usePlaylistsStore(); + const { fetchEPGData } = useEPGsStore(); - const clearAccountNotification = (id) => { - removeRefreshProgress(id); - setProgress({ - ...progress, - [id]: null, + const [notificationStatus, setNotificationStatus] = useState({}); + + const handleM3UUpdate = (data) => { + if ( + JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data) + ) { + return; + } + + console.log(data); + const playlist = playlists.find((pl) => pl.id == data.account); + + setNotificationStatus({ + ...notificationStatus, + [data.account]: data, + }); + + const taskProgress = data.progress; + + if (data.progress != 0 && data.progress != 100) { + console.log('not 0 or 100'); + return; + } + + let message = ''; + switch (data.action) { + case 'downloading': + message = 'Downloading'; + break; + + case 'parsing': + message = 'Stream parsing'; + break; + + case 'processing_groups': + message = 'Group parsing'; + break; + } + + if (taskProgress == 0) { + message = `${message} starting...`; + } else if (taskProgress == 100) { + message = `${message} complete!`; + + if (data.action == 'parsing') { + fetchStreams(); + } else if (data.action == 'processing_groups') { + fetchStreams(); + fetchChannelGroups(); + fetchEPGData(); + fetchPlaylists(); + } + } + + notifications.show({ + title: `M3U Processing: ${playlist.name}`, + message, + loading: taskProgress == 0, + autoClose: 2000, + icon: taskProgress == 100 ? : null, }); }; - for (const id in refreshProgress) { - const playlist = playlists.find((pl) => pl.id == id); - if (!progress[id]) { - if (refreshProgress[id] == 100) { - // This situation is if it refreshes so fast we only get the 100% complete notification - const notificationId = notifications.show({ - loading: false, - title: `M3U Refresh: ${playlist.name}`, - message: `Refresh complete!`, - icon: , - }); - setProgress({ - ...progress, - [id]: notificationId, - }); - setTimeout(() => clearAccountNotification(id), 2000); - - return; - } - - const notificationId = notifications.show({ - loading: true, - title: `M3U Refresh`, - message: `Starting...`, - autoClose: false, - withCloseButton: false, - }); - - setProgress({ - ...progress, - ...(playlist && { - title: `M3U Refresh: ${playlist.name}`, - }), - [id]: notificationId, - }); - } else { - if (refreshProgress[id] == 0) { - notifications.update({ - id: progress[id], - message: `Starting...`, - }); - } else if (refreshProgress[id] == 100) { - notifications.update({ - id: progress[id], - message: `Refresh complete!`, - loading: false, - autoClose: 2000, - icon: , - }); - - setTimeout(() => clearAccountNotification(id), 2000); - } else { - notifications.update({ - id: progress[id], - message: `Updating M3U: ${refreshProgress[id]}%`, - }); - } - } - } + useEffect(() => { + Object.values(refreshProgress).map((data) => handleM3UUpdate(data)); + }, [playlists, refreshProgress]); return <>; } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index a1ca5601..ea33f8b8 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -71,16 +71,17 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { // Fetch environment settings including version on component mount useEffect(() => { + if (!isAuthenticated) { + return; + } + const fetchEnvironment = async () => { - try { - const envData = await API.getEnvironmentSettings(); - } catch (error) { - console.error('Failed to fetch environment settings:', error); - } + API.getEnvironmentSettings(); }; fetchEnvironment(); - }, []); + }, [isAuthenticated]); + // Fetch version information on component mount (regardless of authentication) useEffect(() => { const fetchVersion = async () => { @@ -88,7 +89,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const versionData = await API.getVersion(); setAppVersion({ version: versionData.version || '', - build: versionData.build || '' + build: versionData.build || '', }); } catch (error) { console.error('Failed to fetch version information:', error); @@ -223,7 +224,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { {environment.country_name ) } @@ -263,7 +266,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { {/* Version is always shown when sidebar is expanded, regardless of auth status */} {!collapsed && ( - v{appVersion?.version || '0.0.0'}{appVersion?.build !== '0' ? `-${appVersion?.build}` : ''} + v{appVersion?.version || '0.0.0'} + {appVersion?.build !== '0' ? `-${appVersion?.build}` : ''} )} diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index c063360b..a3cfe992 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -487,7 +487,13 @@ const Channel = ({ channel = null, isOpen, onClose }) => { label={ EPG - diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index 8eb8c183..2cc40988 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -1,17 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../store/auth'; -import { - Paper, - Title, - TextInput, - Button, - Checkbox, - Modal, - Box, - Center, - Stack, -} from '@mantine/core'; +import { Paper, Title, TextInput, Button, Center, Stack } from '@mantine/core'; const LoginForm = () => { const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index d2520cf7..508ecea2 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -399,6 +399,12 @@ const ChannelsTable = ({}) => { size: 50, maxSize: 50, accessorKey: 'channel_number', + sortingFn: (a, b, columnId) => { + return ( + parseInt(a.original.channel_number) - + parseInt(b.original.channel_number) + ); + }, mantineTableHeadCellProps: { align: 'right', // // style: { @@ -469,11 +475,11 @@ const ChannelsTable = ({}) => { placeholder="Group" searchable size="xs" - nothingFound="No options" - // onChange={(e, value) => { - // e.stopPropagation(); - // handleGroupChange(value); - // }} + nothingFoundMessage="No options" + onChange={(value) => { + // e.stopPropagation(); + handleFilterChange(column.id, value); + }} data={channelGroupOptions} variant="unstyled" className="table-input-header" @@ -485,16 +491,15 @@ const ChannelsTable = ({}) => { header: '', accessorKey: 'logo', enableSorting: false, - size: 55, + size: 75, mantineTableBodyCellProps: { align: 'center', style: { - maxWidth: '55px', + maxWidth: '75px', }, }, Cell: ({ cell }) => ( { }; const deleteChannel = async (id) => { + setRowSelection([]); if (channelsPageSelection.length > 0) { return deleteChannels(); } @@ -747,10 +753,6 @@ const ChannelsTable = ({}) => { id: 'channel_number', desc: false, }, - { - id: 'name', - desc: false, - }, ], }, enableRowActions: true, diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 546d6a02..31b71077 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -149,7 +149,7 @@ const EPGsTable = () => { ), mantineTableContainerProps: { style: { - height: 'calc(40vh - 0px)', + height: 'calc(40vh - 10px)', }, }, displayColumnDefOptions: { diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 95ba9e93..c4bd273a 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -26,10 +26,64 @@ const M3UTable = () => { const [activeFilterValue, setActiveFilterValue] = useState('all'); const [playlistCreated, setPlaylistCreated] = useState(false); - const { playlists, setRefreshProgress } = usePlaylistsStore(); + const { playlists, refreshProgress, setRefreshProgress } = + usePlaylistsStore(); const theme = useMantineTheme(); + const generateStatusString = (data) => { + if (data.progress == 100) { + return 'Idle'; + } + + switch (data.action) { + case 'downloading': + return buildDownloadingStats(data); + + case 'processing_groups': + return 'Processing groups...'; + + default: + return buildParsingStats(data); + } + }; + + const buildDownloadingStats = (data) => { + if (data.progress == 100) { + // fetchChannelGroups(); + // fetchPlaylists(); + return 'Download complete!'; + } + + if (data.progress == 0) { + return 'Downloading...'; + } + + return ( + + Downloading: {parseInt(data.progress)}% + {/* Speed: {parseInt(data.speed)} KB/s + Time Remaining: {parseInt(data.time_remaining)} */} + + ); + }; + + const buildParsingStats = (data) => { + if (data.progress == 100) { + // fetchStreams(); + // fetchChannelGroups(); + // fetchEPGData(); + // fetchPlaylists(); + return 'Parsing complete!'; + } + + if (data.progress == 0) { + return 'Parsing...'; + } + + return `Parsing: ${data.progress}%`; + }; + const columns = useMemo( //column definitions... () => [ @@ -57,6 +111,20 @@ const M3UTable = () => { accessorKey: 'max_streams', size: 200, }, + { + header: 'Status', + accessorFn: (row) => { + if (!row.id) { + return ''; + } + if (!refreshProgress[row.id]) { + return 'Idle'; + } + + return generateStatusString(refreshProgress[row.id]); + }, + size: 200, + }, { header: 'Active', accessorKey: 'is_active', @@ -77,7 +145,7 @@ const M3UTable = () => { enableSorting: false, }, ], - [] + [refreshProgress] ); //optionally access the underlying virtualizer instance @@ -190,7 +258,7 @@ const M3UTable = () => { ), mantineTableContainerProps: { style: { - height: 'calc(40vh - 0px)', + height: 'calc(40vh - 10px)', }, }, }); diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx index d6706239..2f5a6f9b 100644 --- a/frontend/src/components/tables/StreamProfilesTable.jsx +++ b/frontend/src/components/tables/StreamProfilesTable.jsx @@ -17,6 +17,7 @@ import { useMantineTheme, Center, Switch, + Stack, } from '@mantine/core'; import { IconSquarePlus } from '@tabler/icons-react'; import { SquareMinus, SquarePen, Check, X, Eye, EyeOff } from 'lucide-react'; @@ -212,19 +213,19 @@ const StreamProfiles = () => { ), mantineTableContainerProps: { style: { - height: 'calc(100vh - 120px)', + height: 'calc(60vh - 100px)', overflowY: 'auto', }, }, }); return ( - + @@ -305,7 +306,7 @@ const StreamProfiles = () => { isOpen={profileModalOpen} onClose={closeStreamProfileForm} /> - + ); }; diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 4cfecab0..ab0f4c2e 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -140,16 +140,13 @@ const StreamsTable = ({}) => { placeholder="Group" searchable size="xs" - nothingFound="No options" + nothingFoundMessage="No options" onClick={handleSelectClick} onChange={handleGroupChange} data={groupOptions} variant="unstyled" className="table-input-header custom-multiselect" clearable - valueComponent={({ value }) => { - return
foo
; // Override to display custom text - }} /> ), @@ -176,7 +173,7 @@ const StreamsTable = ({}) => { placeholder="M3U" searchable size="xs" - nothingFound="No options" + nothingFoundMessage="No options" onClick={handleSelectClick} onChange={handleM3UChange} data={playlists.map((playlist) => ({ @@ -568,24 +565,47 @@ const StreamsTable = ({}) => { }, displayColumnDefOptions: { 'mrt-row-actions': { - size: 30, mantineTableHeadCellProps: { + align: 'left', style: { - paddingLeft: 4, - backgroundColor: '#3F3F46', + minWidth: '65px', + maxWidth: '65px', + paddingLeft: 10, fontWeight: 'normal', color: 'rgb(207,207,207)', + backgroundColor: '#3F3F46', }, }, mantineTableBodyCellProps: { style: { - paddingLeft: 0, - paddingRight: 0, + minWidth: '65px', + maxWidth: '65px', + // paddingLeft: 0, + // paddingRight: 10, }, }, }, 'mrt-row-select': { - size: 20, + size: 10, + maxSize: 10, + mantineTableHeadCellProps: { + align: 'right', + style: { + paddding: 0, + // paddingLeft: 7, + width: '20px', + minWidth: '20px', + backgroundColor: '#3F3F46', + }, + }, + mantineTableBodyCellProps: { + align: 'right', + style: { + paddingLeft: 0, + width: '20px', + minWidth: '20px', + }, + }, }, }, }); diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx index b4e55426..2ea2d3b7 100644 --- a/frontend/src/components/tables/UserAgentsTable.jsx +++ b/frontend/src/components/tables/UserAgentsTable.jsx @@ -16,6 +16,7 @@ import { Paper, Box, Button, + Stack, } from '@mantine/core'; import { IconSquarePlus } from '@tabler/icons-react'; import { SquareMinus, SquarePen, Check, X } from 'lucide-react'; @@ -35,6 +36,7 @@ const UserAgentsTable = () => { { header: 'Name', accessorKey: 'name', + size: 100, }, { header: 'User-Agent', @@ -219,7 +221,9 @@ const UserAgentsTable = () => { ), mantineTableContainerProps: { style: { - height: 'calc(43vh - 55px)', + height: 'calc(60vh - 100px)', + overflowY: 'auto', + // margin: 5, }, }, displayColumnDefOptions: { @@ -230,15 +234,17 @@ const UserAgentsTable = () => { }); return ( - <> + { lineHeight: 1, letterSpacing: '-0.3px', color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary - marginBottom: 0, + // marginBottom: 0, }} > User-Agents @@ -307,7 +313,7 @@ const UserAgentsTable = () => { isOpen={userAgentModalOpen} onClose={closeUserAgentForm} /> - + ); }; diff --git a/frontend/src/pages/M3U.jsx b/frontend/src/pages/ContentSources.jsx similarity index 69% rename from frontend/src/pages/M3U.jsx rename to frontend/src/pages/ContentSources.jsx index acc883e8..eb62fe49 100644 --- a/frontend/src/pages/M3U.jsx +++ b/frontend/src/pages/ContentSources.jsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import useUserAgentsStore from '../store/userAgents'; import M3UsTable from '../components/tables/M3UsTable'; -import UserAgentsTable from '../components/tables/UserAgentsTable'; -import { Box } from '@mantine/core'; +import EPGsTable from '../components/tables/EPGsTable'; +import { Box, Stack } from '@mantine/core'; const M3UPage = () => { const isLoading = useUserAgentsStore((state) => state.isLoading); @@ -12,13 +12,9 @@ const M3UPage = () => { if (error) return
Error: {error}
; return ( - @@ -26,9 +22,9 @@ const M3UPage = () => { - + - + ); }; diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index bcc2c238..d40f64d6 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -80,7 +80,10 @@ export default function TVChannelGuide({ startDate, endDate }) { const filteredChannels = Object.values(channels) .filter((ch) => programIds.includes(ch.epg_data?.tvg_id)) // Add sorting by channel_number - .sort((a, b) => (a.channel_number || Infinity) - (b.channel_number || Infinity)); + .sort( + (a, b) => + (a.channel_number || Infinity) - (b.channel_number || Infinity) + ); console.log( `found ${filteredChannels.length} channels with matching tvg_ids` @@ -105,15 +108,15 @@ export default function TVChannelGuide({ startDate, endDate }) { // Apply search filter if (searchQuery) { const query = searchQuery.toLowerCase(); - result = result.filter(channel => + result = result.filter((channel) => channel.name.toLowerCase().includes(query) ); } // Apply channel group filter if (selectedGroupId !== 'all') { - result = result.filter(channel => - channel.channel_group?.id === parseInt(selectedGroupId) + result = result.filter( + (channel) => channel.channel_group?.id === parseInt(selectedGroupId) ); } @@ -122,16 +125,22 @@ export default function TVChannelGuide({ startDate, endDate }) { // Get the profile's enabled channels const profileChannels = profiles[selectedProfileId]?.channels || []; const enabledChannelIds = profileChannels - .filter(pc => pc.enabled) - .map(pc => pc.id); + .filter((pc) => pc.enabled) + .map((pc) => pc.id); - result = result.filter(channel => + result = result.filter((channel) => enabledChannelIds.includes(channel.id) ); } setFilteredChannels(result); - }, [searchQuery, selectedGroupId, selectedProfileId, guideChannels, profiles]); + }, [ + searchQuery, + selectedGroupId, + selectedProfileId, + guideChannels, + profiles, + ]); // Use start/end from props or default to "today at midnight" +24h const defaultStart = dayjs(startDate || dayjs().startOf('day')); @@ -213,7 +222,7 @@ export default function TVChannelGuide({ startDate, endDate }) { hours.push({ time: current, isNewDay, - dayLabel: formatDayLabel(current) + dayLabel: formatDayLabel(current), }); current = current.add(1, 'hour'); @@ -223,12 +232,21 @@ export default function TVChannelGuide({ startDate, endDate }) { // Scroll to the nearest half-hour mark ONLY on initial load useEffect(() => { - if (guideRef.current && timelineRef.current && programs.length > 0 && !initialScrollComplete) { + if ( + guideRef.current && + timelineRef.current && + programs.length > 0 && + !initialScrollComplete + ) { // Round the current time to the nearest half-hour mark - const roundedNow = now.minute() < 30 ? now.startOf('hour') : now.startOf('hour').add(30, 'minute'); + const roundedNow = + now.minute() < 30 + ? now.startOf('hour') + : now.startOf('hour').add(30, 'minute'); const nowOffset = roundedNow.diff(start, 'minute'); const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; + (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - + MINUTE_BLOCK_WIDTH; const scrollPos = Math.max(scrollPosition, 0); guideRef.current.scrollLeft = scrollPos; @@ -345,19 +363,22 @@ export default function TVChannelGuide({ startDate, endDate }) { const currentScrollPosition = guideRef.current.scrollLeft; // Check if we need to scroll (if program start is before current view or too close to edge) - if (desiredScrollPosition < currentScrollPosition || - leftPx - currentScrollPosition < 100) { // 100px from left edge + if ( + desiredScrollPosition < currentScrollPosition || + leftPx - currentScrollPosition < 100 + ) { + // 100px from left edge // Smooth scroll to the program's start guideRef.current.scrollTo({ left: desiredScrollPosition, - behavior: 'smooth' + behavior: 'smooth', }); // Also sync the timeline scroll timelineRef.current.scrollTo({ left: desiredScrollPosition, - behavior: 'smooth' + behavior: 'smooth', }); } } @@ -375,10 +396,14 @@ export default function TVChannelGuide({ startDate, endDate }) { const scrollToNow = () => { if (guideRef.current && timelineRef.current && nowPosition >= 0) { // Round the current time to the nearest half-hour mark - const roundedNow = now.minute() < 30 ? now.startOf('hour') : now.startOf('hour').add(30, 'minute'); + const roundedNow = + now.minute() < 30 + ? now.startOf('hour') + : now.startOf('hour').add(30, 'minute'); const nowOffset = roundedNow.diff(start, 'minute'); const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; + (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - + MINUTE_BLOCK_WIDTH; const scrollPos = Math.max(scrollPosition, 0); guideRef.current.scrollLeft = scrollPos; @@ -410,7 +435,8 @@ export default function TVChannelGuide({ startDate, endDate }) { const scrollAmount = e.shiftKey ? 250 : 125; // Scroll horizontally based on wheel direction - timelineRef.current.scrollLeft += e.deltaY > 0 ? scrollAmount : -scrollAmount; + timelineRef.current.scrollLeft += + e.deltaY > 0 ? scrollAmount : -scrollAmount; // Sync the main content scroll position if (guideRef.current) { @@ -457,7 +483,8 @@ export default function TVChannelGuide({ startDate, endDate }) { const snappedOffset = snappedTime.diff(start, 'minute'); // Convert to pixels - const scrollPosition = (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; + const scrollPosition = + (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; // Scroll both containers to the snapped position timelineRef.current.scrollLeft = scrollPosition; @@ -476,7 +503,8 @@ export default function TVChannelGuide({ startDate, endDate }) { // Calculate width with a small gap (2px on each side) const gapSize = 2; - const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - (gapSize * 2); + const widthPx = + (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - gapSize * 2; // Check if we have a recording for this program const recording = recordings.find((recording) => { @@ -499,7 +527,10 @@ export default function TVChannelGuide({ startDate, endDate }) { const isExpanded = expandedProgramId === program.id; // Calculate how much of the program is cut off - const cutOffMinutes = Math.max(0, channelStart.diff(programStart, 'minute')); + const cutOffMinutes = Math.max( + 0, + channelStart.diff(programStart, 'minute') + ); const cutOffPx = (cutOffMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; // Set the height based on expanded state @@ -522,7 +553,9 @@ export default function TVChannelGuide({ startDate, endDate }) { height: rowHeight - 4, // Adjust for the parent row padding cursor: 'pointer', zIndex: isExpanded ? 25 : 5, // Increase z-index when expanded - transition: isExpanded ? 'height 0.2s ease, width 0.2s ease' : 'height 0.2s ease', + transition: isExpanded + ? 'height 0.2s ease, width 0.2s ease' + : 'height 0.2s ease', }} onClick={(e) => handleProgramClick(program, e)} > @@ -530,7 +563,7 @@ export default function TVChannelGuide({ startDate, endDate }) { elevation={isExpanded ? 4 : 2} className={`guide-program ${isLive ? 'live' : isPast ? 'past' : 'not-live'} ${isExpanded ? 'expanded' : ''}`} style={{ - width: "100%", // Fill container width (which may be expanded) + width: '100%', // Fill container width (which may be expanded) height: '100%', overflow: 'hidden', position: 'relative', @@ -542,12 +575,12 @@ export default function TVChannelGuide({ startDate, endDate }) { ? isLive ? '#1a365d' // Darker blue when expanded and live : isPast - ? '#2d3748' // Darker gray when expanded and past + ? '#18181B' // Darker gray when expanded and past : '#1e40af' // Darker blue when expanded and upcoming : isLive - ? '#2d3748' // Default live program color + ? '#18181B' // Default live program color : isPast - ? '#4a5568' // Slightly darker color for past programs + ? '#27272A' // Slightly darker color for past programs : '#2c5282', // Default color for upcoming programs color: isPast ? '#a0aec0' : '#fff', // Dim text color for past programs boxShadow: isExpanded ? '0 4px 8px rgba(0,0,0,0.4)' : 'none', @@ -556,7 +589,7 @@ export default function TVChannelGuide({ startDate, endDate }) { > 0) { // Get unique channel group IDs from the channels that have program data const usedGroupIds = new Set(); - guideChannels.forEach(channel => { + guideChannels.forEach((channel) => { if (channel.channel_group?.id) { usedGroupIds.add(channel.channel_group.id); } @@ -665,12 +698,12 @@ export default function TVChannelGuide({ startDate, endDate }) { // Only add groups that are actually used by channels in the guide Object.values(channelGroups) - .filter(group => usedGroupIds.has(group.id)) + .filter((group) => usedGroupIds.has(group.id)) .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically - .forEach(group => { + .forEach((group) => { options.push({ value: group.id.toString(), - label: group.name + label: group.name, }); }); } @@ -683,11 +716,12 @@ export default function TVChannelGuide({ startDate, endDate }) { const options = [{ value: 'all', label: 'All Profiles' }]; if (profiles) { - Object.values(profiles).forEach(profile => { - if (profile.id !== '0') { // Skip the 'All' default profile + Object.values(profiles).forEach((profile) => { + if (profile.id !== '0') { + // Skip the 'All' default profile options.push({ value: profile.id.toString(), - label: profile.name + label: profile.name, }); } }); @@ -720,7 +754,7 @@ export default function TVChannelGuide({ startDate, endDate }) { overflow: 'hidden', width: '100%', height: '100%', - backgroundColor: '#1a202c', + // backgroundColor: 'rgb(39, 39, 42)', color: '#fff', fontFamily: 'Roboto, sans-serif', }} @@ -730,7 +764,7 @@ export default function TVChannelGuide({ startDate, endDate }) { } rightSection={ searchQuery ? ( - setSearchQuery('')} variant="subtle" color="gray" size="sm"> + setSearchQuery('')} + variant="subtle" + color="gray" + size="sm" + > ) : null @@ -794,20 +833,29 @@ export default function TVChannelGuide({ startDate, endDate }) { clearable={true} // Allow clearing the selection /> - {(searchQuery !== '' || selectedGroupId !== 'all' || selectedProfileId !== 'all') && ( + {(searchQuery !== '' || + selectedGroupId !== 'all' || + selectedProfileId !== 'all') && ( )} - {filteredChannels.length} {filteredChannels.length === 1 ? 'channel' : 'channels'} + {filteredChannels.length}{' '} + {filteredChannels.length === 1 ? 'channel' : 'channels'} {/* Guide container with headers and scrollable content */} - + {/* Logo header - Sticky, non-scrollable */} @@ -870,10 +918,10 @@ export default function TVChannelGuide({ startDate, endDate }) { height: '40px', position: 'relative', color: '#a0aec0', - borderRight: '1px solid #4a5568', + borderRight: '1px solid #8DAFAA', cursor: 'pointer', - borderLeft: isNewDay ? '2px solid #4299e1' : 'none', // Highlight day boundaries - backgroundColor: isNewDay ? 'rgba(66, 153, 225, 0.05)' : '#171923', // Subtle background for new days + borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries + backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days }} onClick={(e) => handleTimeClick(time, e)} > @@ -900,10 +948,11 @@ export default function TVChannelGuide({ startDate, endDate }) { display: 'block', opacity: 0.7, fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions - color: isNewDay ? '#4299e1' : undefined, + color: isNewDay ? '#3BA882' : undefined, }} > - {formatDayLabel(time)} {/* Use same formatDayLabel function for all hours */} + {formatDayLabel(time)}{' '} + {/* Use same formatDayLabel function for all hours */} {time.format('h:mm')} @@ -919,7 +968,7 @@ export default function TVChannelGuide({ startDate, endDate }) { top: 0, bottom: 0, width: '1px', - backgroundColor: '#4a5568', + backgroundColor: '#27272A', zIndex: 10, }} /> @@ -969,12 +1018,14 @@ export default function TVChannelGuide({ startDate, endDate }) { onScroll={handleGuideScroll} > {/* Content wrapper with min-width to ensure scroll range */} - + {/* Now line - positioned absolutely within content */} {nowPosition >= 0 && ( p.tvg_id === channel.epg_data?.tvg_id ); // Check if any program in this channel is expanded - const hasExpandedProgram = channelPrograms.some(prog => prog.id === expandedProgramId); - const rowHeight = hasExpandedProgram ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; + const hasExpandedProgram = channelPrograms.some( + (prog) => prog.id === expandedProgramId + ); + const rowHeight = hasExpandedProgram + ? EXPANDED_PROGRAM_HEIGHT + : PROGRAM_HEIGHT; return ( - {/* Changed from Video to Play and increased size */} + {' '} + {/* Changed from Video to Play and increased size */} )} @@ -1108,11 +1164,11 @@ export default function TVChannelGuide({ startDate, endDate }) { bottom: '4px', left: '50%', transform: 'translateX(-50%)', - backgroundColor: '#2d3748', + backgroundColor: '#18181B', padding: '2px 8px', borderRadius: 4, fontSize: '0.85em', - border: '1px solid #4a5568', + border: '1px solid #27272A', height: '24px', display: 'flex', alignItems: 'center', @@ -1126,14 +1182,18 @@ export default function TVChannelGuide({ startDate, endDate }) { {/* Programs for this channel */} - - {channelPrograms.map((prog) => renderProgram(prog, start))} + + {channelPrograms.map((prog) => + renderProgram(prog, start) + )} ); @@ -1160,4 +1220,3 @@ export default function TVChannelGuide({ startDate, endDate }) { ); } - diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index dcc99fe9..81a85c78 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -3,8 +3,20 @@ import API from '../api'; import useSettingsStore from '../store/settings'; import useUserAgentsStore from '../store/userAgents'; import useStreamProfilesStore from '../store/streamProfiles'; -import { Button, Center, Flex, Paper, Select, Title } from '@mantine/core'; +import { + Box, + Button, + Center, + Flex, + Group, + Paper, + Select, + Stack, + Title, +} from '@mantine/core'; import { isNotEmpty, useForm } from '@mantine/form'; +import UserAgentsTable from '../components/tables/UserAgentsTable'; +import StreamProfilesTable from '../components/tables/StreamProfilesTable'; const SettingsPage = () => { const { settings } = useSettingsStore(); @@ -309,65 +321,81 @@ const SettingsPage = () => { }; return ( -
- +
- - Settings - - - ({ + value: `${option.id}`, + label: option.name, + }))} + /> - ({ - label: r.label, - value: `${r.value}`, - }))} - /> + ({ + label: r.label, + value: `${r.value}`, + }))} + /> - - - - - -
+ + + + +
+
+ + + + + + ); }; diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 93ae58ce..4c34be06 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -33,6 +33,7 @@ import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; import { Sparkline } from '@mantine/charts'; import useStreamProfilesStore from '../store/streamProfiles'; +import { useLocation } from 'react-router-dom'; dayjs.extend(duration); dayjs.extend(relativeTime); @@ -75,6 +76,8 @@ const getStartDate = (uptime) => { // Create a separate component for each channel card to properly handle the hook const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => { + const location = useLocation(); + const clientsColumns = useMemo( () => [ { @@ -90,7 +93,9 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => { const channelClientsTable = useMantineReactTable({ ...TableHelper.defaultProperties, columns: clientsColumns, - data: clients.filter(client => client.channel.channel_id === channel.channel_id), + data: clients.filter( + (client) => client.channel.channel_id === channel.channel_id + ), enablePagination: false, enableTopToolbar: false, enableBottomToolbar: false, @@ -140,6 +145,10 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => { }, }); + if (location.pathname != '/stats') { + return <>; + } + return ( { > - channel logo + channel logo diff --git a/frontend/src/pages/guide.css b/frontend/src/pages/guide.css index 5b6568c1..600bb449 100644 --- a/frontend/src/pages/guide.css +++ b/frontend/src/pages/guide.css @@ -7,22 +7,22 @@ white-space: nowrap; text-overflow: ellipsis; border-radius: 8px; - background: linear-gradient(to right, #2d3748, #2d3748); + background: linear-gradient(to right, #2D2D2F, #1F1F20); /* Default background */ color: #fff; transition: all 0.2s ease-out; } .tv-guide .guide-program-container .guide-program.live { - background: linear-gradient(to right, #1e3a8a, #2c5282); + background: linear-gradient(to right, #3BA882, #245043); } .tv-guide .guide-program-container .guide-program.live:hover { - background: linear-gradient(to right, #1e3a8a, #2a4365); + background: linear-gradient(to right, #2E9E80, #206E5E); } .tv-guide .guide-program-container .guide-program.not-live:hover { - background: linear-gradient(to right, #2d3748, #1a202c); + background: linear-gradient(to right, #2F3F3A, #1E2926); } /* New styles for expanded programs */ @@ -33,15 +33,15 @@ } .tv-guide .guide-program-container .guide-program.expanded.live { - background: linear-gradient(to right, #1a365d, #1e40af); + background: linear-gradient(to right, #226F5D, #3BA882); } .tv-guide .guide-program-container .guide-program.expanded.not-live { - background: linear-gradient(to right, #1a365d, #1e3a8a); + background: linear-gradient(to right, #2C3F3A, #206E5E); } .tv-guide .guide-program-container .guide-program.expanded.past { - background: linear-gradient(to right, #2d3748, #4a5568); + background: linear-gradient(to right, #1F2423, #2F3A37); } /* Ensure channel logo is always on top */ @@ -66,4 +66,4 @@ /* Make sure the main scrolling container establishes the right stacking context */ .tv-guide { position: relative; -} \ No newline at end of file +} diff --git a/frontend/src/store/playlists.jsx b/frontend/src/store/playlists.jsx index c723e8c1..e04263e7 100644 --- a/frontend/src/store/playlists.jsx +++ b/frontend/src/store/playlists.jsx @@ -65,11 +65,11 @@ const usePlaylistsStore = create((set) => ({ // @TODO: remove playlist profiles here })), - setRefreshProgress: (id, progress) => + setRefreshProgress: (data) => set((state) => ({ refreshProgress: { ...state.refreshProgress, - [id]: progress, + [data.account]: data, }, })), diff --git a/frontend/src/utils.js b/frontend/src/utils.js index b765c405..a4bb163d 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -51,3 +51,9 @@ export function useDebounce(value, delay = 500) { return debouncedValue; } + +export function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} From 9710560fad6a1649d44a280ec7dafd27b0a8f9b9 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 17:22:24 +0000 Subject: [PATCH 195/239] Increment build number to 13 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index aa2de582..c7e64b66 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '12' # Auto-incremented on builds +__build__ = '13' # Auto-incremented on builds From d647ff16d583a2b14d42bcc4ac7b49ec67f4863f Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 13:26:13 -0400 Subject: [PATCH 196/239] updated new link / page --- frontend/src/components/Sidebar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index ea33f8b8..e74b6392 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -107,7 +107,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { path: '/channels', badge: `(${Object.keys(channels).length})`, }, - { label: 'M3U', icon: , path: '/m3u' }, + { label: 'M3U & EPG Manager', icon: , path: '/sources' }, { label: 'EPG', icon: , path: '/epg' }, { label: 'Stream Profiles', From e04c0a49ee4d2f3d91efd0bc929ca22aa3b63d6d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 17:26:34 +0000 Subject: [PATCH 197/239] Increment build number to 14 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index c7e64b66..5ce3238f 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '13' # Auto-incremented on builds +__build__ = '14' # Auto-incremented on builds From 804dbe85915674e067a8bf1382df410e25ac1f6b Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 13:26:48 -0400 Subject: [PATCH 198/239] removed old navlink --- frontend/src/components/Sidebar.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index e74b6392..e875c05b 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -108,7 +108,6 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { badge: `(${Object.keys(channels).length})`, }, { label: 'M3U & EPG Manager', icon: , path: '/sources' }, - { label: 'EPG', icon: , path: '/epg' }, { label: 'Stream Profiles', icon: , From bd48ac289b829d2be0f6b5157dc1ee572ec8c9eb Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 17:27:10 +0000 Subject: [PATCH 199/239] Increment build number to 15 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 5ce3238f..f9ac793b 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '14' # Auto-incremented on builds +__build__ = '15' # Auto-incremented on builds From 7ef6eb85e4200b1511ac1d115c7ca1c91eb6bd25 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 11 Apr 2025 12:27:34 -0500 Subject: [PATCH 200/239] Fixes logo caching. --- docker/nginx.conf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker/nginx.conf b/docker/nginx.conf index a69579b1..06fa742a 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -37,6 +37,14 @@ server { proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow } + location ~ ^/api/channels/logos/(?\d+)/cache/ { + proxy_pass http://127.0.0.1:5656; + proxy_cache logo_cache; + proxy_cache_key "$scheme$request_uri"; # Cache per logo URL + proxy_cache_valid 200 24h; # Cache for 24 hours + proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow + } + # admin disabled when not in dev mode location /admin { return 301 /login; From 739e726bb90949cd724a4ad2fc50dfaaced67100 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 17:28:08 +0000 Subject: [PATCH 201/239] Increment build number to 16 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index f9ac793b..ec82e9dc 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '15' # Auto-incremented on builds +__build__ = '16' # Auto-incremented on builds From c947f56a6c406ab2544e305504fe8ea77e1a4580 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 14:26:11 -0400 Subject: [PATCH 202/239] virtual list for logos so they don't all load in at the same time --- frontend/src/components/forms/Channel.jsx | 94 ++++++++++++++++++----- 1 file changed, 76 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index a3cfe992..e1e33da3 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -29,6 +29,7 @@ import { ScrollArea, Tooltip, NumberInput, + Image, } from '@mantine/core'; import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react'; import useEPGsStore from '../../store/epgs'; @@ -39,6 +40,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { const theme = useMantineTheme(); const listRef = useRef(null); + const logoListRef = useRef(null); const { channelGroups, logos, fetchLogos } = useChannelsStore(); const streams = useStreamsStore((state) => state.streams); @@ -50,8 +52,11 @@ const Channel = ({ channel = null, isOpen, onClose }) => { const [channelStreams, setChannelStreams] = useState([]); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); const [epgPopoverOpened, setEpgPopoverOpened] = useState(false); + const [logoPopoverOpened, setLogoPopoverOpened] = useState(false); const [selectedEPG, setSelectedEPG] = useState({}); const [tvgFilter, setTvgFilter] = useState(''); + const [logoFilter, setLogoFilter] = useState(''); + const [logoOptions, setLogoOptions] = useState([]); const addStream = (stream) => { const streamSet = new Set(channelStreams); @@ -120,6 +125,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { setLogoPreview(null); setSubmitting(false); setTvgFilter(''); + setLogoFilter(''); onClose(); }, }); @@ -147,9 +153,14 @@ const Channel = ({ channel = null, isOpen, onClose }) => { } else { formik.resetForm(); setTvgFilter(''); + setLogoFilter(''); } }, [channel, tvgsById]); + useEffect(() => { + setLogoOptions([{ id: '0', name: 'Default' }].concat(Object.values(logos))); + }, [logos]); + const renderLogoOption = ({ option, checked }) => { return (
@@ -298,6 +309,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => { tvg.tvg_id.toLowerCase().includes(tvgFilter.toLowerCase()) ); + const filteredLogos = logoOptions.filter((logo) => + logo.name.toLowerCase().includes(logoFilter.toLowerCase()) + ); + return ( <> { - { - // e.stopPropagation(); handleFilterChange(column.id, value); }} data={channelGroupOptions} variant="unstyled" - className="table-input-header" + className="table-input-header custom-multiselect" /> ), @@ -696,11 +701,22 @@ const ChannelsTable = ({}) => { }, [rowSelection]); const filteredData = Object.values(channels).filter((row) => - columns.every(({ accessorKey }) => - filterValues[accessorKey] - ? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey]) - : true - ) + columns.every(({ accessorKey }) => { + if (!accessorKey) { + return true; + } + + const filterValue = filterValues[accessorKey]; + const rowValue = getDescendantProp(row, accessorKey); + + if (Array.isArray(filterValue) && filterValue.length != 0) { + return filterValue.includes(rowValue); + } else if (filterValue) { + return rowValue?.toLowerCase().includes(filterValues[accessorKey]); + } + + return true; + }) ); const deleteProfile = async (id) => { diff --git a/frontend/src/pages/EPG.jsx b/frontend/src/pages/EPG.jsx deleted file mode 100644 index aea3d92d..00000000 --- a/frontend/src/pages/EPG.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { Box } from '@mantine/core'; -import UserAgentsTable from '../components/tables/UserAgentsTable'; -import EPGsTable from '../components/tables/EPGsTable'; - -const EPGPage = () => { - return ( - - - - - - - - - - ); -}; - -export default EPGPage; diff --git a/frontend/src/pages/StreamProfiles.jsx b/frontend/src/pages/StreamProfiles.jsx deleted file mode 100644 index 42dda005..00000000 --- a/frontend/src/pages/StreamProfiles.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import StreamProfilesTable from '../components/tables/StreamProfilesTable'; -import { Box } from '@mantine/core'; - -const StreamProfilesPage = () => { - return ( - - - - ); -}; - -export default StreamProfilesPage; diff --git a/frontend/src/utils.js b/frontend/src/utils.js index a4bb163d..bd62e5c0 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -57,3 +57,6 @@ export function sleep(ms) { setTimeout(resolve, ms); }); } + +export const getDescendantProp = (obj, path) => + path.split('.').reduce((acc, part) => acc && acc[part], obj); From e99702a1ef4cc846a765ce95a18651a57f1c3e9f Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 15:24:50 -0400 Subject: [PATCH 207/239] skip ingestion if m3u is disabled --- core/tasks.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/tasks.py b/core/tasks.py index 061dd1a5..5dd6a746 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -69,9 +69,14 @@ def scan_and_process_files(): "name": filename, }) + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + if not m3u_account.is_active: + logger.info("M3U account is inactive, skipping.") + continue + refresh_single_m3u_account.delay(m3u_account.id) - redis_client.set(redis_key, mtime, ex=REDIS_TTL) - redis_client.set(redis_key, mtime, ex=REDIS_TTL) channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( From 2ac110f2c8b5dcda1624ce159377793d6e9b061e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 19:25:13 +0000 Subject: [PATCH 208/239] Increment build number to 19 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 50408166..892f43b4 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '18' # Auto-incremented on builds +__build__ = '19' # Auto-incremented on builds From 70db4fc36ee729911749f8ee02cdf10a8e1a4036 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 15:41:37 -0400 Subject: [PATCH 209/239] setting for auto-importing mapped files --- .../0012_default_active_m3u_accounts.py | 22 +++++++++++++ core/models.py | 10 ++++++ core/tasks.py | 12 +++++-- frontend/src/pages/Settings.jsx | 32 +++++++++++++++++-- 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 core/migrations/0012_default_active_m3u_accounts.py diff --git a/core/migrations/0012_default_active_m3u_accounts.py b/core/migrations/0012_default_active_m3u_accounts.py new file mode 100644 index 00000000..11888772 --- /dev/null +++ b/core/migrations/0012_default_active_m3u_accounts.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from django.utils.text import slugify + +def preload_core_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + CoreSettings.objects.create( + key=slugify("Auto-Import Mapped Files"), + name="Auto-Import Mapped Files", + value=True, + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_fix_stream_profiles_and_user_agents'), + ] + + operations = [ + migrations.RunPython(preload_core_settings), + ] diff --git a/core/models.py b/core/models.py index a4fa92d4..a8571b5c 100644 --- a/core/models.py +++ b/core/models.py @@ -144,6 +144,7 @@ DEFAULT_USER_AGENT_KEY= slugify("Default User-Agent") DEFAULT_STREAM_PROFILE_KEY = slugify("Default Stream Profile") STREAM_HASH_KEY = slugify("M3U Hash Key") PREFERRED_REGION_KEY = slugify("Preferred Region") +AUTO_IMPORT_MAPPED_FILES = slugify("Auto-Import Mapped Files") class CoreSettings(models.Model): key = models.CharField( @@ -173,9 +174,18 @@ class CoreSettings(models.Model): def get_m3u_hash_key(cls): return cls.objects.get(key=STREAM_HASH_KEY).value + @classmethod def get_preferred_region(cls): """Retrieve the preferred region setting (or return None if not found).""" try: return cls.objects.get(key=PREFERRED_REGION_KEY).value except cls.DoesNotExist: return None + + @classmethod + def get_auto_import_mapped_files(cls): + """Retrieve the preferred region setting (or return None if not found).""" + try: + return cls.objects.get(key=AUTO_IMPORT_MAPPED_FILES).value + except cls.DoesNotExist: + return None diff --git a/core/tasks.py b/core/tasks.py index 5dd6a746..f6069e69 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -14,6 +14,7 @@ from apps.m3u.models import M3UAccount from apps.epg.models import EPGSource from apps.m3u.tasks import refresh_single_m3u_account from apps.epg.tasks import refresh_epg_data +from .models import CoreSettings logger = logging.getLogger(__name__) @@ -67,6 +68,7 @@ def scan_and_process_files(): m3u_account, _ = M3UAccount.objects.get_or_create(file_path=filepath, defaults={ "name": filename, + "is_active": CoreSettings.get_auto_import_mapped_files(), }) redis_client.set(redis_key, mtime, ex=REDIS_TTL) @@ -117,11 +119,17 @@ def scan_and_process_files(): epg_source, _ = EPGSource.objects.get_or_create(file_path=filepath, defaults={ "name": filename, "source_type": "xmltv", + "is_active": CoreSettings.get_auto_import_mapped_files(), }) + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + if not epg_source.is_active: + logger.info("EPG source is inactive, skipping.") + continue + refresh_epg_data.delay(epg_source.id) # Trigger Celery task - redis_client.set(redis_key, mtime, ex=REDIS_TTL) - redis_client.set(redis_key, mtime, ex=REDIS_TTL) channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 81a85c78..19bec97e 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -12,6 +12,8 @@ import { Paper, Select, Stack, + Switch, + Text, Title, } from '@mantine/core'; import { isNotEmpty, useForm } from '@mantine/form'; @@ -280,6 +282,7 @@ const SettingsPage = () => { 'default-user-agent': '', 'default-stream-profile': '', 'preferred-region': '', + 'auto-import-mapped-files': true, }, validate: { @@ -294,6 +297,15 @@ const SettingsPage = () => { form.setValues( Object.entries(settings).reduce((acc, [key, value]) => { // Modify each value based on its own properties + switch (value.value) { + case 'true': + value.value = true; + break; + case 'false': + value.value = false; + break; + } + acc[key] = value.value; return acc; }, {}) @@ -307,7 +319,7 @@ const SettingsPage = () => { for (const settingKey in values) { // If the user changed the setting’s value from what’s in the DB: if (String(values[settingKey]) !== String(settings[settingKey].value)) { - changedSettings[settingKey] = values[settingKey]; + changedSettings[settingKey] = `${values[settingKey]}`; } } @@ -373,13 +385,29 @@ const SettingsPage = () => { }))} /> + + + Auto-Import Mapped Files + + + + From d3c659d426fb048ec60d8e7e25e33b9ba174045a Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 19:41:59 +0000 Subject: [PATCH 210/239] Increment build number to 20 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 892f43b4..e7d23b11 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '19' # Auto-incremented on builds +__build__ = '20' # Auto-incremented on builds From ab7b92bcbdde6a9c4a6b82c6f8625ed9cb81edc2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 11 Apr 2025 16:00:11 -0500 Subject: [PATCH 211/239] Fix: Access the first element directly for EPG ID assignment in process_data --- scripts/epg_match.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/epg_match.py b/scripts/epg_match.py index deece784..e5d17466 100644 --- a/scripts/epg_match.py +++ b/scripts/epg_match.py @@ -52,7 +52,8 @@ def process_data(input_data): if chan["tvg_id"]: epg_match = [epg["id"] for epg in epg_data if epg["tvg_id"] == chan["tvg_id"]] if epg_match: - chan["epg_data_id"] = epg_match[0]["id"] + # Fix: Access the first element directly since epg_match contains the IDs themselves + chan["epg_data_id"] = epg_match[0] # Directly use the integer ID eprint(f"Channel {chan['id']} '{chan['name']}' => EPG found by tvg_id={chan['tvg_id']}") channels_to_update.append(chan) continue From 3c5954952aaa945ae47ea22440309c13737d4049 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 21:00:39 +0000 Subject: [PATCH 212/239] Increment build number to 21 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index e7d23b11..0b64272a 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '20' # Auto-incremented on builds +__build__ = '21' # Auto-incremented on builds From 3726802926b76250bce8f3a737a9341656eb2c6d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 11 Apr 2025 16:07:33 -0500 Subject: [PATCH 213/239] Use channel ID as key instead of channel name. --- frontend/src/pages/Guide.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index d40f64d6..61364e18 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -836,10 +836,10 @@ export default function TVChannelGuide({ startDate, endDate }) { {(searchQuery !== '' || selectedGroupId !== 'all' || selectedProfileId !== 'all') && ( - - )} + + )} {filteredChannels.length}{' '} @@ -1058,7 +1058,7 @@ export default function TVChannelGuide({ startDate, endDate }) { return ( Date: Fri, 11 Apr 2025 21:07:59 +0000 Subject: [PATCH 214/239] Increment build number to 22 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 0b64272a..c0f6b16e 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '21' # Auto-incremented on builds +__build__ = '22' # Auto-incremented on builds From b2d5e19b311725acd50c1d04aed118fb3e68b84c Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 17:06:20 -0400 Subject: [PATCH 215/239] fixed is_active flag --- core/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/tasks.py b/core/tasks.py index f6069e69..7e808310 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -68,7 +68,7 @@ def scan_and_process_files(): m3u_account, _ = M3UAccount.objects.get_or_create(file_path=filepath, defaults={ "name": filename, - "is_active": CoreSettings.get_auto_import_mapped_files(), + "is_active": True if CoreSettings.get_auto_import_mapped_files() == "true" else False, }) redis_client.set(redis_key, mtime, ex=REDIS_TTL) @@ -119,7 +119,7 @@ def scan_and_process_files(): epg_source, _ = EPGSource.objects.get_or_create(file_path=filepath, defaults={ "name": filename, "source_type": "xmltv", - "is_active": CoreSettings.get_auto_import_mapped_files(), + "is_active": True if CoreSettings.get_auto_import_mapped_files() == "true" else False, }) redis_client.set(redis_key, mtime, ex=REDIS_TTL) From 61496eea0e4922c00cda94628c259f22d84d8df9 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 17:08:50 -0400 Subject: [PATCH 216/239] better group filter form sizing --- .../src/components/forms/M3UGroupFilter.jsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 2fe221de..c6795916 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -20,6 +20,7 @@ import { Group, Center, SimpleGrid, + Text, } from '@mantine/core'; import useChannelsStore from '../../store/channels'; import { CircleCheck, CircleX } from 'lucide-react'; @@ -90,7 +91,12 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { } return ( - + @@ -120,10 +126,16 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { checked={group.enabled} onClick={() => toggleGroupEnabled(group.channel_group)} radius="xl" - leftSection={group.enabled ? : } + leftSection={ + group.enabled ? ( + + ) : ( + + ) + } justify="left" > - {group.name} + {group.name} ))} From 40082e33a772640b2feb90772011392f963ccd86 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 21:09:12 +0000 Subject: [PATCH 217/239] Increment build number to 23 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index c0f6b16e..f0337a7a 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '22' # Auto-incremented on builds +__build__ = '23' # Auto-incremented on builds From 348c471e2670ffb8bdaaa92f5c7014e7c04a9410 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 17:15:43 -0400 Subject: [PATCH 218/239] toggle m3u and epg active from ui --- frontend/src/api.js | 1 + frontend/src/components/tables/EPGsTable.jsx | 27 +++++++++++++++++++- frontend/src/components/tables/M3UsTable.jsx | 16 ++++++++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index 94183a10..92dc84e8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -712,6 +712,7 @@ export default class API { if (!payload.url) { delete payload.url; } + body = payload; } const response = await request(`${host}/api/epg/sources/${id}/`, { diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 31b71077..85584fca 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -13,6 +13,7 @@ import { Button, Flex, useMantineTheme, + Switch, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { IconSquarePlus } from '@tabler/icons-react'; @@ -25,10 +26,16 @@ const EPGsTable = () => { const [rowSelection, setRowSelection] = useState([]); const { epgs } = useEPGsStore(); - console.log(epgs); const theme = useMantineTheme(); + const toggleActive = async (epg) => { + await API.updateEPG({ + ...epg, + is_active: !epg.is_active, + }); + }; + const columns = useMemo( //column definitions... () => [ @@ -45,6 +52,24 @@ const EPGsTable = () => { accessorKey: 'url', enableSorting: false, }, + { + header: 'Active', + accessorKey: 'is_active', + size: 100, + sortingFn: 'basic', + mantineTableBodyCellProps: { + align: 'left', + }, + Cell: ({ row, cell }) => ( + + toggleActive(row.original)} + /> + + ), + }, { header: 'Updated', accessorFn: (row) => dayjs(row.updated_at).format('MMMM D, YYYY h:mma'), diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index c4bd273a..08ec142e 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -13,6 +13,7 @@ import { Box, ActionIcon, Tooltip, + Switch, } from '@mantine/core'; import { SquareMinus, SquarePen, RefreshCcw, Check, X } from 'lucide-react'; import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons @@ -84,6 +85,13 @@ const M3UTable = () => { return `Parsing: ${data.progress}%`; }; + const toggleActive = async (playlist) => { + await API.updatePlaylist({ + ...playlist, + is_active: !playlist.is_active, + }); + }; + const columns = useMemo( //column definitions... () => [ @@ -133,9 +141,13 @@ const M3UTable = () => { mantineTableBodyCellProps: { align: 'left', }, - Cell: ({ cell }) => ( + Cell: ({ row, cell }) => ( - {cell.getValue() ? : } + toggleActive(row.original)} + /> ), }, From 8bf75293fbc55a6ec0ff097a0f2723f791aea989 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 17:18:32 -0400 Subject: [PATCH 219/239] disabled refresh button on inactive items, don't process them in inactive --- apps/epg/tasks.py | 4 ++++ apps/m3u/tasks.py | 4 ++++ frontend/src/components/tables/EPGsTable.jsx | 1 + frontend/src/components/tables/M3UsTable.jsx | 1 + 4 files changed, 10 insertions(+) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 33e981a6..9bfce9fe 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -42,6 +42,10 @@ def refresh_epg_data(source_id): return source = EPGSource.objects.get(id=source_id) + if not source.is_active: + logger.info(f"EPG source {source_id} is not active. Skipping.") + return + logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})") if source.source_type == 'xmltv': fetch_xmltv(source) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 3529e1ef..5db25e2a 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -383,6 +383,10 @@ def refresh_single_m3u_account(account_id): try: account = M3UAccount.objects.get(id=account_id, is_active=True) + if not account.is_active: + logger.info(f"Account {account_id} is not active, skipping.") + return + filters = list(account.filters.all()) except M3UAccount.DoesNotExist: release_task_lock('refresh_single_m3u_account', account_id) diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 85584fca..9da2b114 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -167,6 +167,7 @@ const EPGsTable = () => { size="sm" // Makes the button smaller color="blue.5" // Red color for delete actions onClick={() => refreshEPG(row.original.id)} + disabled={!row.original.is_active} > {/* Small icon size */} diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 08ec142e..0fccfc82 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -263,6 +263,7 @@ const M3UTable = () => { size="sm" color="blue.5" onClick={() => refreshPlaylist(row.original.id)} + disabled={!row.original.is_active} > From 508c017f42240eece050a6e22fd61d44e20e2570 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 21:18:53 +0000 Subject: [PATCH 220/239] Increment build number to 24 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index f0337a7a..4a10d9ca 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '23' # Auto-incremented on builds +__build__ = '24' # Auto-incremented on builds From 1209b0c2383c7c1880a254b045abdd8617255df5 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 11 Apr 2025 16:19:52 -0500 Subject: [PATCH 221/239] Add TunerCount to device discovery response. Hard coded to 10 for now. --- apps/hdhr/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 4dd9c07d..048eb340 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -52,6 +52,7 @@ class DiscoverAPIView(APIView): "DeviceAuth": "test_auth_token", "BaseURL": base_url, "LineupURL": f"{base_url}/lineup.json", + "TunerCount": 10, } else: data = { @@ -63,6 +64,7 @@ class DiscoverAPIView(APIView): "DeviceAuth": "test_auth_token", "BaseURL": base_url, "LineupURL": f"{base_url}/lineup.json", + "TunerCount": 10, } return JsonResponse(data) From a0e37c7b24b4f9a221e304ea2fc3cbd0a56a22d4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 21:20:19 +0000 Subject: [PATCH 222/239] Increment build number to 25 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 4a10d9ca..bdb28cd1 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '24' # Auto-incremented on builds +__build__ = '25' # Auto-incremented on builds From cab4fa4986623dc9477e0f8852f5077819574914 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 11 Apr 2025 16:29:12 -0500 Subject: [PATCH 223/239] Forgot quotes. --- apps/hdhr/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 048eb340..48c48f80 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -52,7 +52,7 @@ class DiscoverAPIView(APIView): "DeviceAuth": "test_auth_token", "BaseURL": base_url, "LineupURL": f"{base_url}/lineup.json", - "TunerCount": 10, + "TunerCount": "10", } else: data = { @@ -64,7 +64,7 @@ class DiscoverAPIView(APIView): "DeviceAuth": "test_auth_token", "BaseURL": base_url, "LineupURL": f"{base_url}/lineup.json", - "TunerCount": 10, + "TunerCount": "10", } return JsonResponse(data) From d67e30715b55d57a8a33385fd46a6afbdf830eed Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 21:29:37 +0000 Subject: [PATCH 224/239] Increment build number to 26 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index bdb28cd1..e2237de9 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '25' # Auto-incremented on builds +__build__ = '26' # Auto-incremented on builds From 45573b9ca1692e3478222739aaf503b2c07c4397 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 11 Apr 2025 16:38:56 -0500 Subject: [PATCH 225/239] Added tuners to api_views --- apps/hdhr/api_views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 5d706ef8..fe1d4ff9 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -56,6 +56,7 @@ class DiscoverAPIView(APIView): "DeviceAuth": "test_auth_token", "BaseURL": base_url, "LineupURL": f"{base_url}/lineup.json", + "TunerCount": "10", } else: data = { @@ -67,6 +68,7 @@ class DiscoverAPIView(APIView): "DeviceAuth": "test_auth_token", "BaseURL": base_url, "LineupURL": f"{base_url}/lineup.json", + "TunerCount": "10", } return JsonResponse(data) From f94119320183c7f402fb6a219efc8e7578624e5a Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 21:39:19 +0000 Subject: [PATCH 226/239] Increment build number to 27 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index e2237de9..9d5e8ecc 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '26' # Auto-incremented on builds +__build__ = '27' # Auto-incremented on builds From cc5b8f475a47ac595d758a709e32d5de058a013f Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 17:55:43 -0400 Subject: [PATCH 227/239] m3u honors default UA --- apps/m3u/serializers.py | 3 ++- frontend/src/components/forms/M3U.jsx | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index d3948145..d79b0117 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -53,7 +53,8 @@ class M3UAccountSerializer(serializers.ModelSerializer): # Include user_agent as a mandatory field using its primary key. user_agent = serializers.PrimaryKeyRelatedField( queryset=UserAgent.objects.all(), - required=True + required=False, + allow_null=True, ) profiles = M3UAccountProfileSerializer(many=True, read_only=True) read_only_fields = ['locked', 'created_at', 'updated_at'] diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index f0860485..ccef1944 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -48,7 +48,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { initialValues: { name: '', server_url: '', - user_agent: `${userAgents[0].id}`, + user_agent: '0', is_active: true, max_streams: 0, refresh_interval: 24, @@ -67,7 +67,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { name: playlist.name, server_url: playlist.server_url, max_streams: playlist.max_streams, - user_agent: `${playlist.user_agent}`, + user_agent: playlist.user_agent ? `${playlist.user_agent}` : '0', is_active: playlist.is_active, refresh_interval: playlist.refresh_interval, }); @@ -79,6 +79,10 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { const onSubmit = async () => { const values = form.getValues(); + if (values.user_agent == '0') { + values.user_agent = null; + } + let newPlaylist; if (playlist?.id) { await API.updatePlaylist({ @@ -184,10 +188,12 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { label="User-Agent" {...form.getInputProps('user_agent')} key={form.key('user_agent')} - data={userAgents.map((ua) => ({ - label: ua.name, - value: `${ua.id}`, - }))} + data={[{ value: '0', label: '(use default)' }].concat( + userAgents.map((ua) => ({ + label: ua.name, + value: `${ua.id}`, + })) + )} /> Date: Fri, 11 Apr 2025 21:57:59 +0000 Subject: [PATCH 228/239] Increment build number to 28 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 9d5e8ecc..f876336c 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '27' # Auto-incremented on builds +__build__ = '28' # Auto-incremented on builds From f4b244a338328a9fb2ffcd02d6622460922af508 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 11 Apr 2025 22:20:58 -0500 Subject: [PATCH 229/239] Removed quotes --- apps/hdhr/api_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index fe1d4ff9..4aefcc9a 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -56,7 +56,7 @@ class DiscoverAPIView(APIView): "DeviceAuth": "test_auth_token", "BaseURL": base_url, "LineupURL": f"{base_url}/lineup.json", - "TunerCount": "10", + "TunerCount": 10, } else: data = { @@ -68,7 +68,7 @@ class DiscoverAPIView(APIView): "DeviceAuth": "test_auth_token", "BaseURL": base_url, "LineupURL": f"{base_url}/lineup.json", - "TunerCount": "10", + "TunerCount": 10, } return JsonResponse(data) From 795da9065f303a6a4686664a4790a6c1b466d7a8 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 12 Apr 2025 03:22:12 +0000 Subject: [PATCH 230/239] Increment build number to 29 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index f876336c..de4a88cb 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '28' # Auto-incremented on builds +__build__ = '29' # Auto-incremented on builds From 36b281697c55cbce432cf35bcfcd36a53fe1e9a9 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 12 Apr 2025 07:13:22 -0400 Subject: [PATCH 231/239] fetch default user-agent if none is set on m3u --- apps/m3u/models.py | 8 ++++++++ apps/m3u/tasks.py | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/m3u/models.py b/apps/m3u/models.py index cc84d768..25a332c6 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -5,6 +5,7 @@ import re from django.dispatch import receiver from apps.channels.models import StreamProfile from django_celery_beat.models import PeriodicTask +from core.models import CoreSettings, UserAgent CUSTOM_M3U_ACCOUNT_NAME="custom" @@ -100,6 +101,13 @@ class M3UAccount(models.Model): def get_custom_account(cls): return cls.objects.get(name=CUSTOM_M3U_ACCOUNT_NAME, locked=True) + def get_user_agent(self): + user_agent = self.user_agent + if not user_agent: + user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) + + return user_agent + # def get_channel_groups(self): # return ChannelGroup.objects.filter(m3u_account__m3u_account=self) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 5db25e2a..8a8aa7f1 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -35,7 +35,8 @@ def fetch_m3u_lines(account, use_cache=False): """Fetch M3U file lines efficiently.""" if account.server_url: if not use_cache or not os.path.exists(file_path): - headers = {"User-Agent": account.user_agent.user_agent} + user_agent = account.get_user_agent() + headers = {"User-Agent": user_agent.user_agent} logger.info(f"Fetching from URL {account.server_url}") try: response = requests.get(account.server_url, headers=headers, stream=True) From 2ee168cb959ba3fc240d1d30d5933c853beebb6e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 12 Apr 2025 11:13:48 +0000 Subject: [PATCH 232/239] Increment build number to 30 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index de4a88cb..7115949d 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '29' # Auto-incremented on builds +__build__ = '30' # Auto-incremented on builds From 222899e140d37526755172edd2f07944d09f03ea Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 12 Apr 2025 10:37:45 -0400 Subject: [PATCH 233/239] fixed potential division by 0 --- apps/m3u/tasks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 8a8aa7f1..b75f114b 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -46,6 +46,7 @@ def fetch_m3u_lines(account, use_cache=False): downloaded = 0 start_time = time.time() last_update_time = start_time + progress = 0 with open(file_path, 'wb') as file: send_m3u_update(account.id, "downloading", 0) @@ -60,7 +61,8 @@ def fetch_m3u_lines(account, use_cache=False): speed = downloaded / elapsed_time / 1024 # in KB/s # Calculate progress percentage - progress = (downloaded / total_size) * 100 + if total_size and total_size > 0: + progress = (downloaded / total_size) * 100 # Time remaining (in seconds) time_remaining = (total_size - downloaded) / (speed * 1024) From f5aa4a8b247c13e51d10acf6a9cb501fa28a90bb Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 12 Apr 2025 14:38:06 +0000 Subject: [PATCH 234/239] Increment build number to 31 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 7115949d..4a7aaa32 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '30' # Auto-incremented on builds +__build__ = '31' # Auto-incremented on builds From d721b48aa07c379e6b5bd8289c1b0c3113ecfeaa Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 12 Apr 2025 10:39:44 -0400 Subject: [PATCH 235/239] don't sent update if progress doesn't increase --- apps/m3u/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index b75f114b..36de852b 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -70,7 +70,8 @@ def fetch_m3u_lines(account, use_cache=False): current_time = time.time() if current_time - last_update_time >= 0.5: last_update_time = current_time - send_m3u_update(account.id, "downloading", progress, speed=speed, elapsed_time=elapsed_time, time_remaining=time_remaining) + if progress > 0: + send_m3u_update(account.id, "downloading", progress, speed=speed, elapsed_time=elapsed_time, time_remaining=time_remaining) send_m3u_update(account.id, "downloading", 100) except requests.exceptions.RequestException as e: From e4a84f0e807ff9f6a9c5c05bc17f45f9d13eb5c6 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 12 Apr 2025 14:40:03 +0000 Subject: [PATCH 236/239] Increment build number to 32 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 4a7aaa32..883b199f 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '31' # Auto-incremented on builds +__build__ = '32' # Auto-incremented on builds From e878afa5c2a36ea5b0358b3fb8c505bb64c0376d Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 12 Apr 2025 14:50:34 -0400 Subject: [PATCH 237/239] fixed add m3u button on onboarding view: --- frontend/src/components/tables/StreamsTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index ab0f4c2e..bcc81a37 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -757,7 +757,7 @@ const StreamsTable = ({}) => { variant="default" radius="md" size="md" - onClick={() => navigate('/m3u')} + onClick={() => navigate('/sources')} style={{ backgroundColor: '#444', color: '#d4d4d8', From 73519d27908c8723fdaa733290b8bf609d1859ad Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 12 Apr 2025 18:51:14 +0000 Subject: [PATCH 238/239] Increment build number to 33 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 883b199f..8fe72f67 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '32' # Auto-incremented on builds +__build__ = '33' # Auto-incremented on builds From 8633b959261b6623d6c7451c7508eb8a587c8663 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 12 Apr 2025 21:34:07 +0000 Subject: [PATCH 239/239] Increment build number to 34 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 8fe72f67..a025acab 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '33' # Auto-incremented on builds +__build__ = '34' # Auto-incremented on builds