From d24520d3d89dd7d2e4881740630b14b4fe0e0916 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 10 Jul 2025 13:22:42 -0500 Subject: [PATCH 01/74] Enhance EPG XML generation with additional metadata extraction and improved handling for keywords, languages, ratings, and credits. --- apps/epg/tasks.py | 157 ++++++++++++++++++++++++++++++++++++++-- apps/output/views.py | 166 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 293 insertions(+), 30 deletions(-) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index d3062171..4fcf5706 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -1612,6 +1612,11 @@ def extract_custom_properties(prog): if categories: custom_props['categories'] = categories + # Extract keywords (new) + keywords = [kw.text.strip() for kw in prog.findall('keyword') if kw.text and kw.text.strip()] + if keywords: + custom_props['keywords'] = keywords + # Extract episode numbers for ep_num in prog.findall('episode-num'): system = ep_num.get('system', '') @@ -1637,6 +1642,9 @@ def extract_custom_properties(prog): elif system == 'dd_progid' and ep_num.text: # Store the dd_progid format custom_props['dd_progid'] = ep_num.text.strip() + # Add support for other systems like thetvdb.com, themoviedb.org, imdb.com + elif system in ['thetvdb.com', 'themoviedb.org', 'imdb.com'] and ep_num.text: + custom_props[f'{system}_id'] = ep_num.text.strip() # Extract ratings more efficiently rating_elem = prog.find('rating') @@ -1647,37 +1655,172 @@ def extract_custom_properties(prog): if rating_elem.get('system'): custom_props['rating_system'] = rating_elem.get('system') + # Extract star ratings (new) + star_ratings = [] + for star_rating in prog.findall('star-rating'): + value_elem = star_rating.find('value') + if value_elem is not None and value_elem.text: + rating_data = {'value': value_elem.text.strip()} + if star_rating.get('system'): + rating_data['system'] = star_rating.get('system') + star_ratings.append(rating_data) + if star_ratings: + custom_props['star_ratings'] = star_ratings + # Extract credits more efficiently credits_elem = prog.find('credits') if credits_elem is not None: credits = {} - for credit_type in ['director', 'actor', 'writer', 'presenter', 'producer']: - names = [e.text.strip() for e in credits_elem.findall(credit_type) if e.text and e.text.strip()] - if names: - credits[credit_type] = names + for credit_type in ['director', 'actor', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']: + if credit_type == 'actor': + # Handle actors with roles and guest status + actors = [] + for actor_elem in credits_elem.findall('actor'): + if actor_elem.text and actor_elem.text.strip(): + actor_data = {'name': actor_elem.text.strip()} + if actor_elem.get('role'): + actor_data['role'] = actor_elem.get('role') + if actor_elem.get('guest') == 'yes': + actor_data['guest'] = True + actors.append(actor_data) + if actors: + credits['actor'] = actors + else: + names = [e.text.strip() for e in credits_elem.findall(credit_type) if e.text and e.text.strip()] + if names: + credits[credit_type] = names if credits: custom_props['credits'] = credits # Extract other common program metadata date_elem = prog.find('date') if date_elem is not None and date_elem.text: - custom_props['year'] = date_elem.text.strip()[:4] # Just the year part + custom_props['date'] = date_elem.text.strip() country_elem = prog.find('country') if country_elem is not None and country_elem.text: custom_props['country'] = country_elem.text.strip() + # Extract language information (new) + language_elem = prog.find('language') + if language_elem is not None and language_elem.text: + custom_props['language'] = language_elem.text.strip() + + orig_language_elem = prog.find('orig-language') + if orig_language_elem is not None and orig_language_elem.text: + custom_props['original_language'] = orig_language_elem.text.strip() + + # Extract length (new) + length_elem = prog.find('length') + if length_elem is not None and length_elem.text: + try: + length_value = int(length_elem.text.strip()) + length_units = length_elem.get('units', 'minutes') + custom_props['length'] = {'value': length_value, 'units': length_units} + except ValueError: + pass + + # Extract video information (new) + video_elem = prog.find('video') + if video_elem is not None: + video_info = {} + for video_attr in ['present', 'colour', 'aspect', 'quality']: + attr_elem = video_elem.find(video_attr) + if attr_elem is not None and attr_elem.text: + video_info[video_attr] = attr_elem.text.strip() + if video_info: + custom_props['video'] = video_info + + # Extract audio information (new) + audio_elem = prog.find('audio') + if audio_elem is not None: + audio_info = {} + for audio_attr in ['present', 'stereo']: + attr_elem = audio_elem.find(audio_attr) + if attr_elem is not None and attr_elem.text: + audio_info[audio_attr] = attr_elem.text.strip() + if audio_info: + custom_props['audio'] = audio_info + + # Extract subtitles information (new) + subtitles = [] + for subtitle_elem in prog.findall('subtitles'): + subtitle_data = {} + if subtitle_elem.get('type'): + subtitle_data['type'] = subtitle_elem.get('type') + lang_elem = subtitle_elem.find('language') + if lang_elem is not None and lang_elem.text: + subtitle_data['language'] = lang_elem.text.strip() + if subtitle_data: + subtitles.append(subtitle_data) + + if subtitles: + custom_props['subtitles'] = subtitles + + # Extract reviews (new) + reviews = [] + for review_elem in prog.findall('review'): + if review_elem.text and review_elem.text.strip(): + review_data = {'content': review_elem.text.strip()} + if review_elem.get('type'): + review_data['type'] = review_elem.get('type') + if review_elem.get('source'): + review_data['source'] = review_elem.get('source') + if review_elem.get('reviewer'): + review_data['reviewer'] = review_elem.get('reviewer') + reviews.append(review_data) + if reviews: + custom_props['reviews'] = reviews + + # Extract images (new) + images = [] + for image_elem in prog.findall('image'): + if image_elem.text and image_elem.text.strip(): + image_data = {'url': image_elem.text.strip()} + for attr in ['type', 'size', 'orient', 'system']: + if image_elem.get(attr): + image_data[attr] = image_elem.get(attr) + images.append(image_data) + if images: + custom_props['images'] = images + icon_elem = prog.find('icon') if icon_elem is not None and icon_elem.get('src'): custom_props['icon'] = icon_elem.get('src') - # Simpler approach for boolean flags - for kw in ['previously-shown', 'premiere', 'new', 'live']: + # Simpler approach for boolean flags - expanded list + for kw in ['previously-shown', 'premiere', 'new', 'live', 'last-chance']: if prog.find(kw) is not None: custom_props[kw.replace('-', '_')] = True + # Extract premiere and last-chance text content if available + premiere_elem = prog.find('premiere') + if premiere_elem is not None: + custom_props['premiere'] = True + if premiere_elem.text and premiere_elem.text.strip(): + custom_props['premiere_text'] = premiere_elem.text.strip() + + last_chance_elem = prog.find('last-chance') + if last_chance_elem is not None: + custom_props['last_chance'] = True + if last_chance_elem.text and last_chance_elem.text.strip(): + custom_props['last_chance_text'] = last_chance_elem.text.strip() + + # Extract previously-shown details + prev_shown_elem = prog.find('previously-shown') + if prev_shown_elem is not None: + custom_props['previously_shown'] = True + prev_shown_data = {} + if prev_shown_elem.get('start'): + prev_shown_data['start'] = prev_shown_elem.get('start') + if prev_shown_elem.get('channel'): + prev_shown_data['channel'] = prev_shown_elem.get('channel') + if prev_shown_data: + custom_props['previously_shown_details'] = prev_shown_data + return custom_props + def clear_element(elem): """Clear an XML element and its parent to free memory.""" try: diff --git a/apps/output/views.py b/apps/output/views.py index 4ef9f4f2..67d72bd2 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -467,19 +467,27 @@ def generate_epg(request, profile_name=None, user=None): for category in custom_data["categories"]: program_xml.append(f" {html.escape(category)}") - # Handle episode numbering - multiple formats supported - # Standard episode number if available - if "episode" in custom_data: - program_xml.append(f' E{custom_data["episode"]}') + # Add keywords if available + if "keywords" in custom_data and custom_data["keywords"]: + for keyword in custom_data["keywords"]: + program_xml.append(f" {html.escape(keyword)}") - # Handle onscreen episode format (like S06E128) + # Handle episode numbering - multiple formats supported + # Prioritize onscreen_episode over standalone episode for onscreen system if "onscreen_episode" in custom_data: program_xml.append(f' {html.escape(custom_data["onscreen_episode"])}') + elif "episode" in custom_data: + program_xml.append(f' E{custom_data["episode"]}') # Handle dd_progid format if 'dd_progid' in custom_data: program_xml.append(f' {html.escape(custom_data["dd_progid"])}') + # Handle external database IDs + for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']: + if f'{system}_id' in custom_data: + program_xml.append(f' {html.escape(custom_data[f"{system}_id"])}') + # Add season and episode numbers in xmltv_ns format if available if "season" in custom_data and "episode" in custom_data: season = ( @@ -494,6 +502,46 @@ def generate_epg(request, profile_name=None, user=None): ) program_xml.append(f' {season}.{episode}.') + # Add language information + if "language" in custom_data: + program_xml.append(f' {html.escape(custom_data["language"])}') + + if "original_language" in custom_data: + program_xml.append(f' {html.escape(custom_data["original_language"])}') + + # Add length information + if "length" in custom_data and isinstance(custom_data["length"], dict): + length_value = custom_data["length"].get("value", "") + length_units = custom_data["length"].get("units", "minutes") + program_xml.append(f' {html.escape(str(length_value))}') + + # Add video information + if "video" in custom_data and isinstance(custom_data["video"], dict): + program_xml.append(" ") + + # Add audio information + if "audio" in custom_data and isinstance(custom_data["audio"], dict): + program_xml.append(" ") + + # Add subtitles information + if "subtitles" in custom_data and isinstance(custom_data["subtitles"], list): + for subtitle in custom_data["subtitles"]: + if isinstance(subtitle, dict): + subtitle_type = subtitle.get("type", "") + type_attr = f' type="{html.escape(subtitle_type)}"' if subtitle_type else "" + program_xml.append(f" ") + if "language" in subtitle: + program_xml.append(f" {html.escape(subtitle['language'])}") + program_xml.append(" ") + # Add rating if available if "rating" in custom_data: rating_system = custom_data.get("rating_system", "TV Parental Guidelines") @@ -501,20 +549,74 @@ def generate_epg(request, profile_name=None, user=None): program_xml.append(f' {html.escape(custom_data["rating"])}') program_xml.append(f" ") - # Add actors/directors/writers if available - if "credits" in custom_data: - program_xml.append(f" ") - for role, people in custom_data["credits"].items(): - if isinstance(people, list): - for person in people: - program_xml.append(f" <{role}>{html.escape(person)}") - else: - program_xml.append(f" <{role}>{html.escape(people)}") - program_xml.append(f" ") + # Add star ratings + if "star_ratings" in custom_data and isinstance(custom_data["star_ratings"], list): + for star_rating in custom_data["star_ratings"]: + if isinstance(star_rating, dict) and "value" in star_rating: + system_attr = f' system="{html.escape(star_rating["system"])}"' if "system" in star_rating else "" + program_xml.append(f" ") + program_xml.append(f" {html.escape(star_rating['value'])}") + program_xml.append(" ") - # Add program date/year if available - if "year" in custom_data: - program_xml.append(f' {html.escape(custom_data["year"])}') + # Add reviews + if "reviews" in custom_data and isinstance(custom_data["reviews"], list): + for review in custom_data["reviews"]: + if isinstance(review, dict) and "content" in review: + review_type = review.get("type", "text") + attrs = [f'type="{html.escape(review_type)}"'] + if "source" in review: + attrs.append(f'source="{html.escape(review["source"])}"') + if "reviewer" in review: + attrs.append(f'reviewer="{html.escape(review["reviewer"])}"') + attr_str = " ".join(attrs) + program_xml.append(f' {html.escape(review["content"])}') + + # Add images + if "images" in custom_data and isinstance(custom_data["images"], list): + for image in custom_data["images"]: + if isinstance(image, dict) and "url" in image: + attrs = [] + for attr in ['type', 'size', 'orient', 'system']: + if attr in image: + attrs.append(f'{attr}="{html.escape(image[attr])}"') + attr_str = " " + " ".join(attrs) if attrs else "" + program_xml.append(f' {html.escape(image["url"])}') + + # Add enhanced credits handling + if "credits" in custom_data: + program_xml.append(" ") + credits = custom_data["credits"] + + # Handle different credit types + for role in ['director', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']: + if role in credits: + people = credits[role] + if isinstance(people, list): + for person in people: + program_xml.append(f" <{role}>{html.escape(person)}") + else: + program_xml.append(f" <{role}>{html.escape(people)}") + + # Handle actors separately to include role and guest attributes + if "actor" in credits: + actors = credits["actor"] + if isinstance(actors, list): + for actor in actors: + if isinstance(actor, dict): + name = actor.get("name", "") + role_attr = f' role="{html.escape(actor["role"])}"' if "role" in actor else "" + guest_attr = ' guest="yes"' if actor.get("guest") else "" + program_xml.append(f" {html.escape(name)}") + else: + program_xml.append(f" {html.escape(actor)}") + else: + program_xml.append(f" {html.escape(actors)}") + + program_xml.append(" ") + + # Add program date if available (full date, not just year) + if "date" in custom_data: + program_xml.append(f' {html.escape(custom_data["date"])}') # Add country if available if "country" in custom_data: @@ -524,18 +626,36 @@ def generate_epg(request, profile_name=None, user=None): if "icon" in custom_data: program_xml.append(f' ') - # Add special flags as proper tags + # Add special flags as proper tags with enhanced handling if custom_data.get("previously_shown", False): - program_xml.append(f" ") + prev_shown_details = custom_data.get("previously_shown_details", {}) + attrs = [] + if "start" in prev_shown_details: + attrs.append(f'start="{html.escape(prev_shown_details["start"])}"') + if "channel" in prev_shown_details: + attrs.append(f'channel="{html.escape(prev_shown_details["channel"])}"') + attr_str = " " + " ".join(attrs) if attrs else "" + program_xml.append(f" ") if custom_data.get("premiere", False): - program_xml.append(f" ") + premiere_text = custom_data.get("premiere_text", "") + if premiere_text: + program_xml.append(f" {html.escape(premiere_text)}") + else: + program_xml.append(" ") + + if custom_data.get("last_chance", False): + last_chance_text = custom_data.get("last_chance_text", "") + if last_chance_text: + program_xml.append(f" {html.escape(last_chance_text)}") + else: + program_xml.append(" ") if custom_data.get("new", False): - program_xml.append(f" ") + program_xml.append(" ") if custom_data.get('live', False): - program_xml.append(f' ') + program_xml.append(' ') except Exception as e: program_xml.append(f" ") From b392788d5f4ee436ee6009237ecdd4f18ddd81fa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 10 Jul 2025 16:22:16 -0500 Subject: [PATCH 02/74] Improve error handling for API responses by checking for empty content and handling JSON decode errors. --- core/xtream_codes.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/core/xtream_codes.py b/core/xtream_codes.py index 17f3eaad..64b49cb2 100644 --- a/core/xtream_codes.py +++ b/core/xtream_codes.py @@ -56,8 +56,19 @@ class Client: response = requests.get(url, params=params, headers=self.headers, timeout=30) response.raise_for_status() - data = response.json() - logger.debug(f"XC API Response: {url} status code: {response.status_code}") + # Check if response is empty + if not response.content: + error_msg = f"XC API returned empty response from {url}" + logger.error(error_msg) + raise ValueError(error_msg) + + try: + data = response.json() + except requests.exceptions.JSONDecodeError as json_err: + error_msg = f"XC API returned invalid JSON from {url}. Response: {response.text[:1000]}" + logger.error(error_msg) + logger.error(f"JSON decode error: {str(json_err)}") + raise ValueError(error_msg) # Check for XC-specific error responses if isinstance(data, dict) and data.get('user_info') is None and 'error' in data: From 65da85991c35690cf7d36b2bc54c65d696562417 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 10 Jul 2025 18:07:25 -0500 Subject: [PATCH 03/74] Enhance error handling in API requests by checking for common blocking responses and improving JSON decode error logging. --- core/xtream_codes.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/core/xtream_codes.py b/core/xtream_codes.py index 64b49cb2..846e53d4 100644 --- a/core/xtream_codes.py +++ b/core/xtream_codes.py @@ -62,12 +62,25 @@ class Client: logger.error(error_msg) raise ValueError(error_msg) + # Check for common blocking responses before trying to parse JSON + response_text = response.text.strip() + if response_text.lower() in ['blocked', 'forbidden', 'access denied', 'unauthorized']: + error_msg = f"XC API request blocked by server from {url}. Response: {response_text}" + logger.error(error_msg) + logger.error(f"This may indicate IP blocking, User-Agent filtering, or rate limiting") + raise ValueError(error_msg) + try: data = response.json() except requests.exceptions.JSONDecodeError as json_err: error_msg = f"XC API returned invalid JSON from {url}. Response: {response.text[:1000]}" logger.error(error_msg) logger.error(f"JSON decode error: {str(json_err)}") + + # Check if it looks like an HTML error page + if response_text.startswith('<'): + logger.error("Response appears to be HTML - server may be returning an error page") + raise ValueError(error_msg) # Check for XC-specific error responses From fafd93e9588cedb82ebfb0ee8709485075c44691 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 10 Jul 2025 19:14:43 -0500 Subject: [PATCH 04/74] Refactor XC Client usage to improve error handling and resource management with context management. Implement connection pooling for better performance. --- apps/m3u/tasks.py | 202 +++++++++++++++++++++---------------------- core/xtream_codes.py | 42 +++++++-- 2 files changed, 137 insertions(+), 107 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index d6e0755b..d7e46cde 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -285,57 +285,56 @@ def process_xc_category(account_id, batch, groups, hash_keys): stream_hashes = {} try: - xc_client = XCClient(account.server_url, account.username, account.password, account.get_user_agent()) + with XCClient(account.server_url, account.username, account.password, account.get_user_agent()) as xc_client: + # Log the batch details to help with debugging + logger.debug(f"Processing XC batch: {batch}") - # Log the batch details to help with debugging - logger.debug(f"Processing XC batch: {batch}") - - for group_name, props in batch.items(): - # Check if we have a valid xc_id for this group - if 'xc_id' not in props: - logger.error(f"Missing xc_id for group {group_name} in batch {batch}") - continue - - # Get actual group ID from the mapping - group_id = groups.get(group_name) - if not group_id: - logger.error(f"Group {group_name} not found in enabled groups") - continue - - try: - logger.debug(f"Fetching streams for XC category: {group_name} (ID: {props['xc_id']})") - streams = xc_client.get_live_category_streams(props['xc_id']) - - if not streams: - logger.warning(f"No streams found for XC category {group_name} (ID: {props['xc_id']})") + for group_name, props in batch.items(): + # Check if we have a valid xc_id for this group + if 'xc_id' not in props: + logger.error(f"Missing xc_id for group {group_name} in batch {batch}") continue - logger.debug(f"Found {len(streams)} streams for category {group_name}") + # Get actual group ID from the mapping + group_id = groups.get(group_name) + if not group_id: + logger.error(f"Group {group_name} not found in enabled groups") + continue - for stream in streams: - name = stream["name"] - url = xc_client.get_stream_url(stream["stream_id"]) - tvg_id = stream.get("epg_channel_id", "") - tvg_logo = stream.get("stream_icon", "") - group_title = group_name + try: + logger.debug(f"Fetching streams for XC category: {group_name} (ID: {props['xc_id']})") + streams = xc_client.get_live_category_streams(props['xc_id']) - stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys) - stream_props = { - "name": name, - "url": url, - "logo_url": tvg_logo, - "tvg_id": tvg_id, - "m3u_account": account, - "channel_group_id": int(group_id), - "stream_hash": stream_hash, - "custom_properties": json.dumps(stream), - } + if not streams: + logger.warning(f"No streams found for XC category {group_name} (ID: {props['xc_id']})") + continue - if stream_hash not in stream_hashes: - stream_hashes[stream_hash] = stream_props - except Exception as e: - logger.error(f"Error processing XC category {group_name} (ID: {props['xc_id']}): {str(e)}") - continue + logger.debug(f"Found {len(streams)} streams for category {group_name}") + + for stream in streams: + name = stream["name"] + url = xc_client.get_stream_url(stream["stream_id"]) + tvg_id = stream.get("epg_channel_id", "") + tvg_logo = stream.get("stream_icon", "") + group_title = group_name + + stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys) + stream_props = { + "name": name, + "url": url, + "logo_url": tvg_logo, + "tvg_id": tvg_id, + "m3u_account": account, + "channel_group_id": int(group_id), + "stream_hash": stream_hash, + "custom_properties": json.dumps(stream), + } + + if stream_hash not in stream_hashes: + stream_hashes[stream_hash] = stream_props + except Exception as e: + logger.error(f"Error processing XC category {group_name} (ID: {props['xc_id']}): {str(e)}") + continue # Process all found streams existing_streams = {s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys())} @@ -622,62 +621,63 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): # Create XCClient with explicit error handling try: - xc_client = XCClient(server_url, account.username, account.password, user_agent_string) - logger.info(f"XCClient instance created successfully") + with XCClient(server_url, account.username, account.password, user_agent_string) as xc_client: + logger.info(f"XCClient instance created successfully") + + # Authenticate with detailed error handling + try: + logger.debug(f"Authenticating with XC server {server_url}") + auth_result = xc_client.authenticate() + logger.debug(f"Authentication response: {auth_result}") + except Exception as e: + error_msg = f"Failed to authenticate with XC server: {str(e)}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg) + release_task_lock('refresh_m3u_account_groups', account_id) + return error_msg, None + + # Get categories with detailed error handling + try: + logger.info(f"Getting live categories from XC server") + xc_categories = xc_client.get_live_categories() + logger.info(f"Found {len(xc_categories)} categories: {xc_categories}") + + # Validate response + if not isinstance(xc_categories, list): + error_msg = f"Unexpected response from XC server: {xc_categories}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg) + release_task_lock('refresh_m3u_account_groups', account_id) + return error_msg, None + + if len(xc_categories) == 0: + logger.warning("No categories found in XC server response") + + for category in xc_categories: + cat_name = category.get("category_name", "Unknown Category") + cat_id = category.get("category_id", "0") + logger.info(f"Adding category: {cat_name} (ID: {cat_id})") + groups[cat_name] = { + "xc_id": cat_id, + } + except Exception as e: + error_msg = f"Failed to get categories from XC server: {str(e)}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=['status', 'last_message']) + send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg) + release_task_lock('refresh_m3u_account_groups', account_id) + return error_msg, None + except Exception as e: - error_msg = f"Failed to create XCClient: {str(e)}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=['status', 'last_message']) - send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg) - release_task_lock('refresh_m3u_account_groups', account_id) - return error_msg, None - - # Authenticate with detailed error handling - try: - logger.debug(f"Authenticating with XC server {server_url}") - auth_result = xc_client.authenticate() - logger.debug(f"Authentication response: {auth_result}") - except Exception as e: - error_msg = f"Failed to authenticate with XC server: {str(e)}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=['status', 'last_message']) - send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg) - release_task_lock('refresh_m3u_account_groups', account_id) - return error_msg, None - - # Get categories with detailed error handling - try: - logger.info(f"Getting live categories from XC server") - xc_categories = xc_client.get_live_categories() - logger.info(f"Found {len(xc_categories)} categories: {xc_categories}") - - # Validate response - if not isinstance(xc_categories, list): - error_msg = f"Unexpected response from XC server: {xc_categories}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=['status', 'last_message']) - send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg) - release_task_lock('refresh_m3u_account_groups', account_id) - return error_msg, None - - if len(xc_categories) == 0: - logger.warning("No categories found in XC server response") - - for category in xc_categories: - cat_name = category.get("category_name", "Unknown Category") - cat_id = category.get("category_id", "0") - logger.info(f"Adding category: {cat_name} (ID: {cat_id})") - groups[cat_name] = { - "xc_id": cat_id, - } - except Exception as e: - error_msg = f"Failed to get categories from XC server: {str(e)}" + error_msg = f"Failed to create XC Client: {str(e)}" logger.error(error_msg) account.status = M3UAccount.Status.ERROR account.last_message = error_msg @@ -686,7 +686,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): release_task_lock('refresh_m3u_account_groups', account_id) return error_msg, None except Exception as e: - error_msg = f"Unexpected error in XC processing: {str(e)}" + error_msg = f"Unexpected error occurred in XC Client: {str(e)}" logger.error(error_msg) account.status = M3UAccount.Status.ERROR account.last_message = error_msg diff --git a/core/xtream_codes.py b/core/xtream_codes.py index 846e53d4..d068bacb 100644 --- a/core/xtream_codes.py +++ b/core/xtream_codes.py @@ -17,20 +17,29 @@ class Client: # Fix: Properly handle all possible user_agent input types if user_agent: if isinstance(user_agent, str): - # Direct string user agent user_agent_string = user_agent elif hasattr(user_agent, 'user_agent'): - # UserAgent model object user_agent_string = user_agent.user_agent else: - # Fallback for any other type logger.warning(f"Unexpected user_agent type: {type(user_agent)}, using default") user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' else: - # No user agent provided user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' - self.headers = {'User-Agent': user_agent_string} + # Create persistent session + self.session = requests.Session() + self.session.headers.update({'User-Agent': user_agent_string}) + + # Configure connection pooling + adapter = requests.adapters.HTTPAdapter( + pool_connections=1, + pool_maxsize=2, + max_retries=3, + pool_block=False + ) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + self.server_info = None def _normalize_url(self, url): @@ -53,7 +62,7 @@ class Client: url = f"{self.server_url}/{endpoint}" logger.debug(f"XC API Request: {url} with params: {params}") - response = requests.get(url, params=params, headers=self.headers, timeout=30) + response = self.session.get(url, params=params, timeout=30) response.raise_for_status() # Check if response is empty @@ -186,3 +195,24 @@ class Client: def get_stream_url(self, stream_id): """Get the playback URL for a stream""" return f"{self.server_url}/live/{self.username}/{self.password}/{stream_id}.ts" + + def close(self): + """Close the session and cleanup resources""" + if hasattr(self, 'session') and self.session: + try: + self.session.close() + except Exception as e: + logger.debug(f"Error closing XC session: {e}") + + def __enter__(self): + """Enter the context manager""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit the context manager and cleanup resources""" + self.close() + return False # Don't suppress exceptions + + def __del__(self): + """Ensure session is closed when object is destroyed""" + self.close() From 1c7fa21b868bc160ad2897fa65afa892b7fba43a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 11 Jul 2025 14:11:41 -0500 Subject: [PATCH 05/74] Add rehash streams endpoint and UI integration for triggering stream rehashing --- core/api_urls.py | 3 +- core/api_views.py | 37 +++++++++++++ core/tasks.py | 96 ++++++++++++++++++++++++++++----- frontend/src/pages/Settings.jsx | 36 ++++++++++++- 4 files changed, 156 insertions(+), 16 deletions(-) diff --git a/core/api_urls.py b/core/api_urls.py index e30eb698..30714d44 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version +from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version, rehash_streams_endpoint router = DefaultRouter() router.register(r'useragents', UserAgentViewSet, basename='useragent') @@ -12,5 +12,6 @@ router.register(r'settings', CoreSettingsViewSet, basename='settings') urlpatterns = [ path('settings/env/', environment, name='token_refresh'), path('version/', version, name='version'), + path('rehash-streams/', rehash_streams_endpoint, name='rehash_streams'), path('', include(router.urls)), ] diff --git a/core/api_views.py b/core/api_views.py index b416cf92..6b9743f6 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -280,3 +280,40 @@ def version(request): "timestamp": __timestamp__, } ) + + +@swagger_auto_schema( + method="post", + operation_description="Trigger rehashing of all streams", + responses={200: "Rehash task started"}, +) +@api_view(["POST"]) +@permission_classes([Authenticated]) +def rehash_streams_endpoint(request): + """Trigger the rehash streams task""" + try: + # Get the current hash keys from settings + hash_key_setting = CoreSettings.objects.get(key=STREAM_HASH_KEY) + hash_keys = hash_key_setting.value.split(",") + + # Queue the rehash task + task = rehash_streams.delay(hash_keys) + + return Response({ + "success": True, + "message": "Stream rehashing task has been queued", + "task_id": task.id + }, status=status.HTTP_200_OK) + + except CoreSettings.DoesNotExist: + return Response({ + "success": False, + "message": "Hash key settings not found" + }, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + logger.error(f"Error triggering rehash streams: {e}") + return Response({ + "success": False, + "message": "Failed to trigger rehash task" + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/core/tasks.py b/core/tasks.py index 0fdaedf7..157ffadc 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -312,32 +312,100 @@ def fetch_channel_stats(): @shared_task def rehash_streams(keys): + """ + Rehash all streams with new hash keys and handle duplicates. + """ batch_size = 1000 queryset = Stream.objects.all() + # Track statistics + total_processed = 0 + duplicates_merged = 0 hash_keys = {} + total_records = queryset.count() + logger.info(f"Starting rehash of {total_records} streams with keys: {keys}") + for start in range(0, total_records, batch_size): + batch_processed = 0 + batch_duplicates = 0 + with transaction.atomic(): batch = queryset[start:start + batch_size] + for obj in batch: - stream_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys) - if stream_hash in hash_keys: - # Handle duplicate keys and remove any without channels - stream_channels = ChannelStream.objects.filter(stream_id=obj.id).count() - if stream_channels == 0: + # Generate new hash + new_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys) + + # Check if this hash already exists in our tracking dict or in database + if new_hash in hash_keys: + # Found duplicate in current batch - merge the streams + existing_stream_id = hash_keys[new_hash] + existing_stream = Stream.objects.get(id=existing_stream_id) + + # Move any channel relationships from duplicate to existing stream + ChannelStream.objects.filter(stream_id=obj.id).update(stream_id=existing_stream_id) + + # Update the existing stream with the most recent data + if obj.updated_at > existing_stream.updated_at: + existing_stream.name = obj.name + existing_stream.url = obj.url + existing_stream.logo_url = obj.logo_url + existing_stream.tvg_id = obj.tvg_id + existing_stream.m3u_account = obj.m3u_account + existing_stream.channel_group = obj.channel_group + existing_stream.custom_properties = obj.custom_properties + existing_stream.last_seen = obj.last_seen + existing_stream.updated_at = obj.updated_at + existing_stream.save() + + # Delete the duplicate + obj.delete() + batch_duplicates += 1 + else: + # Check if hash already exists in database (from previous batches or existing data) + existing_stream = Stream.objects.filter(stream_hash=new_hash).exclude(id=obj.id).first() + if existing_stream: + # Found duplicate in database - merge the streams + # Move any channel relationships from duplicate to existing stream + ChannelStream.objects.filter(stream_id=obj.id).update(stream_id=existing_stream.id) + + # Update the existing stream with the most recent data + if obj.updated_at > existing_stream.updated_at: + existing_stream.name = obj.name + existing_stream.url = obj.url + existing_stream.logo_url = obj.logo_url + existing_stream.tvg_id = obj.tvg_id + existing_stream.m3u_account = obj.m3u_account + existing_stream.channel_group = obj.channel_group + existing_stream.custom_properties = obj.custom_properties + existing_stream.last_seen = obj.last_seen + existing_stream.updated_at = obj.updated_at + existing_stream.save() + + # Delete the duplicate obj.delete() - continue + batch_duplicates += 1 + hash_keys[new_hash] = existing_stream.id + else: + # Update hash for this stream + obj.stream_hash = new_hash + obj.save(update_fields=['stream_hash']) + hash_keys[new_hash] = obj.id + batch_processed += 1 - existing_stream_channels = ChannelStream.objects.filter(stream_id=hash_keys[stream_hash]).count() - if existing_stream_channels == 0: - Stream.objects.filter(id=hash_keys[stream_hash]).delete() + total_processed += batch_processed + duplicates_merged += batch_duplicates - obj.stream_hash = stream_hash - obj.save(update_fields=['stream_hash']) - hash_keys[stream_hash] = obj.id + logger.info(f"Rehashed batch {start//batch_size + 1}/{(total_records//batch_size) + 1}: " + f"{batch_processed} processed, {batch_duplicates} duplicates merged") - logger.debug(f"Re-hashed {batch_size} streams") + logger.info(f"Rehashing complete: {total_processed} streams processed, " + f"{duplicates_merged} duplicates merged") - logger.debug(f"Re-hashing complete") + return { + 'total_processed': total_processed, + 'duplicates_merged': duplicates_merged, + 'final_count': total_processed - duplicates_merged + } diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index a5b07fa2..865ac6c7 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -47,6 +47,8 @@ const SettingsPage = () => { useState([]); const [proxySettingsSaved, setProxySettingsSaved] = useState(false); + const [rehashingStreams, setRehashingStreams] = useState(false); + const [rehashSuccess, setRehashSuccess] = useState(false); // UI / local storage settings const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); @@ -245,6 +247,22 @@ const SettingsPage = () => { } }; + const onRehashStreams = async () => { + setRehashingStreams(true); + setRehashSuccess(false); + + try { + await API.post('/core/rehash-streams/'); + setRehashSuccess(true); + setTimeout(() => setRehashSuccess(false), 5000); // Clear success message after 5 seconds + } catch (error) { + console.error('Error rehashing streams:', error); + // You might want to add error state handling here + } finally { + setRehashingStreams(false); + } + }; + return (
{ key={form.key('m3u-hash-key')} /> + {rehashSuccess && ( + + )} + + + )} + + + + + + {/* Existing groups */} + + Existing Groups ({Object.keys(channelGroups).length}) + + {loading ? ( + Loading group information... + ) : Object.keys(channelGroups).length === 0 ? ( + No groups found + ) : ( + + {Object.values(channelGroups) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((group) => ( + + + {editingGroup === group.id ? ( + setEditName(e.target.value)} + size="sm" + onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()} + /> + ) : ( + <> + {group.name} + + {getGroupBadges(group)} + + + )} + + + + {editingGroup === group.id ? ( + <> + + + + + + + + ) : ( + <> + handleEdit(group)} + disabled={!canEditGroup(group)} + > + + + handleDelete(group)} + disabled={!canDeleteGroup(group)} + > + + + + )} + + + ))} + + )} + + + + + + + + + + ); +}; + +export default GroupManager; diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 8813ceda..1568e10d 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -25,6 +25,7 @@ import { SquareMinus, SquarePen, SquarePlus, + Settings, } from 'lucide-react'; import API from '../../../api'; import { notifications } from '@mantine/notifications'; @@ -32,6 +33,7 @@ import useChannelsStore from '../../../store/channels'; import useAuthStore from '../../../store/auth'; import { USER_LEVELS } from '../../../constants'; import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers'; +import GroupManager from '../../forms/GroupManager'; import ConfirmationDialog from '../../ConfirmationDialog'; import useWarningsStore from '../../../store/warnings'; @@ -105,6 +107,7 @@ const ChannelTableHeader = ({ const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); + const [groupManagerOpen, setGroupManagerOpen] = useState(false); const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false); const [profileToDelete, setProfileToDelete] = useState(null); @@ -301,6 +304,15 @@ const ChannelTableHeader = ({ Auto-Match + + } + disabled={authUser.user_level != USER_LEVELS.ADMIN} + > + setGroupManagerOpen(true)}> + Edit Groups + + @@ -312,6 +324,11 @@ const ChannelTableHeader = ({ onClose={closeAssignChannelNumbersModal} /> + setGroupManagerOpen(false)} + /> + setConfirmDeleteProfileOpen(false)} diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index beb62fe1..03cf2b86 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -204,10 +204,18 @@ const useChannelsStore = create((set, get) => ({ updateChannelGroup: (channelGroup) => set((state) => ({ - ...state.channelGroups, - [channelGroup.id]: channelGroup, + channelGroups: { + ...state.channelGroups, + [channelGroup.id]: channelGroup, + }, })), + removeChannelGroup: (groupId) => + set((state) => { + const { [groupId]: removed, ...remainingGroups } = state.channelGroups; + return { channelGroups: remainingGroups }; + }), + fetchLogos: async () => { set({ isLoading: true, error: null }); try { From a1d9a7cbbe22c246e8c50714e6626f184f59f856 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 16:21:40 -0500 Subject: [PATCH 15/74] Fixed performance issue while creating group. --- .../src/components/forms/GroupManager.jsx | 262 +++++++++++------- 1 file changed, 156 insertions(+), 106 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 7709416f..65f4e0b6 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Modal, Stack, @@ -27,7 +27,114 @@ import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; import API from '../../api'; -const GroupManager = ({ isOpen, onClose }) => { +// Move GroupItem outside to prevent recreation on every render +const GroupItem = React.memo(({ + group, + editingGroup, + editName, + onEditNameChange, + onSaveEdit, + onCancelEdit, + onEdit, + onDelete, + groupUsage +}) => { + const getGroupBadges = (group) => { + const usage = groupUsage[group.id]; + const badges = []; + + if (usage?.hasChannels) { + badges.push( + }> + Channels + + ); + } + + if (usage?.hasM3UAccounts) { + badges.push( + }> + M3U + + ); + } + + return badges; + }; + + const canEditGroup = (group) => { + const usage = groupUsage[group.id]; + return usage?.canEdit !== false; + }; + + const canDeleteGroup = (group) => { + const usage = groupUsage[group.id]; + return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; + }; + + return ( + + + {editingGroup === group.id ? ( + e.key === 'Enter' && onSaveEdit()} + autoFocus + /> + ) : ( + <> + {group.name} + + {getGroupBadges(group)} + + + )} + + + + {editingGroup === group.id ? ( + <> + + + + + + + + ) : ( + <> + onEdit(group)} + disabled={!canEditGroup(group)} + > + + + onDelete(group)} + disabled={!canDeleteGroup(group)} + > + + + + )} + + + ); +}); + +const GroupManager = React.memo(({ isOpen, onClose }) => { + // Use a more specific selector to avoid unnecessary re-renders + const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); const channelGroups = useChannelsStore((s) => s.channelGroups); const [editingGroup, setEditingGroup] = useState(null); const [editName, setEditName] = useState(''); @@ -36,6 +143,18 @@ const GroupManager = ({ isOpen, onClose }) => { const [groupUsage, setGroupUsage] = useState({}); const [loading, setLoading] = useState(false); + // Memoize the channel groups array to prevent unnecessary re-renders + const channelGroupsArray = useMemo(() => + Object.values(channelGroups), + [channelGroups] + ); + + // Memoize sorted groups to prevent re-sorting on every render + const sortedGroups = useMemo(() => + channelGroupsArray.sort((a, b) => a.name.localeCompare(b.name)), + [channelGroupsArray] + ); + // Fetch group usage information when modal opens useEffect(() => { if (isOpen) { @@ -69,12 +188,12 @@ const GroupManager = ({ isOpen, onClose }) => { } }; - const handleEdit = (group) => { + const handleEdit = useCallback((group) => { setEditingGroup(group.id); setEditName(group.name); - }; + }, []); - const handleSaveEdit = async () => { + const handleSaveEdit = useCallback(async () => { if (!editName.trim()) { notifications.show({ title: 'Error', @@ -105,14 +224,14 @@ const GroupManager = ({ isOpen, onClose }) => { color: 'red', }); } - }; + }, [editName, editingGroup]); - const handleCancelEdit = () => { + const handleCancelEdit = useCallback(() => { setEditingGroup(null); setEditName(''); - }; + }, []); - const handleCreate = async () => { + const handleCreate = useCallback(async () => { if (!newGroupName.trim()) { notifications.show({ title: 'Error', @@ -143,9 +262,9 @@ const GroupManager = ({ isOpen, onClose }) => { color: 'red', }); } - }; + }, [newGroupName]); - const handleDelete = async (group) => { + const handleDelete = useCallback(async (group) => { const usage = groupUsage[group.id]; if (usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts)) { @@ -174,40 +293,15 @@ const GroupManager = ({ isOpen, onClose }) => { color: 'red', }); } - }; + }, [groupUsage]); - const getGroupBadges = (group) => { - const usage = groupUsage[group.id]; - const badges = []; + const handleNewGroupNameChange = useCallback((e) => { + setNewGroupName(e.target.value); + }, []); - if (usage?.hasChannels) { - badges.push( - }> - Channels - - ); - } - - if (usage?.hasM3UAccounts) { - badges.push( - }> - M3U - - ); - } - - return badges; - }; - - const canEditGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canEdit !== false; // Default to true if no usage data - }; - - const canDeleteGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; - }; + const handleEditNameChange = useCallback((e) => { + setEditName(e.target.value); + }, []); if (!isOpen) return null; @@ -233,9 +327,10 @@ const GroupManager = ({ isOpen, onClose }) => { setNewGroupName(e.target.value)} + onChange={handleNewGroupNameChange} style={{ flex: 1 }} onKeyPress={(e) => e.key === 'Enter' && handleCreate()} + autoFocus /> @@ -264,73 +359,28 @@ const GroupManager = ({ isOpen, onClose }) => { {/* Existing groups */} - Existing Groups ({Object.keys(channelGroups).length}) + Existing Groups ({channelGroupsArray.length}) {loading ? ( Loading group information... - ) : Object.keys(channelGroups).length === 0 ? ( + ) : sortedGroups.length === 0 ? ( No groups found ) : ( - {Object.values(channelGroups) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((group) => ( - - - {editingGroup === group.id ? ( - setEditName(e.target.value)} - size="sm" - onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()} - /> - ) : ( - <> - {group.name} - - {getGroupBadges(group)} - - - )} - - - - {editingGroup === group.id ? ( - <> - - - - - - - - ) : ( - <> - handleEdit(group)} - disabled={!canEditGroup(group)} - > - - - handleDelete(group)} - disabled={!canDeleteGroup(group)} - > - - - - )} - - - ))} + {sortedGroups.map((group) => ( + + ))} )} @@ -345,6 +395,6 @@ const GroupManager = ({ isOpen, onClose }) => { ); -}; +}); export default GroupManager; From 9cb05a0ae1610d0e0c893cd78065d5cd31ed9c9c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 16:27:49 -0500 Subject: [PATCH 16/74] Add search functionality to GroupManager for filtering groups --- .../src/components/forms/GroupManager.jsx | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 65f4e0b6..e10b9a1c 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -142,6 +142,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [isCreating, setIsCreating] = useState(false); const [groupUsage, setGroupUsage] = useState({}); const [loading, setLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo(() => @@ -155,6 +156,14 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { [channelGroupsArray] ); + // Filter groups based on search term + const filteredGroups = useMemo(() => { + if (!searchTerm.trim()) return sortedGroups; + return sortedGroups.filter(group => + group.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [sortedGroups, searchTerm]); + // Fetch group usage information when modal opens useEffect(() => { if (isOpen) { @@ -293,7 +302,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { color: 'red', }); } - }, [groupUsage]); + }, [groupUsage, fetchGroupUsage]); const handleNewGroupNameChange = useCallback((e) => { setNewGroupName(e.target.value); @@ -303,6 +312,10 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setEditName(e.target.value); }, []); + const handleSearchChange = useCallback((e) => { + setSearchTerm(e.target.value); + }, []); + if (!isOpen) return null; return ( @@ -359,15 +372,37 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { {/* Existing groups */} - Existing Groups ({channelGroupsArray.length}) + + + Existing Groups ({filteredGroups.length}{searchTerm && ` of ${sortedGroups.length}`}) + + setSearchTerm('')} + > + + + )} + /> + {loading ? ( Loading group information... - ) : sortedGroups.length === 0 ? ( - No groups found + ) : filteredGroups.length === 0 ? ( + + {searchTerm ? 'No groups found matching your search' : 'No groups found'} + ) : ( - {sortedGroups.map((group) => ( + {filteredGroups.map((group) => ( Date: Sat, 12 Jul 2025 16:57:05 -0500 Subject: [PATCH 17/74] Disable buttons that can't be used. --- apps/channels/api_views.py | 34 +++++++++++++++++ apps/channels/serializers.py | 5 ++- frontend/src/api.js | 10 ++++- frontend/src/components/forms/Channel.jsx | 2 + .../src/components/forms/ChannelBatch.jsx | 2 + .../src/components/forms/ChannelGroup.jsx | 27 ++++++++++++- .../src/components/forms/GroupManager.jsx | 38 +++++++------------ .../src/components/tables/ChannelsTable.jsx | 4 +- frontend/src/store/channels.jsx | 35 +++++++++++++---- 9 files changed, 120 insertions(+), 37 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 5d00e84d..b4df2461 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -187,6 +187,40 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def get_queryset(self): + """Add annotation for association counts""" + from django.db.models import Count + return ChannelGroup.objects.annotate( + channel_count=Count('channels', distinct=True), + m3u_account_count=Count('m3u_account', distinct=True) + ) + + def update(self, request, *args, **kwargs): + """Override update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Override partial_update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().partial_update(request, *args, **kwargs) + def destroy(self, request, *args, **kwargs): """Override destroy to check for associations before deletion""" instance = self.get_object() diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index cdc6ef60..4d1694dc 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -89,9 +89,12 @@ class StreamSerializer(serializers.ModelSerializer): # Channel Group # class ChannelGroupSerializer(serializers.ModelSerializer): + channel_count = serializers.IntegerField(read_only=True) + m3u_account_count = serializers.IntegerField(read_only=True) + class Meta: model = ChannelGroup - fields = ["id", "name"] + fields = ["id", "name", "channel_count", "m3u_account_count"] class ChannelProfileSerializer(serializers.ModelSerializer): diff --git a/frontend/src/api.js b/frontend/src/api.js index ff95f634..9786bb75 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -250,7 +250,15 @@ export default class API { }); if (response.id) { - useChannelsStore.getState().addChannelGroup(response); + // Add association flags for new groups + const processedGroup = { + ...response, + hasChannels: false, + hasM3UAccounts: false, + canEdit: true, + canDelete: true + }; + useChannelsStore.getState().addChannelGroup(processedGroup); } return response; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 452db052..64412cb4 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -45,6 +45,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const logos = useChannelsStore((s) => s.logos); const fetchLogos = useChannelsStore((s) => s.fetchLogos); const streams = useStreamsStore((state) => state.streams); diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 2ba3245c..693ebb11 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -32,6 +32,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const streamProfiles = useStreamProfilesStore((s) => s.profiles); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx index 18ed31c1..46641fb1 100644 --- a/frontend/src/components/forms/ChannelGroup.jsx +++ b/frontend/src/components/forms/ChannelGroup.jsx @@ -1,10 +1,17 @@ // Modal.js import React from 'react'; import API from '../../api'; -import { Flex, TextInput, Button, Modal } from '@mantine/core'; +import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { isNotEmpty, useForm } from '@mantine/form'; +import useChannelsStore from '../../store/channels'; const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + + // Check if editing is allowed + const canEdit = !channelGroup || canEditChannelGroup(channelGroup.id); + const form = useForm({ mode: 'uncontrolled', initialValues: { @@ -17,6 +24,16 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { }); const onSubmit = async () => { + // Prevent submission if editing is not allowed + if (channelGroup && !canEdit) { + notifications.show({ + title: 'Error', + message: 'Cannot edit group with M3U account associations', + color: 'red', + }); + return; + } + const values = form.getValues(); let newGroup; @@ -36,11 +53,17 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { return ( + {channelGroup && !canEdit && ( + + This group cannot be edited because it has M3U account associations. + + )}
@@ -50,7 +73,7 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { type="submit" variant="contained" color="primary" - disabled={form.submitting} + disabled={form.submitting || (channelGroup && !canEdit)} size="small" > Submit diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index e10b9a1c..f6bf7305 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -37,7 +37,9 @@ const GroupItem = React.memo(({ onCancelEdit, onEdit, onDelete, - groupUsage + groupUsage, + canEditGroup, + canDeleteGroup }) => { const getGroupBadges = (group) => { const usage = groupUsage[group.id]; @@ -62,16 +64,6 @@ const GroupItem = React.memo(({ return badges; }; - const canEditGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canEdit !== false; - }; - - const canDeleteGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; - }; - return ( { - // Use a more specific selector to avoid unnecessary re-renders - const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); const [editingGroup, setEditingGroup] = useState(null); const [editName, setEditName] = useState(''); const [newGroupName, setNewGroupName] = useState(''); @@ -171,21 +163,18 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } }, [isOpen]); - const fetchGroupUsage = async () => { + const fetchGroupUsage = useCallback(async () => { setLoading(true); try { - // This would ideally be a dedicated API endpoint, but we'll use the existing data - // For now, we'll determine usage based on the group having associated data + // Use the actual channel group data that already has the flags const usage = {}; - // Check which groups have channels or M3U associations - // This is a simplified check - in a real implementation you'd want a dedicated API Object.values(channelGroups).forEach(group => { usage[group.id] = { - hasChannels: false, // Would need API call to check - hasM3UAccounts: false, // Would need API call to check - canEdit: true, // Assume editable unless proven otherwise - canDelete: true // Assume deletable unless proven otherwise + hasChannels: group.hasChannels ?? false, + hasM3UAccounts: group.hasM3UAccounts ?? false, + canEdit: group.canEdit ?? true, + canDelete: group.canDelete ?? true }; }); @@ -195,7 +184,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } finally { setLoading(false); } - }; + }, [channelGroups]); const handleEdit = useCallback((group) => { setEditingGroup(group.id); @@ -414,6 +403,8 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { onEdit={handleEdit} onDelete={handleDelete} groupUsage={groupUsage} + canEditGroup={canEditChannelGroup} + canDeleteGroup={canDeleteChannelGroup} /> ))} @@ -431,5 +422,4 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { ); }); - export default GroupManager; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 7a9d5007..077602ad 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -216,6 +216,9 @@ const ChannelRowActions = React.memo( const ChannelsTable = ({ }) => { const theme = useMantineTheme(); + const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); /** * STORES @@ -241,7 +244,6 @@ const ChannelsTable = ({ }) => { const channels = useChannelsStore((s) => s.channels); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); - const channelGroups = useChannelsStore((s) => s.channelGroups); const logos = useChannelsStore((s) => s.logos); const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', { pageSize: 50, diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 03cf2b86..40791cf4 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -46,16 +46,24 @@ const useChannelsStore = create((set, get) => ({ }, fetchChannelGroups: async () => { - set({ isLoading: true, error: null }); try { const channelGroups = await api.getChannelGroups(); - set({ - channelGroups: channelGroups.reduce((acc, group) => { - acc[group.id] = group; - return acc; - }, {}), - isLoading: false, - }); + + // Process groups to add association flags + const processedGroups = channelGroups.reduce((acc, group) => { + acc[group.id] = { + ...group, + hasChannels: group.channel_count > 0, + hasM3UAccounts: group.m3u_account_count > 0, + canEdit: group.m3u_account_count === 0, + canDelete: group.channel_count === 0 && group.m3u_account_count === 0 + }; + return acc; + }, {}); + + set((state) => ({ + channelGroups: processedGroups, + })); } catch (error) { console.error('Failed to fetch channel groups:', error); set({ error: 'Failed to load channel groups.', isLoading: false }); @@ -435,6 +443,17 @@ const useChannelsStore = create((set, get) => ({ set({ error: 'Failed to load recordings.', isLoading: false }); } }, + + // Add helper methods for validation + canEditChannelGroup: (groupIdOrGroup) => { + const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup; + return get().channelGroups[groupId]?.canEdit ?? true; + }, + + canDeleteChannelGroup: (groupIdOrGroup) => { + const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup; + return get().channelGroups[groupId]?.canDelete ?? true; + }, })); export default useChannelsStore; From 9b7aa0c8946bccf65b641bf41fd330caa33fab96 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 17:05:48 -0500 Subject: [PATCH 18/74] Add ability to cleanup all unused groups. --- apps/channels/api_views.py | 45 ++++++++++++++++--- frontend/src/api.js | 16 +++++++ .../src/components/forms/GroupManager.jsx | 41 ++++++++++++++++- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b4df2461..f0f59f29 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -198,47 +198,78 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): """Override update to check M3U associations""" instance = self.get_object() - + # Check if group has M3U account associations if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): return Response( {"error": "Cannot edit group with M3U account associations"}, status=status.HTTP_400_BAD_REQUEST ) - + return super().update(request, *args, **kwargs) def partial_update(self, request, *args, **kwargs): """Override partial_update to check M3U associations""" instance = self.get_object() - + # Check if group has M3U account associations if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): return Response( {"error": "Cannot edit group with M3U account associations"}, status=status.HTTP_400_BAD_REQUEST ) - + return super().partial_update(request, *args, **kwargs) + @swagger_auto_schema( + method="post", + operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)", + responses={200: "Cleanup completed"}, + ) + @action(detail=False, methods=["post"], url_path="cleanup") + def cleanup_unused_groups(self, request): + """Delete all channel groups with no channels or M3U account associations""" + from django.db.models import Count + + # Find groups with no channels and no M3U account associations + unused_groups = ChannelGroup.objects.annotate( + channel_count=Count('channels', distinct=True), + m3u_account_count=Count('m3u_account', distinct=True) + ).filter( + channel_count=0, + m3u_account_count=0 + ) + + deleted_count = unused_groups.count() + group_names = list(unused_groups.values_list('name', flat=True)) + + # Delete the unused groups + unused_groups.delete() + + return Response({ + "message": f"Successfully deleted {deleted_count} unused channel groups", + "deleted_count": deleted_count, + "deleted_groups": group_names + }) + def destroy(self, request, *args, **kwargs): """Override destroy to check for associations before deletion""" instance = self.get_object() - + # Check if group has associated channels if instance.channels.exists(): return Response( {"error": "Cannot delete group with associated channels"}, status=status.HTTP_400_BAD_REQUEST ) - + # Check if group has M3U account associations if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): return Response( {"error": "Cannot delete group with M3U account associations"}, status=status.HTTP_400_BAD_REQUEST ) - + return super().destroy(request, *args, **kwargs) diff --git a/frontend/src/api.js b/frontend/src/api.js index 9786bb75..e9ab4deb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -301,6 +301,22 @@ export default class API { } } + static async cleanupUnusedChannelGroups() { + try { + const response = await request(`${host}/api/channels/groups/cleanup/`, { + method: 'POST', + }); + + // Refresh channel groups to update the UI + useChannelsStore.getState().fetchChannelGroups(); + + return response; + } catch (e) { + errorNotification('Failed to cleanup unused channel groups', e); + throw e; + } + } + static async addChannel(channel) { try { let body = null; diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index f6bf7305..f89c9228 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -21,7 +21,8 @@ import { X, AlertCircle, Database, - Tv + Tv, + Trash } from 'lucide-react'; import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; @@ -135,6 +136,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [groupUsage, setGroupUsage] = useState({}); const [loading, setLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [isCleaningUp, setIsCleaningUp] = useState(false); // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo(() => @@ -305,6 +307,29 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setSearchTerm(e.target.value); }, []); + const handleCleanup = useCallback(async () => { + setIsCleaningUp(true); + try { + const result = await API.cleanupUnusedChannelGroups(); + + notifications.show({ + title: 'Cleanup Complete', + message: `Successfully deleted ${result.deleted_count} unused groups`, + color: 'green', + }); + + fetchGroupUsage(); // Refresh usage data + } catch (error) { + notifications.show({ + title: 'Cleanup Failed', + message: 'Failed to cleanup unused groups', + color: 'red', + }); + } finally { + setIsCleaningUp(false); + } + }, [fetchGroupUsage]); + if (!isOpen) return null; return ( @@ -322,7 +347,19 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { {/* Create new group section */} - Create New Group + + Create New Group + + {isCreating ? ( <> From 171d64841a566e79f0fb7dc84d3185016c2d5b99 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 17:28:04 -0500 Subject: [PATCH 19/74] Changed some colors to match our theme better. --- .../src/components/forms/GroupManager.jsx | 91 ++++++++++--------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index f89c9228..edc04d20 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -12,11 +12,12 @@ import { Alert, Divider, ScrollArea, + useMantineTheme, } from '@mantine/core'; import { SquarePlus, SquarePen, - Trash2, + SquareMinus, Check, X, AlertCircle, @@ -42,6 +43,8 @@ const GroupItem = React.memo(({ canEditGroup, canDeleteGroup }) => { + const theme = useMantineTheme(); + const getGroupBadges = (group) => { const usage = groupUsage[group.id]; const badges = []; @@ -69,7 +72,7 @@ const GroupItem = React.memo(({ {editingGroup === group.id ? ( @@ -103,20 +106,22 @@ const GroupItem = React.memo(({ ) : ( <> onEdit(group)} disabled={!canEditGroup(group)} > - + onDelete(group)} disabled={!canDeleteGroup(group)} > - + )} @@ -346,9 +351,39 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { {/* Create new group section */} - - - Create New Group + + {isCreating ? ( + + e.key === 'Enter' && handleCreate()} + autoFocus + /> + + + + { + setIsCreating(false); + setNewGroupName(''); + }}> + + + + ) : ( + + )} + + {!isCreating && ( - - - {isCreating ? ( - <> - e.key === 'Enter' && handleCreate()} - autoFocus - /> - - - - { - setIsCreating(false); - setNewGroupName(''); - }}> - - - - ) : ( - - )} - - + )} + @@ -400,7 +403,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { - Existing Groups ({filteredGroups.length}{searchTerm && ` of ${sortedGroups.length}`}) + Groups ({filteredGroups.length}{searchTerm && ` of ${sortedGroups.length}`}) Date: Sat, 12 Jul 2025 17:37:24 -0500 Subject: [PATCH 20/74] Add filtering based on group membership. --- .../src/components/forms/GroupManager.jsx | 139 ++++++++++++++++-- 1 file changed, 125 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index edc04d20..3b63b738 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -13,6 +13,7 @@ import { Divider, ScrollArea, useMantineTheme, + Chip, } from '@mantine/core'; import { SquarePlus, @@ -23,7 +24,8 @@ import { AlertCircle, Database, Tv, - Trash + Trash, + Filter } from 'lucide-react'; import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; @@ -142,6 +144,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [loading, setLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [isCleaningUp, setIsCleaningUp] = useState(false); + const [showChannelGroups, setShowChannelGroups] = useState(true); + const [showM3UGroups, setShowM3UGroups] = useState(true); + const [showUnusedGroups, setShowUnusedGroups] = useState(true); // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo(() => @@ -155,13 +160,75 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { [channelGroupsArray] ); - // Filter groups based on search term + // Filter groups based on search term and chip filters const filteredGroups = useMemo(() => { - if (!searchTerm.trim()) return sortedGroups; - return sortedGroups.filter(group => - group.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - }, [sortedGroups, searchTerm]); + let filtered = sortedGroups; + + // Apply search filter + if (searchTerm.trim()) { + filtered = filtered.filter(group => + group.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Apply chip filters + filtered = filtered.filter(group => { + const usage = groupUsage[group.id]; + if (!usage) return false; + + const hasChannels = usage.hasChannels; + const hasM3U = usage.hasM3UAccounts; + const isUnused = !hasChannels && !hasM3U; + + // If group is unused, only show if unused groups are enabled + if (isUnused) { + return showUnusedGroups; + } + + // For groups with channels and/or M3U, show if either filter is enabled + let shouldShow = false; + if (hasChannels && showChannelGroups) shouldShow = true; + if (hasM3U && showM3UGroups) shouldShow = true; + + return shouldShow; + }); + + return filtered; + }, [sortedGroups, searchTerm, showChannelGroups, showM3UGroups, showUnusedGroups, groupUsage]); + + // Calculate filter counts + const filterCounts = useMemo(() => { + const counts = { + channels: 0, + m3u: 0, + unused: 0 + }; + + sortedGroups.forEach(group => { + const usage = groupUsage[group.id]; + if (usage) { + const hasChannels = usage.hasChannels; + const hasM3U = usage.hasM3UAccounts; + + // Count groups with channels (including those with both) + if (hasChannels) { + counts.channels++; + } + + // Count groups with M3U (including those with both) + if (hasM3U) { + counts.m3u++; + } + + // Count truly unused groups + if (!hasChannels && !hasM3U) { + counts.unused++; + } + } + }); + + return counts; + }, [sortedGroups, groupUsage]); // Fetch group usage information when modal opens useEffect(() => { @@ -342,7 +409,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { opened={isOpen} onClose={onClose} title="Group Manager" - size="md" + size="lg" scrollAreaComponent={ScrollArea.Autosize} > @@ -399,12 +466,13 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { - {/* Existing groups */} - + {/* Filter Controls */} + - - Groups ({filteredGroups.length}{searchTerm && ` of ${sortedGroups.length}`}) - + + + Filter Groups + { /> + + Show: + + + + Channel Groups ({filterCounts.channels}) + + + + + + M3U Groups ({filterCounts.m3u}) + + + + Unused Groups ({filterCounts.unused}) + + + + + + + {/* Existing groups */} + + + Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`}) + + {loading ? ( Loading group information... ) : filteredGroups.length === 0 ? ( - {searchTerm ? 'No groups found matching your search' : 'No groups found'} + {searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'} ) : ( From 2da8273de64fd19324ef4ea8769ab32141f75c3c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 17:41:35 -0500 Subject: [PATCH 21/74] Add confirmation for deleting and cleaning up groups. --- .../src/components/forms/GroupManager.jsx | 452 +++++++++++------- 1 file changed, 268 insertions(+), 184 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 3b63b738..abb44727 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -29,6 +29,8 @@ import { } from 'lucide-react'; import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; +import useWarningsStore from '../../store/warnings'; +import ConfirmationDialog from '../ConfirmationDialog'; import API from '../../api'; // Move GroupItem outside to prevent recreation on every render @@ -136,6 +138,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); + const [editingGroup, setEditingGroup] = useState(null); const [editName, setEditName] = useState(''); const [newGroupName, setNewGroupName] = useState(''); @@ -148,6 +153,11 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [showM3UGroups, setShowM3UGroups] = useState(true); const [showUnusedGroups, setShowUnusedGroups] = useState(true); + // Confirmation dialog states + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [groupToDelete, setGroupToDelete] = useState(null); + const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false); + // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo(() => Object.values(channelGroups), @@ -348,6 +358,18 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { return; } + // Store group for confirmation dialog + setGroupToDelete(group); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-group')) { + return executeDeleteGroup(group); + } + + setConfirmDeleteOpen(true); + }, [groupUsage, isWarningSuppressed]); + + const executeDeleteGroup = useCallback(async (group) => { try { await API.deleteChannelGroup(group.id); @@ -358,14 +380,50 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { }); fetchGroupUsage(); // Refresh usage data + setConfirmDeleteOpen(false); } catch (error) { notifications.show({ title: 'Error', message: 'Failed to delete group', color: 'red', }); + setConfirmDeleteOpen(false); } - }, [groupUsage, fetchGroupUsage]); + }, [fetchGroupUsage]); + + const handleCleanup = useCallback(async () => { + // Skip warning if it's been suppressed + if (isWarningSuppressed('cleanup-groups')) { + return executeCleanup(); + } + + setConfirmCleanupOpen(true); + }, [isWarningSuppressed]); + + const executeCleanup = useCallback(async () => { + setIsCleaningUp(true); + try { + const result = await API.cleanupUnusedChannelGroups(); + + notifications.show({ + title: 'Cleanup Complete', + message: `Successfully deleted ${result.deleted_count} unused groups`, + color: 'green', + }); + + fetchGroupUsage(); // Refresh usage data + setConfirmCleanupOpen(false); + } catch (error) { + notifications.show({ + title: 'Cleanup Failed', + message: 'Failed to cleanup unused groups', + color: 'red', + }); + setConfirmCleanupOpen(false); + } finally { + setIsCleaningUp(false); + } + }, [fetchGroupUsage]); const handleNewGroupNameChange = useCallback((e) => { setNewGroupName(e.target.value); @@ -379,198 +437,224 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setSearchTerm(e.target.value); }, []); - const handleCleanup = useCallback(async () => { - setIsCleaningUp(true); - try { - const result = await API.cleanupUnusedChannelGroups(); - - notifications.show({ - title: 'Cleanup Complete', - message: `Successfully deleted ${result.deleted_count} unused groups`, - color: 'green', - }); - - fetchGroupUsage(); // Refresh usage data - } catch (error) { - notifications.show({ - title: 'Cleanup Failed', - message: 'Failed to cleanup unused groups', - color: 'red', - }); - } finally { - setIsCleaningUp(false); - } - }, [fetchGroupUsage]); - if (!isOpen) return null; return ( - - - } color="blue" variant="light"> - Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted. - - - {/* Create new group section */} - - {isCreating ? ( - - e.key === 'Enter' && handleCreate()} - autoFocus - /> - - - - { - setIsCreating(false); - setNewGroupName(''); - }}> - - - - ) : ( - - )} - - {!isCreating && ( - - )} - - - - - {/* Filter Controls */} - - - - - Filter Groups - - setSearchTerm('')} - > - - - )} - /> - - - - Show: - - - - Channel Groups ({filterCounts.channels}) - - - - - - M3U Groups ({filterCounts.m3u}) - - - - Unused Groups ({filterCounts.unused}) - - - - - - - {/* Existing groups */} + <> + - - Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`}) - + } color="blue" variant="light"> + Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted. + - {loading ? ( - Loading group information... - ) : filteredGroups.length === 0 ? ( - - {searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'} - - ) : ( - - {filteredGroups.map((group) => ( - + {isCreating ? ( + + e.key === 'Enter' && handleCreate()} + autoFocus /> - ))} - - )} + + + + { + setIsCreating(false); + setNewGroupName(''); + }}> + + + + ) : ( + + )} + + {!isCreating && ( + + )} + + + + + {/* Filter Controls */} + + + + + Filter Groups + + setSearchTerm('')} + > + + + )} + /> + + + + Show: + + + + Channel Groups ({filterCounts.channels}) + + + + + + M3U Groups ({filterCounts.m3u}) + + + + Unused Groups ({filterCounts.unused}) + + + + + + + {/* Existing groups */} + + + Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`}) + + + {loading ? ( + Loading group information... + ) : filteredGroups.length === 0 ? ( + + {searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'} + + ) : ( + + {filteredGroups.map((group) => ( + + ))} + + )} + + + + + + + + - + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteGroup(groupToDelete)} + title="Confirm Group Deletion" + message={ + groupToDelete ? ( +
+ {`Are you sure you want to delete the following group? - - - - - +Name: ${groupToDelete.name} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this group? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-group" + onSuppressChange={suppressWarning} + size="md" + /> + + setConfirmCleanupOpen(false)} + onConfirm={executeCleanup} + title="Confirm Group Cleanup" + message={ +
+ {`Are you sure you want to cleanup all unused groups? + +This will permanently delete all groups that are not associated with any channels or M3U accounts. + +This action cannot be undone.`} +
+ } + confirmLabel="Cleanup" + cancelLabel="Cancel" + actionKey="cleanup-groups" + onSuppressChange={suppressWarning} + size="md" + /> + ); }); + export default GroupManager; From 35d95c47c724dcf1563e7bb69b37783f11a4fbdd Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 17:48:56 -0500 Subject: [PATCH 22/74] Fixed z index issue when stream table was refreshing. --- frontend/src/components/ConfirmationDialog.jsx | 12 ++++++++++-- frontend/src/components/forms/GroupManager.jsx | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 822b46f1..8f96708d 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -27,7 +27,8 @@ const ConfirmationDialog = ({ cancelLabel = 'Cancel', actionKey, onSuppressChange, - size = 'md', // Add default size parameter - md is a medium width + size = 'md', + zIndex = 1000, }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -50,7 +51,14 @@ const ConfirmationDialog = ({ }; return ( - + {message} {actionKey && ( diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index abb44727..48ca85b1 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -447,6 +447,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { title="Group Manager" size="lg" scrollAreaComponent={ScrollArea.Autosize} + zIndex={2000} > } color="blue" variant="light"> @@ -631,6 +632,7 @@ This action cannot be undone.`} actionKey="delete-group" onSuppressChange={suppressWarning} size="md" + zIndex={2100} /> ); From 8b361ee6466c212df64951e645b44fa0cab25cbf Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 18:12:25 -0500 Subject: [PATCH 23/74] Fix eslint issues. --- .../src/components/forms/GroupManager.jsx | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 48ca85b1..253a2b9c 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -240,13 +240,6 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { return counts; }, [sortedGroups, groupUsage]); - // Fetch group usage information when modal opens - useEffect(() => { - if (isOpen) { - fetchGroupUsage(); - } - }, [isOpen]); - const fetchGroupUsage = useCallback(async () => { setLoading(true); try { @@ -270,6 +263,13 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } }, [channelGroups]); + // Fetch group usage information when modal opens + useEffect(() => { + if (isOpen) { + fetchGroupUsage(); + } + }, [isOpen, fetchGroupUsage]); + const handleEdit = useCallback((group) => { setEditingGroup(group.id); setEditName(group.name); @@ -299,6 +299,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setEditingGroup(null); setEditName(''); + await fetchGroupUsage(); // Refresh usage data } catch (error) { notifications.show({ title: 'Error', @@ -306,7 +307,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { color: 'red', }); } - }, [editName, editingGroup]); + }, [editName, editingGroup, fetchGroupUsage]); const handleCancelEdit = useCallback(() => { setEditingGroup(null); @@ -336,7 +337,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setNewGroupName(''); setIsCreating(false); - fetchGroupUsage(); // Refresh usage data + await fetchGroupUsage(); // Refresh usage data } catch (error) { notifications.show({ title: 'Error', @@ -344,7 +345,29 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { color: 'red', }); } - }, [newGroupName]); + }, [newGroupName, fetchGroupUsage]); + + const executeDeleteGroup = useCallback(async (group) => { + try { + await API.deleteChannelGroup(group.id); + + notifications.show({ + title: 'Success', + message: 'Group deleted successfully', + color: 'green', + }); + + await fetchGroupUsage(); // Refresh usage data + setConfirmDeleteOpen(false); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete group', + color: 'red', + }); + setConfirmDeleteOpen(false); + } + }, [fetchGroupUsage]); const handleDelete = useCallback(async (group) => { const usage = groupUsage[group.id]; @@ -367,38 +390,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } setConfirmDeleteOpen(true); - }, [groupUsage, isWarningSuppressed]); - - const executeDeleteGroup = useCallback(async (group) => { - try { - await API.deleteChannelGroup(group.id); - - notifications.show({ - title: 'Success', - message: 'Group deleted successfully', - color: 'green', - }); - - fetchGroupUsage(); // Refresh usage data - setConfirmDeleteOpen(false); - } catch (error) { - notifications.show({ - title: 'Error', - message: 'Failed to delete group', - color: 'red', - }); - setConfirmDeleteOpen(false); - } - }, [fetchGroupUsage]); - - const handleCleanup = useCallback(async () => { - // Skip warning if it's been suppressed - if (isWarningSuppressed('cleanup-groups')) { - return executeCleanup(); - } - - setConfirmCleanupOpen(true); - }, [isWarningSuppressed]); + }, [groupUsage, isWarningSuppressed, executeDeleteGroup]); const executeCleanup = useCallback(async () => { setIsCleaningUp(true); @@ -411,7 +403,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { color: 'green', }); - fetchGroupUsage(); // Refresh usage data + await fetchGroupUsage(); // Refresh usage data setConfirmCleanupOpen(false); } catch (error) { notifications.show({ @@ -425,6 +417,15 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } }, [fetchGroupUsage]); + const handleCleanup = useCallback(async () => { + // Skip warning if it's been suppressed + if (isWarningSuppressed('cleanup-groups')) { + return executeCleanup(); + } + + setConfirmCleanupOpen(true); + }, [isWarningSuppressed, executeCleanup]); + const handleNewGroupNameChange = useCallback((e) => { setNewGroupName(e.target.value); }, []); @@ -612,7 +613,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setConfirmDeleteOpen(false)} - onConfirm={() => executeDeleteGroup(groupToDelete)} + onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)} title="Confirm Group Deletion" message={ groupToDelete ? ( From c4e5710b484ce8d31673d194e3636e8ed521cdca Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 19:05:06 -0500 Subject: [PATCH 24/74] When adding a group. Fetch groups after. --- frontend/src/api.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/api.js b/frontend/src/api.js index e9ab4deb..e0a62160 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -259,6 +259,8 @@ export default class API { canDelete: true }; useChannelsStore.getState().addChannelGroup(processedGroup); + // Refresh channel groups to update the UI + useChannelsStore.getState().fetchChannelGroups(); } return response; From 69f8f426a627af88b7a8d85389501b40a3bcd4a1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 19:10:59 -0500 Subject: [PATCH 25/74] Refactor menu items in ChannelTableHeader to fix html error. --- .../ChannelsTable/ChannelTableHeader.jsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 1568e10d..72372cc7 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -287,31 +287,25 @@ const ChannelTableHeader = ({ selectedTableIds.length == 0 || authUser.user_level != USER_LEVELS.ADMIN } + onClick={() => setAssignNumbersModalOpen(true)} > - setAssignNumbersModalOpen(true)} - > - Assign #s - + Assign #s } disabled={authUser.user_level != USER_LEVELS.ADMIN} + onClick={matchEpg} > - - Auto-Match - + Auto-Match } disabled={authUser.user_level != USER_LEVELS.ADMIN} + onClick={() => setGroupManagerOpen(true)} > - setGroupManagerOpen(true)}> - Edit Groups - + Edit Groups From ea81cfb1afd426bb8b12df6bb5205673634e31a1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 13 Jul 2025 15:59:25 -0500 Subject: [PATCH 26/74] Add auto channel sync settings to ChannelGroupM3UAccount and update related components - Introduced `auto_channel_sync` and `auto_sync_channel_start` fields in the ChannelGroupM3UAccount model. - Added API endpoint to update M3U group settings. - Updated M3UGroupFilter component to manage auto sync settings. - Enhanced M3URefreshNotification and M3U components for better user guidance. - Created a Celery task for automatic channel synchronization after M3U refresh. --- ...upm3uaccount_auto_channel_sync_and_more.py | 23 +++ apps/channels/models.py | 9 ++ apps/channels/serializers.py | 4 +- apps/m3u/api_views.py | 34 ++++- apps/m3u/tasks.py | 144 ++++++++++++++++++ dispatcharr/celery.py | 1 + frontend/src/api.js | 13 ++ .../src/components/M3URefreshNotification.jsx | 4 +- frontend/src/components/forms/M3U.jsx | 3 +- .../src/components/forms/M3UGroupFilter.jsx | 140 ++++++++++++----- 10 files changed, 334 insertions(+), 41 deletions(-) create mode 100644 apps/channels/migrations/0022_channelgroupm3uaccount_auto_channel_sync_and_more.py diff --git a/apps/channels/migrations/0022_channelgroupm3uaccount_auto_channel_sync_and_more.py b/apps/channels/migrations/0022_channelgroupm3uaccount_auto_channel_sync_and_more.py new file mode 100644 index 00000000..a0c94c7d --- /dev/null +++ b/apps/channels/migrations/0022_channelgroupm3uaccount_auto_channel_sync_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-07-13 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0021_channel_user_level'), + ] + + operations = [ + migrations.AddField( + model_name='channelgroupm3uaccount', + name='auto_channel_sync', + field=models.BooleanField(default=False, help_text='Automatically create/delete channels to match streams in this group'), + ), + migrations.AddField( + model_name='channelgroupm3uaccount', + name='auto_sync_channel_start', + field=models.FloatField(blank=True, help_text='Starting channel number for auto-created channels in this group', null=True), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 1bcbcc41..b6333aab 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -541,6 +541,15 @@ class ChannelGroupM3UAccount(models.Model): ) custom_properties = models.TextField(null=True, blank=True) enabled = models.BooleanField(default=True) + auto_channel_sync = models.BooleanField( + default=False, + help_text='Automatically create/delete channels to match streams in this group' + ) + auto_sync_channel_start = models.FloatField( + null=True, + blank=True, + help_text='Starting channel number for auto-created channels in this group' + ) class Meta: unique_together = ("channel_group", "m3u_account") diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 4d1694dc..0eb5acc3 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -289,10 +289,12 @@ class ChannelSerializer(serializers.ModelSerializer): class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): enabled = serializers.BooleanField() + auto_channel_sync = serializers.BooleanField(default=False) + auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False) class Meta: model = ChannelGroupM3UAccount - fields = ["id", "channel_group", "enabled"] + fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start"] # Optionally, if you only need the id of the ChannelGroup, you can customize it like this: # channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all()) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 0ef42272..39b9e22e 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -16,13 +16,11 @@ from rest_framework.decorators import action from django.conf import settings from .tasks import refresh_m3u_groups -# Import all models, including UserAgent. from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile from core.models import UserAgent from apps.channels.models import ChannelGroupM3UAccount from core.serializers import UserAgentSerializer -# Import all serializers, including the UserAgentSerializer. from .serializers import ( M3UAccountSerializer, M3UFilterSerializer, @@ -144,6 +142,38 @@ class M3UAccountViewSet(viewsets.ModelViewSet): # Continue with regular partial update return super().partial_update(request, *args, **kwargs) + @action(detail=True, methods=["patch"], url_path="group-settings") + def update_group_settings(self, request, pk=None): + """Update auto channel sync settings for M3U account groups""" + account = self.get_object() + group_settings = request.data.get("group_settings", []) + + try: + for setting in group_settings: + group_id = setting.get("channel_group") + enabled = setting.get("enabled", True) + auto_sync = setting.get("auto_channel_sync", False) + sync_start = setting.get("auto_sync_channel_start") + + if group_id: + ChannelGroupM3UAccount.objects.update_or_create( + channel_group_id=group_id, + m3u_account=account, + defaults={ + "enabled": enabled, + "auto_channel_sync": auto_sync, + "auto_sync_channel_start": sync_start, + }, + ) + + return Response({"message": "Group settings updated successfully"}) + + except Exception as e: + return Response( + {"error": f"Failed to update group settings: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + class M3UFilterViewSet(viewsets.ModelViewSet): """Handles CRUD operations for M3U filters""" diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 0b782649..b5614376 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -838,6 +838,144 @@ def delete_m3u_refresh_task_by_id(account_id): logger.error(f"Error deleting periodic task for M3UAccount {account_id}: {str(e)}", exc_info=True) return False +@shared_task +def sync_auto_channels(account_id): + """ + Automatically create/delete channels to match streams in groups with auto_channel_sync enabled. + Called after M3U refresh completes successfully. + """ + from apps.channels.models import Channel, ChannelGroup, ChannelGroupM3UAccount, Stream, ChannelStream + from apps.epg.models import EPGData + import json + + try: + account = M3UAccount.objects.get(id=account_id) + logger.info(f"Starting auto channel sync for M3U account {account.name}") + + # Get groups with auto sync enabled for this account + auto_sync_groups = ChannelGroupM3UAccount.objects.filter( + m3u_account=account, + enabled=True, + auto_channel_sync=True + ).select_related('channel_group') + + channels_created = 0 + channels_deleted = 0 + + for group_relation in auto_sync_groups: + channel_group = group_relation.channel_group + start_number = group_relation.auto_sync_channel_start or 1.0 + + logger.info(f"Processing auto sync for group: {channel_group.name} (start: {start_number})") + + # Get all streams in this group for this M3U account + current_streams = Stream.objects.filter( + m3u_account=account, + channel_group=channel_group + ) + + # Get existing channels in this group that were auto-created (we'll track this via a custom property) + existing_auto_channels = Channel.objects.filter( + channel_group=channel_group, + streams__m3u_account=account + ).distinct() + + # Create a mapping of stream hashes to existing channels + existing_channel_streams = {} + for channel in existing_auto_channels: + for stream in channel.streams.filter(m3u_account=account): + existing_channel_streams[stream.stream_hash] = channel + + # Track which channels should exist (based on current streams) + channels_to_keep = set() + current_channel_number = start_number + + # Create channels for streams that don't have them + for stream in current_streams.order_by('name'): + if stream.stream_hash in existing_channel_streams: + # Channel already exists for this stream + channels_to_keep.add(existing_channel_streams[stream.stream_hash].id) + continue + + # Find next available channel number + while Channel.objects.filter(channel_number=current_channel_number).exists(): + current_channel_number += 0.1 + + # Create new channel + try: + # Parse custom properties for additional info + stream_custom_props = json.loads(stream.custom_properties) if stream.custom_properties else {} + + # Get tvc_guide_stationid from custom properties if it exists + tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid") + + # Create the channel + channel = Channel.objects.create( + channel_number=current_channel_number, + name=stream.name, + tvg_id=stream.tvg_id, + tvc_guide_stationid=tvc_guide_stationid, + channel_group=channel_group, + user_level=0 # Default user level + ) + + # Associate the stream with the channel + ChannelStream.objects.create( + channel=channel, + stream=stream, + order=0 + ) + + # Try to match EPG data + if stream.tvg_id: + epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first() + if epg_data: + channel.epg_data = epg_data + channel.save(update_fields=['epg_data']) + + # Handle logo + if stream.logo_url: + from apps.channels.models import Logo + logo, _ = Logo.objects.get_or_create( + url=stream.logo_url, + defaults={"name": stream.name or stream.tvg_id or "Unknown"} + ) + channel.logo = logo + channel.save(update_fields=['logo']) + + channels_to_keep.add(channel.id) + channels_created += 1 + current_channel_number += 1.0 + + logger.debug(f"Created auto channel: {channel.channel_number} - {channel.name}") + + except Exception as e: + logger.error(f"Error creating auto channel for stream {stream.name}: {str(e)}") + continue + + # Delete channels that no longer have corresponding streams + channels_to_delete = existing_auto_channels.exclude(id__in=channels_to_keep) + + for channel in channels_to_delete: + # Only delete if all streams for this channel are from this M3U account + # and this channel group + all_streams_from_account = all( + s.m3u_account_id == account.id and s.channel_group_id == channel_group.id + for s in channel.streams.all() + ) + + if all_streams_from_account: + logger.debug(f"Deleting auto channel: {channel.channel_number} - {channel.name}") + channel.delete() + channels_deleted += 1 + + logger.info(f"Auto channel sync complete for account {account.name}: {channels_created} created, {channels_deleted} deleted") + return f"Auto sync: {channels_created} channels created, {channels_deleted} deleted" + + except Exception as e: + logger.error(f"Error in auto channel sync for account {account_id}: {str(e)}") + return f"Auto sync error: {str(e)}" + @shared_task def refresh_single_m3u_account(account_id): """Splits M3U processing into chunks and dispatches them as parallel tasks.""" @@ -1120,6 +1258,12 @@ def refresh_single_m3u_account(account_id): message=account.last_message ) + # Run auto channel sync after successful refresh + try: + sync_result = sync_auto_channels(account_id) + logger.info(f"Auto channel sync result for account {account_id}: {sync_result}") + except Exception as e: + logger.error(f"Error running auto channel sync for account {account_id}: {str(e)}") except Exception as e: logger.error(f"Error processing M3U for account {account_id}: {str(e)}") account.status = M3UAccount.Status.ERROR diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 8856d330..98c6210b 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -62,6 +62,7 @@ def cleanup_task_memory(**kwargs): 'apps.m3u.tasks.refresh_m3u_accounts', 'apps.m3u.tasks.process_m3u_batch', 'apps.m3u.tasks.process_xc_category', + 'apps.m3u.tasks.sync_auto_channels', 'apps.epg.tasks.refresh_epg_data', 'apps.epg.tasks.refresh_all_epg_data', 'apps.epg.tasks.parse_programs_for_source', diff --git a/frontend/src/api.js b/frontend/src/api.js index e0a62160..e34dabe2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -733,6 +733,19 @@ export default class API { } } + static async updateM3UGroupSettings(playlistId, groupSettings) { + try { + const response = await request(`${host}/api/m3u/accounts/${playlistId}/group-settings/`, { + method: 'PATCH', + body: { group_settings: groupSettings }, + }); + + return response; + } catch (e) { + errorNotification('Failed to update M3U group settings', e); + } + } + static async addPlaylist(values) { if (values.custom_properties) { values.custom_properties = JSON.stringify(values.custom_properties); diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 8a6647cb..3b57af37 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -49,7 +49,7 @@ export default function M3URefreshNotification() { message: ( {data.message || - 'M3U groups loaded. Please select groups or refresh M3U to complete setup.'} + 'M3U groups loaded. Configure group filters and auto channel sync settings.'} diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 24ddd377..0e4d5643 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -145,8 +145,7 @@ const M3U = ({ if (values.account_type != 'XC') { notifications.show({ title: 'Fetching M3U Groups', - message: 'Filter out groups or refresh M3U once complete.', - // color: 'green.5', + message: 'Configure group filters and auto sync settings once complete.', }); // Don't prompt for group filters, but keeping this here diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 7ca0fa96..0213eeee 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -21,7 +21,11 @@ import { Center, SimpleGrid, Text, + NumberInput, + Divider, + Alert, } from '@mantine/core'; +import { Info } from 'lucide-react'; import useChannelsStore from '../../store/channels'; import { CircleCheck, CircleX } from 'lucide-react'; @@ -40,6 +44,8 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { playlist.channel_groups.map((group) => ({ ...group, name: channelGroups[group.channel_group].name, + auto_channel_sync: group.auto_channel_sync || false, + auto_sync_channel_start: group.auto_sync_channel_start || 1.0, })) ); }, [playlist, channelGroups]); @@ -53,15 +59,38 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { ); }; + const toggleAutoSync = (id) => { + setGroupStates( + groupStates.map((state) => ({ + ...state, + auto_channel_sync: state.channel_group == id ? !state.auto_channel_sync : state.auto_channel_sync, + })) + ); + }; + + const updateChannelStart = (id, value) => { + setGroupStates( + groupStates.map((state) => ({ + ...state, + auto_sync_channel_start: state.channel_group == id ? value : state.auto_sync_channel_start, + })) + ); + }; + const submit = async () => { setIsLoading(true); - await API.updatePlaylist({ - ...playlist, - channel_groups: groupStates, - }); - setIsLoading(false); - API.refreshPlaylist(playlist.id); - onClose(); + try { + // Update group settings via new API endpoint + await API.updateM3UGroupSettings(playlist.id, groupStates); + + // Refresh the playlist + API.refreshPlaylist(playlist.id); + onClose(); + } catch (error) { + console.error('Error updating group settings:', error); + } finally { + setIsLoading(false); + } }; const selectAll = () => { @@ -94,14 +123,21 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { + } color="blue" variant="light"> + + Auto Channel Sync: When enabled, channels will be automatically created for all streams in the group during M3U updates, + and removed when streams are no longer present. Set a starting channel number for each group to organize your channels. + + + setGroupFilter(event.currentTarget.value)} style={{ flex: 1 }} @@ -113,41 +149,77 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { Deselect Visible - + + + + {groupStates .filter((group) => group.name.toLowerCase().includes(groupFilter.toLowerCase()) ) - .sort((a, b) => a.name > b.name) + .sort((a, b) => a.name.localeCompare(b.name)) .map((group) => ( - + + {/* Group Enable/Disable Button */} + + + {/* Auto Sync Checkbox */} + toggleAutoSync(group.channel_group)} + size="sm" + /> + + {/* Channel Start Number Input */} + updateChannelStart(group.channel_group, value)} + disabled={!group.enabled || !group.auto_channel_sync} + min={1} + step={1} + size="sm" + style={{ width: '120px' }} + precision={1} + /> + ))} - + + - - - {groupStates - .filter((group) => - group.name.toLowerCase().includes(groupFilter.toLowerCase()) - ) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((group) => ( - - {/* Group Enable/Disable Button */} - + + + {groupStates + .filter((group) => + group.name.toLowerCase().includes(groupFilter.toLowerCase()) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((group) => ( + + {/* Group Enable/Disable Button */} + - {/* Auto Sync Checkbox */} - toggleAutoSync(group.channel_group)} - size="sm" - /> + {/* Auto Sync Controls */} + + toggleAutoSync(group.channel_group)} + size="xs" + /> - {/* Channel Start Number Input */} - updateChannelStart(group.channel_group, value)} - disabled={!group.enabled || !group.auto_channel_sync} - min={1} - step={1} - size="sm" - style={{ width: '120px' }} - precision={1} - /> - - ))} - + {group.auto_channel_sync && group.enabled && ( + updateChannelStart(group.channel_group, value)} + min={1} + step={1} + size="xs" + precision={1} + /> + )} + +
+ ))} + + - + + + + +
+ ); +}; + +export default LogoForm; diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx new file mode 100644 index 00000000..7ca879f6 --- /dev/null +++ b/frontend/src/pages/Logos.jsx @@ -0,0 +1,223 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, + Title, + Button, + Table, + Group, + ActionIcon, + Text, + Image, + Box, + Center, + Stack, + Badge, +} from '@mantine/core'; +import { SquarePen, Trash2, Plus, ExternalLink } from 'lucide-react'; +import { notifications } from '@mantine/notifications'; +import useChannelsStore from '../store/channels'; +import API from '../api'; +import LogoForm from '../components/forms/Logo'; +import ConfirmationDialog from '../components/ConfirmationDialog'; + +const LogosPage = () => { + const { logos, fetchLogos } = useChannelsStore(); + const [logoFormOpen, setLogoFormOpen] = useState(false); + const [editingLogo, setEditingLogo] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [logoToDelete, setLogoToDelete] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadLogos(); + }, []); + + const loadLogos = async () => { + setLoading(true); + try { + await fetchLogos(); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to load logos', + color: 'red', + }); + } finally { + setLoading(false); + } + }; + + const handleCreateLogo = () => { + setEditingLogo(null); + setLogoFormOpen(true); + }; + + const handleEditLogo = (logo) => { + setEditingLogo(logo); + setLogoFormOpen(true); + }; + + const handleDeleteLogo = (logo) => { + setLogoToDelete(logo); + setDeleteConfirmOpen(true); + }; + + const confirmDeleteLogo = async () => { + if (!logoToDelete) return; + + try { + await API.deleteLogo(logoToDelete.id); + await fetchLogos(); + notifications.show({ + title: 'Success', + message: 'Logo deleted successfully', + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete logo', + color: 'red', + }); + } finally { + setDeleteConfirmOpen(false); + setLogoToDelete(null); + } + }; + + const handleFormClose = () => { + setLogoFormOpen(false); + setEditingLogo(null); + loadLogos(); // Refresh the logos list + }; + + const logosArray = Object.values(logos || {}); + + const rows = logosArray.map((logo) => ( + + +
+ {logo.name} +
+
+ + {logo.name} + + + + + {logo.url} + + {logo.url.startsWith('http') && ( + window.open(logo.url, '_blank')} + > + + + )} + + + + + handleEditLogo(logo)} + color="blue" + > + + + handleDeleteLogo(logo)} + color="red" + > + + + + +
+ )); + + return ( + <> + + + Logos + + + + {loading ? ( +
+ Loading logos... +
+ ) : logosArray.length === 0 ? ( +
+ + No logos found + Click "Add Logo" to create your first logo + +
+ ) : ( + + + Total: {logosArray.length} logo{logosArray.length !== 1 ? 's' : ''} + + + + + + Preview + Name + URL + Actions + + + {rows} +
+
+ )} +
+ + + + setDeleteConfirmOpen(false)} + onConfirm={confirmDeleteLogo} + title="Delete Logo" + message={ + logoToDelete ? ( +
+ Are you sure you want to delete the logo "{logoToDelete.name}"? +
+ + This action cannot be undone. + +
+ ) : ( + 'Are you sure you want to delete this logo?' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + /> + + ); +}; + +export default LogosPage; diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 40791cf4..a4c61149 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -21,7 +21,7 @@ const useChannelsStore = create((set, get) => ({ forceUpdate: 0, triggerUpdate: () => { - set({ forecUpdate: new Date() }); + set({ forceUpdate: new Date() }); }, fetchChannels: async () => { @@ -255,6 +255,24 @@ const useChannelsStore = create((set, get) => ({ }, })), + updateLogo: (logo) => + set((state) => ({ + logos: { + ...state.logos, + [logo.id]: { + ...logo, + url: logo.url.replace(/^\/data/, ''), + }, + }, + })), + + removeLogo: (logoId) => + set((state) => { + const newLogos = { ...state.logos }; + delete newLogos[logoId]; + return { logos: newLogos }; + }), + addProfile: (profile) => set((state) => ({ profiles: { From cea078f6ef5b20cbbb8c0fd6991964ca76527bba Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 18:37:22 -0500 Subject: [PATCH 35/74] Use default user-agent and adjust timeouts. --- apps/channels/api_views.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 96b7362f..310fccbb 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -17,6 +17,8 @@ from apps.accounts.permissions import ( permission_classes_by_method, ) +from core.models import UserAgent, CoreSettings + from .models import ( Stream, Channel, @@ -1053,14 +1055,14 @@ class LogoViewSet(viewsets.ModelViewSet): def destroy(self, request, *args, **kwargs): """Delete a logo""" logo = self.get_object() - + # Check if logo is being used by any channels if logo.channels.exists(): return Response( {"error": f"Cannot delete logo as it is used by {logo.channels.count()} channel(s)"}, status=status.HTTP_400_BAD_REQUEST ) - + return super().destroy(request, *args, **kwargs) @action(detail=False, methods=["post"]) @@ -1117,12 +1119,21 @@ class LogoViewSet(viewsets.ModelViewSet): else: # Remote image try: + # Get the default user agent + try: + default_user_agent_id = CoreSettings.get_default_user_agent_id() + user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id)) + user_agent = user_agent_obj.user_agent + except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError): + # Fallback to hardcoded if default not found + user_agent = 'Dispatcharr/1.0' + # Add proper timeouts to prevent hanging remote_response = requests.get( - logo_url, - stream=True, - timeout=(10, 30), # (connect_timeout, read_timeout) - headers={'User-Agent': 'Dispatcharr/1.0'} + logo_url, + stream=True, + timeout=(3, 5), # (connect_timeout, read_timeout) + headers={'User-Agent': user_agent} ) if remote_response.status_code == 200: # Try to get content type from response headers first From 6afd5a38c9429459c12c757eef46e3f0b6c96527 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 18:44:53 -0500 Subject: [PATCH 36/74] Add timeouts to logo fetching to avoid hanging UI if a logo is unreachable. Also add default user-agent to request to prevent servers from denying request. Fixes #217 and Fixes #101 --- apps/channels/api_views.py | 55 +++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index f0f59f29..310fccbb 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -17,6 +17,8 @@ from apps.accounts.permissions import ( permission_classes_by_method, ) +from core.models import UserAgent, CoreSettings + from .models import ( Stream, Channel, @@ -1038,6 +1040,31 @@ class LogoViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def create(self, request, *args, **kwargs): + """Create a new logo entry""" + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + logo = serializer.save() + return Response(self.get_serializer(logo).data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + """Update an existing logo""" + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """Delete a logo""" + logo = self.get_object() + + # Check if logo is being used by any channels + if logo.channels.exists(): + return Response( + {"error": f"Cannot delete logo as it is used by {logo.channels.count()} channel(s)"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().destroy(request, *args, **kwargs) + @action(detail=False, methods=["post"]) def upload(self, request): if "file" not in request.FILES: @@ -1062,7 +1089,7 @@ class LogoViewSet(viewsets.ModelViewSet): ) return Response( - {"id": logo.id, "name": logo.name, "url": logo.url}, + LogoSerializer(logo, context={'request': request}).data, status=status.HTTP_201_CREATED, ) @@ -1092,7 +1119,22 @@ class LogoViewSet(viewsets.ModelViewSet): else: # Remote image try: - remote_response = requests.get(logo_url, stream=True) + # Get the default user agent + try: + default_user_agent_id = CoreSettings.get_default_user_agent_id() + user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id)) + user_agent = user_agent_obj.user_agent + except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError): + # Fallback to hardcoded if default not found + user_agent = 'Dispatcharr/1.0' + + # Add proper timeouts to prevent hanging + remote_response = requests.get( + logo_url, + stream=True, + timeout=(3, 5), # (connect_timeout, read_timeout) + headers={'User-Agent': user_agent} + ) if remote_response.status_code == 200: # Try to get content type from response headers first content_type = remote_response.headers.get("Content-Type") @@ -1114,7 +1156,14 @@ class LogoViewSet(viewsets.ModelViewSet): ) return response raise Http404("Remote image not found") - except requests.RequestException: + except requests.exceptions.Timeout: + logger.warning(f"Timeout fetching logo from {logo_url}") + raise Http404("Logo request timed out") + except requests.exceptions.ConnectionError: + logger.warning(f"Connection error fetching logo from {logo_url}") + raise Http404("Unable to connect to logo server") + except requests.RequestException as e: + logger.warning(f"Error fetching logo from {logo_url}: {e}") raise Http404("Error fetching remote image") From 2bba31940d1ac4927827f7a97f994c59291f4ceb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 20:02:21 -0500 Subject: [PATCH 37/74] Use our custom table for displaying logos --- frontend/src/components/tables/LogosTable.jsx | 356 ++++++++++++++++++ frontend/src/pages/Logos.jsx | 204 +--------- 2 files changed, 363 insertions(+), 197 deletions(-) create mode 100644 frontend/src/components/tables/LogosTable.jsx diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx new file mode 100644 index 00000000..df6605d1 --- /dev/null +++ b/frontend/src/components/tables/LogosTable.jsx @@ -0,0 +1,356 @@ +import React, { useMemo, useCallback, useState } from 'react'; +import API from '../../api'; +import LogoForm from '../forms/Logo'; +import useChannelsStore from '../../store/channels'; +import useLocalStorage from '../../hooks/useLocalStorage'; +import { + SquarePlus, + SquareMinus, + SquarePen, + ExternalLink, +} from 'lucide-react'; +import { + ActionIcon, + Box, + Text, + Paper, + Button, + Flex, + Group, + useMantineTheme, + LoadingOverlay, + Stack, + Image, + Center, +} from '@mantine/core'; +import { CustomTable, useTable } from './CustomTable'; +import ConfirmationDialog from '../ConfirmationDialog'; +import { notifications } from '@mantine/notifications'; + +const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => { + const [tableSize, _] = useLocalStorage('table-size', 'default'); + + const onEdit = useCallback(() => { + editLogo(row.original); + }, [row.original, editLogo]); + + const onDelete = useCallback(() => { + deleteLogo(row.original.id); + }, [row.original.id, deleteLogo]); + + const iconSize = + tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; + + return ( + + + + + + + + + + + + ); +}; + +const LogosTable = () => { + const theme = useMantineTheme(); + + /** + * STORES + */ + const { logos, fetchLogos } = useChannelsStore(); + + /** + * useState + */ + const [selectedLogo, setSelectedLogo] = useState(null); + const [logoModalOpen, setLogoModalOpen] = useState(false); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [logoToDelete, setLogoToDelete] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + /** + * Functions + */ + const executeDeleteLogo = useCallback(async (id) => { + setIsLoading(true); + try { + await API.deleteLogo(id); + await fetchLogos(); + notifications.show({ + title: 'Success', + message: 'Logo deleted successfully', + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete logo', + color: 'red', + }); + } finally { + setIsLoading(false); + setConfirmDeleteOpen(false); + } + }, [fetchLogos]); + + const editLogo = useCallback(async (logo = null) => { + setSelectedLogo(logo); + setLogoModalOpen(true); + }, []); + + const deleteLogo = useCallback(async (id) => { + const logosArray = Object.values(logos || {}); + const logo = logosArray.find((l) => l.id === id); + setLogoToDelete(logo); + setDeleteTarget(id); + setConfirmDeleteOpen(true); + }, [logos]); + + /** + * useMemo + */ + const columns = useMemo( + () => [ + { + header: 'Preview', + accessorKey: 'cache_url', + size: 80, + enableSorting: false, + cell: ({ getValue, row }) => ( +
+ {row.original.name} +
+ ), + }, + { + header: 'Name', + accessorKey: 'name', + size: 200, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }, + { + header: 'URL', + accessorKey: 'url', + cell: ({ getValue }) => ( + + + + {getValue()} + + + {getValue()?.startsWith('http') && ( + window.open(getValue(), '_blank')} + > + + + )} + + ), + }, + { + id: 'actions', + size: 80, + header: 'Actions', + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + ], + [theme, editLogo, deleteLogo] + ); + + const closeLogoForm = () => { + setSelectedLogo(null); + setLogoModalOpen(false); + fetchLogos(); // Refresh the logos list + }; + + const data = useMemo(() => { + const logosArray = Object.values(logos || {}); + return logosArray.sort((a, b) => a.id - b.id); + }, [logos]); + + const renderHeaderCell = (header) => { + return ( + + {header.column.columnDef.header} + + ); + }; + + const table = useTable({ + columns, + data, + allRowIds: data.map((logo) => logo.id), + enablePagination: false, + enableRowSelection: false, + enableRowVirtualization: false, + renderTopToolbar: false, + manualSorting: false, + manualFiltering: false, + manualPagination: false, + headerCellRenderFns: { + actions: renderHeaderCell, + cache_url: renderHeaderCell, + name: renderHeaderCell, + url: renderHeaderCell, + }, + }); + + return ( + <> + + + + + Logos + + + ({data.length} logo{data.length !== 1 ? 's' : ''}) + + + + + {/* Top toolbar */} + + + + + {/* Table container */} + +
+ + +
+
+
+
+
+ + + + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteLogo(deleteTarget)} + title="Delete Logo" + message={ + logoToDelete ? ( +
+ Are you sure you want to delete the logo "{logoToDelete.name}"? +
+ + This action cannot be undone. + +
+ ) : ( + 'Are you sure you want to delete this logo?' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + size="md" + /> + + ); +}; + +export default LogosTable; diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx index 7ca879f6..ee26c51e 100644 --- a/frontend/src/pages/Logos.jsx +++ b/frontend/src/pages/Logos.jsx @@ -1,39 +1,17 @@ -import React, { useState, useEffect } from 'react'; -import { - Container, - Title, - Button, - Table, - Group, - ActionIcon, - Text, - Image, - Box, - Center, - Stack, - Badge, -} from '@mantine/core'; -import { SquarePen, Trash2, Plus, ExternalLink } from 'lucide-react'; +import React, { useEffect } from 'react'; +import { Box } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import useChannelsStore from '../store/channels'; -import API from '../api'; -import LogoForm from '../components/forms/Logo'; -import ConfirmationDialog from '../components/ConfirmationDialog'; +import LogosTable from '../components/tables/LogosTable'; const LogosPage = () => { - const { logos, fetchLogos } = useChannelsStore(); - const [logoFormOpen, setLogoFormOpen] = useState(false); - const [editingLogo, setEditingLogo] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [logoToDelete, setLogoToDelete] = useState(null); - const [loading, setLoading] = useState(true); + const { fetchLogos } = useChannelsStore(); useEffect(() => { loadLogos(); }, []); const loadLogos = async () => { - setLoading(true); try { await fetchLogos(); } catch (error) { @@ -42,181 +20,13 @@ const LogosPage = () => { message: 'Failed to load logos', color: 'red', }); - } finally { - setLoading(false); } }; - const handleCreateLogo = () => { - setEditingLogo(null); - setLogoFormOpen(true); - }; - - const handleEditLogo = (logo) => { - setEditingLogo(logo); - setLogoFormOpen(true); - }; - - const handleDeleteLogo = (logo) => { - setLogoToDelete(logo); - setDeleteConfirmOpen(true); - }; - - const confirmDeleteLogo = async () => { - if (!logoToDelete) return; - - try { - await API.deleteLogo(logoToDelete.id); - await fetchLogos(); - notifications.show({ - title: 'Success', - message: 'Logo deleted successfully', - color: 'green', - }); - } catch (error) { - notifications.show({ - title: 'Error', - message: 'Failed to delete logo', - color: 'red', - }); - } finally { - setDeleteConfirmOpen(false); - setLogoToDelete(null); - } - }; - - const handleFormClose = () => { - setLogoFormOpen(false); - setEditingLogo(null); - loadLogos(); // Refresh the logos list - }; - - const logosArray = Object.values(logos || {}); - - const rows = logosArray.map((logo) => ( - - -
- {logo.name} -
-
- - {logo.name} - - - - - {logo.url} - - {logo.url.startsWith('http') && ( - window.open(logo.url, '_blank')} - > - - - )} - - - - - handleEditLogo(logo)} - color="blue" - > - - - handleDeleteLogo(logo)} - color="red" - > - - - - -
- )); - return ( - <> - - - Logos - - - - {loading ? ( -
- Loading logos... -
- ) : logosArray.length === 0 ? ( -
- - No logos found - Click "Add Logo" to create your first logo - -
- ) : ( - - - Total: {logosArray.length} logo{logosArray.length !== 1 ? 's' : ''} - - - - - - Preview - Name - URL - Actions - - - {rows} -
-
- )} -
- - - - setDeleteConfirmOpen(false)} - onConfirm={confirmDeleteLogo} - title="Delete Logo" - message={ - logoToDelete ? ( -
- Are you sure you want to delete the logo "{logoToDelete.name}"? -
- - This action cannot be undone. - -
- ) : ( - 'Are you sure you want to delete this logo?' - ) - } - confirmLabel="Delete" - cancelLabel="Cancel" - /> - + + + ); }; From 500df533bbe3ca68ab824ec5fc8f477a25a93086 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 20:12:25 -0500 Subject: [PATCH 38/74] Center logos in the column. --- frontend/src/components/tables/LogosTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index df6605d1..257bb17f 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -133,7 +133,7 @@ const LogosTable = () => { size: 80, enableSorting: false, cell: ({ getValue, row }) => ( -
+
{row.original.name} Date: Tue, 15 Jul 2025 20:14:34 -0500 Subject: [PATCH 39/74] Add padding to logos. --- frontend/src/components/tables/LogosTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 257bb17f..9138aeb7 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -133,7 +133,7 @@ const LogosTable = () => { size: 80, enableSorting: false, cell: ({ getValue, row }) => ( -
+
{row.original.name} Date: Tue, 15 Jul 2025 20:26:02 -0500 Subject: [PATCH 40/74] Enhance Logo management with filtering and usage details in API and UI --- apps/channels/api_views.py | 18 +++ apps/channels/serializers.py | 21 +++- frontend/src/api.js | 15 ++- frontend/src/components/tables/LogosTable.jsx | 108 +++++++++++++++++- 4 files changed, 155 insertions(+), 7 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 310fccbb..97d0b074 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1040,6 +1040,24 @@ class LogoViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def get_queryset(self): + """Optimize queryset with prefetch and add filtering""" + queryset = Logo.objects.prefetch_related('channels').order_by('name') + + # Filter by usage + used_filter = self.request.query_params.get('used', None) + if used_filter == 'true': + queryset = queryset.filter(channels__isnull=False).distinct() + elif used_filter == 'false': + queryset = queryset.filter(channels__isnull=True) + + # Filter by name + name_filter = self.request.query_params.get('name', None) + if name_filter: + queryset = queryset.filter(name__icontains=name_filter) + + return queryset + def create(self, request, *args, **kwargs): """Create a new logo entry""" serializer = self.get_serializer(data=request.data) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 278399dd..3346495e 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -20,10 +20,13 @@ from django.utils import timezone class LogoSerializer(serializers.ModelSerializer): cache_url = serializers.SerializerMethodField() + channel_count = serializers.SerializerMethodField() + is_used = serializers.SerializerMethodField() + channel_names = serializers.SerializerMethodField() class Meta: model = Logo - fields = ["id", "name", "url", "cache_url"] + fields = ["id", "name", "url", "cache_url", "channel_count", "is_used", "channel_names"] def get_cache_url(self, obj): # return f"/api/channels/logos/{obj.id}/cache/" @@ -34,6 +37,22 @@ class LogoSerializer(serializers.ModelSerializer): ) return reverse("api:channels:logo-cache", args=[obj.id]) + def get_channel_count(self, obj): + """Get the number of channels using this logo""" + return obj.channels.count() + + def get_is_used(self, obj): + """Check if this logo is used by any channels""" + return obj.channels.exists() + + def get_channel_names(self, obj): + """Get the names of channels using this logo (limited to first 5)""" + channels = obj.channels.all()[:5] + names = [channel.name for channel in channels] + if obj.channels.count() > 5: + names.append(f"...and {obj.channels.count() - 5} more") + return names + # # Stream diff --git a/frontend/src/api.js b/frontend/src/api.js index cbd8950a..3263eaf5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1225,9 +1225,10 @@ export default class API { } } - static async getLogos() { + static async getLogos(params = {}) { try { - const response = await request(`${host}/api/channels/logos/`); + const queryParams = new URLSearchParams(params); + const response = await request(`${host}/api/channels/logos/?${queryParams.toString()}`); return response; } catch (e) { @@ -1235,6 +1236,16 @@ export default class API { } } + static async fetchLogos() { + try { + const response = await this.getLogos(); + useChannelsStore.getState().setLogos(response); + return response; + } catch (e) { + errorNotification('Failed to fetch logos', e); + } + } + static async uploadLogo(file) { try { const formData = new FormData(); diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 9138aeb7..872b9dca 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -8,6 +8,7 @@ import { SquareMinus, SquarePen, ExternalLink, + Filter, } from 'lucide-react'; import { ActionIcon, @@ -22,6 +23,11 @@ import { Stack, Image, Center, + Badge, + Tooltip, + Select, + TextInput, + Menu, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; @@ -83,6 +89,10 @@ const LogosTable = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [logoToDelete, setLogoToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [filters, setFilters] = useState({ + name: '', + used: 'all' + }); /** * Functions @@ -155,6 +165,42 @@ const LogosTable = () => { ), }, + { + header: 'Usage', + accessorKey: 'channel_count', + size: 120, + cell: ({ getValue, row }) => { + const count = getValue(); + const channelNames = row.original.channel_names || []; + + if (count === 0) { + return ( + + Unused + + ); + } + + return ( + + Used by {count} channel{count !== 1 ? 's' : ''}: + {channelNames.map((name, index) => ( + • {name} + ))} + + } + multiline + width={220} + > + + {count} channel{count !== 1 ? 's' : ''} + + + ); + }, + }, { header: 'URL', accessorKey: 'url', @@ -211,8 +257,24 @@ const LogosTable = () => { const data = useMemo(() => { const logosArray = Object.values(logos || {}); - return logosArray.sort((a, b) => a.id - b.id); - }, [logos]); + + // Apply filters + let filteredLogos = logosArray; + + if (filters.name) { + filteredLogos = filteredLogos.filter(logo => + logo.name.toLowerCase().includes(filters.name.toLowerCase()) + ); + } + + if (filters.used === 'used') { + filteredLogos = filteredLogos.filter(logo => logo.is_used); + } else if (filters.used === 'unused') { + filteredLogos = filteredLogos.filter(logo => !logo.is_used); + } + + return filteredLogos.sort((a, b) => a.id - b.id); + }, [logos, filters]); const renderHeaderCell = (header) => { return ( @@ -238,6 +300,7 @@ const LogosTable = () => { cache_url: renderHeaderCell, name: renderHeaderCell, url: renderHeaderCell, + channel_count: renderHeaderCell, }, }); @@ -282,11 +345,44 @@ const LogosTable = () => { + + + setFilters(prev => ({ + ...prev, + name: event.currentTarget.value + })) + } + size="xs" + style={{ width: 200 }} + /> + { + const newValue = value ? parseInt(value) : null; + setGroupStates( + groupStates.map((state) => ({ + ...state, + custom_properties: { + ...state.custom_properties, + group_override: newValue, + }, + })) + ); + }} + data={Object.values(channelGroups).map((g) => ({ + value: g.id.toString(), + label: g.name, + }))} + clearable + searchable + size="xs" + /> )} diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 077602ad..3d34ca55 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -288,7 +288,8 @@ const ChannelsTable = ({ }) => { const [isLoading, setIsLoading] = useState(true); const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase); - const [epgUrl, setEPGUrl] = useState(epgUrlBase); const [m3uUrl, setM3UUrl] = useState(m3uUrlBase); + const [epgUrl, setEPGUrl] = useState(epgUrlBase); + const [m3uUrl, setM3UUrl] = useState(m3uUrlBase); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); @@ -308,7 +309,7 @@ const ChannelsTable = ({ }) => { }); /** - * Dereived variables + * Derived variables */ const activeGroupIds = new Set( Object.values(channels).map((channel) => channel.channel_group_id) From b406a3b504fda3aa588e605ff05a464568e1ccfe Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 19:02:03 -0500 Subject: [PATCH 48/74] Move force dummy epg to top. --- .../src/components/forms/M3UGroupFilter.jsx | 117 ++++++++++++------ 1 file changed, 76 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index f94f35ef..315a6424 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -254,13 +254,23 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { {/* Auto Sync Controls */} - toggleAutoSync(group.channel_group)} - size="xs" - /> + + toggleAutoSync(group.channel_group)} + size="xs" + /> + {group.auto_channel_sync && group.enabled && ( + toggleForceDummyEPG(group.channel_group)} + size="xs" + /> + )} + {group.auto_channel_sync && group.enabled && ( <> @@ -274,39 +284,64 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { precision={1} /> - {/* Force Dummy EPG Checkbox */} - toggleForceDummyEPG(group.channel_group)} - size="xs" - /> - - {/* Override Channel Group Select */} - { + const newValue = value ? parseInt(value) : null; + setGroupStates( + groupStates.map((state) => { + if (state.channel_group == group.channel_group) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + group_override: newValue, + }, + }; + } + return state; + }) + ); + }} + data={Object.values(channelGroups).map((g) => ({ + value: g.id.toString(), + label: g.name, + }))} + disabled={!(group.custom_properties && Object.prototype.hasOwnProperty.call(group.custom_properties, 'group_override'))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + )} @@ -334,4 +369,4 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { ); }; -export default M3UGroupFilter; +export default M3UGroupFilter; \ No newline at end of file From f40e9fb9be1458e56881828edce746a6e200a38f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 19:24:27 -0500 Subject: [PATCH 49/74] Update playlist store when auto sync settings change. --- frontend/src/api.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index e34dabe2..5812a4b9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -739,7 +739,9 @@ export default class API { method: 'PATCH', body: { group_settings: groupSettings }, }); - + // Fetch the updated playlist and update the store + const updatedPlaylist = await API.getPlaylist(playlistId); + usePlaylistsStore.getState().updatePlaylist(updatedPlaylist); return response; } catch (e) { errorNotification('Failed to update M3U group settings', e); @@ -781,7 +783,6 @@ export default class API { const response = await request(`${host}/api/m3u/refresh/${id}/`, { method: 'POST', }); - return response; } catch (e) { errorNotification('Failed to refresh M3U account', e); From cebc4c8ca931967f814bd6d1fee3a66f548801b3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 20:32:24 -0500 Subject: [PATCH 50/74] Add pagination. --- frontend/src/components/tables/LogosTable.jsx | 120 ++++++++++++++++-- 1 file changed, 111 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 61a325c2..64822cb5 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -31,6 +31,8 @@ import { TextInput, Menu, Checkbox, + Pagination, + NativeSelect, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; @@ -101,6 +103,12 @@ const LogosTable = () => { }); const [debouncedNameFilter, setDebouncedNameFilter] = useState(''); const [selectedRows, setSelectedRows] = useState(new Set()); + const [pageSize, setPageSize] = useLocalStorage('logos-page-size', 25); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: pageSize, + }); + const [paginationString, setPaginationString] = useState(''); // Debounce the name filter useEffect(() => { @@ -132,6 +140,13 @@ const LogosTable = () => { return filteredLogos.sort((a, b) => a.id - b.id); }, [logos, debouncedNameFilter, filters.used]); + // Get paginated data + const paginatedData = useMemo(() => { + const startIndex = pagination.pageIndex * pagination.pageSize; + const endIndex = startIndex + pagination.pageSize; + return data.slice(startIndex, endIndex); + }, [data, pagination.pageIndex, pagination.pageSize]); + // Calculate unused logos count const unusedLogosCount = useMemo(() => { const allLogos = Object.values(logos || {}); @@ -270,6 +285,29 @@ const LogosTable = () => { setSelectedRows(new Set()); }, [data.length]); + // Update pagination when pageSize changes + useEffect(() => { + setPagination(prev => ({ + ...prev, + pageSize: pageSize, + })); + }, [pageSize]); + + // Calculate pagination string + useEffect(() => { + const startItem = pagination.pageIndex * pagination.pageSize + 1; + const endItem = Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + data.length + ); + setPaginationString(`${startItem} to ${endItem} of ${data.length}`); + }, [pagination.pageIndex, pagination.pageSize, data.length]); + + // Calculate page count + const pageCount = useMemo(() => { + return Math.ceil(data.length / pagination.pageSize); + }, [data.length, pagination.pageSize]); + /** * useMemo */ @@ -425,17 +463,38 @@ const LogosTable = () => { setSelectedRows(new Set(newSelection)); }, []); + const onPageSizeChange = (e) => { + const newPageSize = parseInt(e.target.value); + setPageSize(newPageSize); + setPagination(prev => ({ + ...prev, + pageSize: newPageSize, + pageIndex: 0, // Reset to first page + })); + }; + + const onPageIndexChange = (pageIndex) => { + if (!pageIndex || pageIndex > pageCount) { + return; + } + + setPagination(prev => ({ + ...prev, + pageIndex: pageIndex - 1, + })); + }; + const table = useTable({ columns, - data, - allRowIds: data.map((logo) => logo.id), - enablePagination: false, + data: paginatedData, + allRowIds: paginatedData.map((logo) => logo.id), + enablePagination: false, // Disable internal pagination since we're handling it manually enableRowSelection: true, enableRowVirtualization: false, renderTopToolbar: false, manualSorting: false, manualFiltering: false, - manualPagination: false, + manualPagination: true, // Enable manual pagination onRowSelectionChange: onRowSelectionChange, headerCellRenderFns: { actions: renderHeaderCell, @@ -571,14 +630,57 @@ const LogosTable = () => { -
- - -
+ +
+ + +
+
+ + {/* Pagination Controls */} + + + Page Size + + + {paginationString} + +
From 05539794e3578101e84a2f643ebed69e0af7a12b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 20:45:04 -0500 Subject: [PATCH 51/74] Set better sizing. --- frontend/src/components/tables/LogosTable.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 64822cb5..477da0cf 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -512,7 +512,8 @@ const LogosTable = () => { display: 'flex', justifyContent: 'center', padding: '0px', - minHeight: '100vh', + minHeight: 'calc(100vh - 200px)', + minWidth: '900px', }} > @@ -636,10 +637,10 @@ const LogosTable = () => { -
+
From bd1831e226b7443cf4ac4c23e8e3e6a3b67c1d6c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 20:49:05 -0500 Subject: [PATCH 52/74] Fix edits not saving --- frontend/src/api.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index 63c193ba..967e462b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -209,10 +209,10 @@ export default class API { API.getAllChannelIds(API.lastQueryParams), ]); - useChannelsTableStore + useChannelsTable .getState() .queryChannels(response, API.lastQueryParams); - useChannelsTableStore.getState().setAllQueryIds(ids); + useChannelsTable.getState().setAllQueryIds(ids); return response; } catch (e) { @@ -1282,9 +1282,19 @@ export default class API { static async updateLogo(id, values) { try { + // Convert values to FormData for the multipart/form-data content type + const formData = new FormData(); + + // Add each field to the form data + Object.keys(values).forEach(key => { + if (values[key] !== null && values[key] !== undefined) { + formData.append(key, values[key]); + } + }); + const response = await request(`${host}/api/channels/logos/${id}/`, { method: 'PUT', - body: values, + body: formData, // Send as FormData instead of JSON }); useChannelsStore.getState().updateLogo(response); From 8e2309ac583c05bb2b479bc97b799c1989826921 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 21:02:50 -0500 Subject: [PATCH 53/74] Fixes logo uploads --- apps/channels/api_views.py | 14 +++++++- dispatcharr/utils.py | 8 ++--- frontend/src/api.js | 42 +++++++++++++++++++--- frontend/src/components/forms/Channel.jsx | 27 +++++++++++--- frontend/src/components/forms/Channels.jsx | 27 +++++++++++--- frontend/src/components/forms/Logo.jsx | 23 +++++++++--- 6 files changed, 119 insertions(+), 22 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 0956da11..ee7109b7 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1172,6 +1172,16 @@ class LogoViewSet(viewsets.ModelViewSet): ) file = request.FILES["file"] + + # Validate file + try: + from dispatcharr.utils import validate_logo_file + validate_logo_file(file) + except Exception as e: + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + file_name = file.name file_path = os.path.join("/data/logos", file_name) @@ -1187,8 +1197,10 @@ class LogoViewSet(viewsets.ModelViewSet): }, ) + # Use get_serializer to ensure proper context + serializer = self.get_serializer(logo) return Response( - LogoSerializer(logo, context={'request': request}).data, + serializer.data, status=status.HTTP_201_CREATED, ) diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index 767913c6..5e1ad087 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -21,11 +21,11 @@ def json_success_response(data=None, status=200): def validate_logo_file(file): """Validate uploaded logo file size and MIME type.""" - valid_mime_types = ["image/jpeg", "image/png", "image/gif"] + valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp"] if file.content_type not in valid_mime_types: - raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF.") - if file.size > 2 * 1024 * 1024: - raise ValidationError("File too large. Max 2MB.") + raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP.") + if file.size > 5 * 1024 * 1024: # Increased to 5MB + raise ValidationError("File too large. Max 5MB.") def get_client_ip(request): diff --git a/frontend/src/api.js b/frontend/src/api.js index 967e462b..bcffc920 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -209,10 +209,10 @@ export default class API { API.getAllChannelIds(API.lastQueryParams), ]); - useChannelsTable + useChannelsTableStore .getState() .queryChannels(response, API.lastQueryParams); - useChannelsTable.getState().setAllQueryIds(ids); + useChannelsTableStore.getState().setAllQueryIds(ids); return response; } catch (e) { @@ -1252,16 +1252,48 @@ export default class API { const formData = new FormData(); formData.append('file', file); - const response = await request(`${host}/api/channels/logos/upload/`, { + // Add timeout handling for file uploads + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + + const response = await fetch(`${host}/api/channels/logos/upload/`, { method: 'POST', body: formData, + headers: { + Authorization: `Bearer ${await API.getAuthToken()}`, + }, + signal: controller.signal, }); - useChannelsStore.getState().addLogo(response); + clearTimeout(timeoutId); - return response; + 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; + } + + const result = await response.json(); + useChannelsStore.getState().addLogo(result); + return result; } catch (e) { + if (e.name === 'AbortError') { + const timeoutError = new Error('Upload timed out. Please try again.'); + timeoutError.code = 'NETWORK_ERROR'; + throw timeoutError; + } errorNotification('Failed to upload logo', e); + throw e; } } diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 64412cb4..c7d8ed6c 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -31,6 +31,7 @@ import { Image, UnstyledButton, } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react'; import useEPGsStore from '../../store/epgs'; import { Dropzone } from '@mantine/dropzone'; @@ -84,10 +85,28 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const handleLogoChange = async (files) => { if (files.length === 1) { - const retval = await API.uploadLogo(files[0]); - await fetchLogos(); - setLogoPreview(retval.cache_url); - formik.setFieldValue('logo_id', retval.id); + const file = files[0]; + + // Validate file size on frontend first + if (file.size > 5 * 1024 * 1024) { + // 5MB + notifications.show({ + title: 'Error', + message: 'File too large. Maximum size is 5MB.', + color: 'red', + }); + return; + } + + try { + const retval = await API.uploadLogo(file); + await fetchLogos(); + setLogoPreview(retval.cache_url); + formik.setFieldValue('logo_id', retval.id); + } catch (error) { + console.error('Logo upload failed:', error); + // Error notification is already handled in API.uploadLogo + } } else { setLogoPreview(null); } diff --git a/frontend/src/components/forms/Channels.jsx b/frontend/src/components/forms/Channels.jsx index dbce5cf3..e67d9419 100644 --- a/frontend/src/components/forms/Channels.jsx +++ b/frontend/src/components/forms/Channels.jsx @@ -34,6 +34,7 @@ import { import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react'; import useEPGsStore from '../../store/epgs'; import { Dropzone } from '@mantine/dropzone'; +import { notifications } from '@mantine/notifications'; import { FixedSizeList as List } from 'react-window'; const ChannelsForm = ({ channel = null, isOpen, onClose }) => { @@ -81,10 +82,28 @@ const ChannelsForm = ({ channel = null, isOpen, onClose }) => { const handleLogoChange = async (files) => { if (files.length === 1) { - const retval = await API.uploadLogo(files[0]); - await fetchLogos(); - setLogoPreview(retval.cache_url); - formik.setFieldValue('logo_id', retval.id); + const file = files[0]; + + // Validate file size on frontend first + if (file.size > 5 * 1024 * 1024) { + // 5MB + notifications.show({ + title: 'Error', + message: 'File too large. Maximum size is 5MB.', + color: 'red', + }); + return; + } + + try { + const retval = await API.uploadLogo(file); + await fetchLogos(); + setLogoPreview(retval.cache_url); + formik.setFieldValue('logo_id', retval.id); + } catch (error) { + console.error('Logo upload failed:', error); + // Error notification is already handled in API.uploadLogo + } } else { setLogoPreview(null); } diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index c3e48d5d..436dbf8a 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -51,12 +51,12 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { onClose(); } catch (error) { let errorMessage = logo ? 'Failed to update logo' : 'Failed to create logo'; - + // Handle specific timeout errors if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) { errorMessage = 'Request timed out. Please try again.'; } - + notifications.show({ title: 'Error', message: errorMessage, @@ -85,6 +85,17 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { if (files.length === 0) return; const file = files[0]; + + // Validate file size on frontend first + if (file.size > 5 * 1024 * 1024) { // 5MB + notifications.show({ + title: 'Error', + message: 'File too large. Maximum size is 5MB.', + color: 'red', + }); + return; + } + setUploading(true); try { @@ -102,12 +113,16 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { }); } catch (error) { let errorMessage = 'Failed to upload logo'; - + // Handle specific timeout errors if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) { errorMessage = 'Upload timed out. Please try again.'; + } else if (error.status === 413) { + errorMessage = 'File too large. Please choose a smaller file.'; + } else if (error.body?.error) { + errorMessage = error.body.error; } - + notifications.show({ title: 'Error', message: errorMessage, From 5d82fd17c2865aef1b36a743a80248bdadf76114 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 21:09:05 -0500 Subject: [PATCH 54/74] Treat local files as valid urls --- frontend/src/components/forms/Logo.jsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index 436dbf8a..7c685b2f 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -29,7 +29,20 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { }, validationSchema: Yup.object({ name: Yup.string().required('Name is required'), - url: Yup.string().url('Must be a valid URL').required('URL is required'), + url: Yup.string() + .required('URL is required') + .test('valid-url-or-path', 'Must be a valid URL or local file path', (value) => { + if (!value) return false; + // Allow local file paths starting with /logos/ + if (value.startsWith('/logos/')) return true; + // Allow valid URLs + try { + new URL(value); + return true; + } catch { + return false; + } + }), }), onSubmit: async (values, { setSubmitting }) => { try { From 23bd5484ee1a0e3a472ba865a961c661189de5ef Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 21:12:05 -0500 Subject: [PATCH 55/74] Enlarge logo on hover. --- frontend/src/components/forms/Logo.jsx | 13 +++++++++++++ frontend/src/components/tables/LogosTable.jsx | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index 7c685b2f..bd711443 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -179,6 +179,19 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { height={75} fit="contain" fallbackSrc="/logo.png" + style={{ + transition: 'transform 0.3s ease', + cursor: 'pointer', + ':hover': { + transform: 'scale(1.5)' + } + }} + onMouseEnter={(e) => { + e.target.style.transform = 'scale(1.5)'; + }} + onMouseLeave={(e) => { + e.target.style.transform = 'scale(1)'; + }} />
diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 477da0cf..41799449 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -347,6 +347,16 @@ const LogosTable = () => { height={30} fit="contain" fallbackSrc="/logo.png" + style={{ + transition: 'transform 0.3s ease', + cursor: 'pointer', + }} + onMouseEnter={(e) => { + e.target.style.transform = 'scale(1.5)'; + }} + onMouseLeave={(e) => { + e.target.style.transform = 'scale(1)'; + }} />
), From e7771d5b6764c7c18c15384d51fba3adbec3da23 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 11:36:15 -0500 Subject: [PATCH 56/74] Allow deleting logos that are assigned to channels. --- apps/channels/api_views.py | 42 ++++++++++--------- apps/channels/serializers.py | 10 +++++ frontend/src/components/forms/Logo.jsx | 2 + frontend/src/components/tables/LogosTable.jsx | 5 ++- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ee7109b7..dbdd4271 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1053,24 +1053,27 @@ class BulkDeleteLogosAPIView(APIView): def delete(self, request): logo_ids = request.data.get("logo_ids", []) - # Check if any logos are being used by channels - used_logos = Logo.objects.filter( - id__in=logo_ids, - channels__isnull=False - ).distinct() + # Get logos and their usage info before deletion + logos_to_delete = Logo.objects.filter(id__in=logo_ids) + total_channels_affected = 0 + + for logo in logos_to_delete: + if logo.channels.exists(): + channel_count = logo.channels.count() + total_channels_affected += channel_count + # Remove logo from channels + logo.channels.update(logo=None) + logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion") - if used_logos.exists(): - used_names = list(used_logos.values_list('name', flat=True)) - return Response( - {"error": f"Cannot delete logos that are in use: {', '.join(used_names)}"}, - status=status.HTTP_400_BAD_REQUEST - ) + # Delete logos + deleted_count = logos_to_delete.delete()[0] - # Delete logos that are not in use - deleted_count = Logo.objects.filter(id__in=logo_ids).delete()[0] + message = f"Successfully deleted {deleted_count} logos" + if total_channels_affected > 0: + message += f" and removed them from {total_channels_affected} channels" return Response( - {"message": f"Successfully deleted {deleted_count} logos"}, + {"message": message}, status=status.HTTP_204_NO_CONTENT ) @@ -1152,15 +1155,14 @@ class LogoViewSet(viewsets.ModelViewSet): return super().update(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): - """Delete a logo""" + """Delete a logo and remove it from any channels using it""" logo = self.get_object() - # Check if logo is being used by any channels + # Instead of preventing deletion, remove the logo from channels if logo.channels.exists(): - return Response( - {"error": f"Cannot delete logo as it is used by {logo.channels.count()} channel(s)"}, - status=status.HTTP_400_BAD_REQUEST - ) + channel_count = logo.channels.count() + logo.channels.update(logo=None) + logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion") return super().destroy(request, *args, **kwargs) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index a933c496..82b5f808 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -28,6 +28,16 @@ class LogoSerializer(serializers.ModelSerializer): model = Logo fields = ["id", "name", "url", "cache_url", "channel_count", "is_used", "channel_names"] + def validate_url(self, value): + """Validate that the URL is unique for creation or update""" + if self.instance and self.instance.url == value: + return value + + if Logo.objects.filter(url=value).exists(): + raise serializers.ValidationError("A logo with this URL already exists.") + + return value + def get_cache_url(self, obj): # return f"/api/channels/logos/{obj.id}/cache/" request = self.context.get("request") diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index bd711443..c724c21c 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -68,6 +68,8 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { // Handle specific timeout errors if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) { errorMessage = 'Request timed out. Please try again.'; + } else if (error.response?.data?.error) { + errorMessage = error.response.data.error; } notifications.show({ diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 41799449..0ec6488f 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -718,6 +718,9 @@ const LogosTable = () => { isBulkDelete ? (
Are you sure you want to delete {selectedRows.size} selected logos? + + Any channels using these logos will have their logo removed. + This action cannot be undone. @@ -727,7 +730,7 @@ const LogosTable = () => { Are you sure you want to delete the logo "{logoToDelete.name}"? {logoToDelete.channel_count > 0 && ( - Warning: This logo is currently used by {logoToDelete.channel_count} channel{logoToDelete.channel_count !== 1 ? 's' : ''}. + This logo is currently used by {logoToDelete.channel_count} channel{logoToDelete.channel_count !== 1 ? 's' : ''}. They will have their logo removed. )} From 0fcb8b9f2eeec7a74e03d9afe36c07d898f50a1e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 13:44:00 -0500 Subject: [PATCH 57/74] Don't convert urls in the store. --- frontend/src/store/channels.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index a4c61149..b18b02f6 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -232,7 +232,6 @@ const useChannelsStore = create((set, get) => ({ logos: logos.reduce((acc, logo) => { acc[logo.id] = { ...logo, - url: logo.url.replace(/^\/data/, ''), }; return acc; }, {}), @@ -250,7 +249,6 @@ const useChannelsStore = create((set, get) => ({ ...state.logos, [newLogo.id]: { ...newLogo, - url: newLogo.url.replace(/^\/data/, ''), }, }, })), @@ -261,7 +259,6 @@ const useChannelsStore = create((set, get) => ({ ...state.logos, [logo.id]: { ...logo, - url: logo.url.replace(/^\/data/, ''), }, }, })), From e27f45809bb479967ba92fef108857c55d34c556 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 13:47:50 -0500 Subject: [PATCH 58/74] Allow /data/logos as a url. --- frontend/src/components/forms/Logo.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index c724c21c..e209659c 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -33,8 +33,8 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { .required('URL is required') .test('valid-url-or-path', 'Must be a valid URL or local file path', (value) => { if (!value) return false; - // Allow local file paths starting with /logos/ - if (value.startsWith('/logos/')) return true; + // Allow local file paths starting with /data/logos/ + if (value.startsWith('/data/logos/')) return true; // Allow valid URLs try { new URL(value); From 1ece74a0b0d2cbdbe849988ac274721fae1f8bda Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 14:07:58 -0500 Subject: [PATCH 59/74] Scan logos folder for new logos. --- core/tasks.py | 117 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/core/tasks.py b/core/tasks.py index e8b36162..41e5d707 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -21,10 +21,12 @@ logger = logging.getLogger(__name__) EPG_WATCH_DIR = '/data/epgs' M3U_WATCH_DIR = '/data/m3us' +LOGO_WATCH_DIR = '/data/logos' MIN_AGE_SECONDS = 6 STARTUP_SKIP_AGE = 30 REDIS_PREFIX = "processed_file:" REDIS_TTL = 60 * 60 * 24 * 3 # expire keys after 3 days (optional) +SUPPORTED_LOGO_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'] # Store the last known value to compare with new data last_known_data = {} @@ -56,10 +58,11 @@ def scan_and_process_files(): global _first_scan_completed redis_client = RedisClient.get_client() now = time.time() + # Check if directories exist - dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR]) + dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR, LOGO_WATCH_DIR]) if not dirs_exist: - throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)})", "watch_dirs_missing") + throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)}), LOGO ({os.path.exists(LOGO_WATCH_DIR)})", "watch_dirs_missing") # Process M3U files m3u_files = [f for f in os.listdir(M3U_WATCH_DIR) @@ -266,6 +269,116 @@ def scan_and_process_files(): logger.trace(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors") + # Process Logo files + try: + logo_files = os.listdir(LOGO_WATCH_DIR) if os.path.exists(LOGO_WATCH_DIR) else [] + logger.trace(f"Found {len(logo_files)} files in LOGO directory") + except Exception as e: + logger.error(f"Error listing LOGO directory: {e}") + logo_files = [] + + logo_processed = 0 + logo_skipped = 0 + logo_errors = 0 + + for filename in logo_files: + filepath = os.path.join(LOGO_WATCH_DIR, filename) + + if not os.path.isfile(filepath): + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Not a file") + else: + logger.debug(f"Skipping {filename}: Not a file") + logo_skipped += 1 + continue + + # Check if file has supported logo extension + file_ext = os.path.splitext(filename)[1].lower() + if file_ext not in SUPPORTED_LOGO_FORMATS: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Not a supported logo format") + else: + logger.debug(f"Skipping {filename}: Not a supported logo format") + logo_skipped += 1 + continue + + mtime = os.path.getmtime(filepath) + age = now - mtime + redis_key = REDIS_PREFIX + filepath + stored_mtime = redis_client.get(redis_key) + + # Check if logo already exists in database + if not stored_mtime and age > STARTUP_SKIP_AGE: + from apps.channels.models import Logo + existing_logo = Logo.objects.filter(url=filepath).exists() + if existing_logo: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already exists in database") + else: + logger.debug(f"Skipping {filename}: Already exists in database") + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + logo_skipped += 1 + continue + else: + logger.debug(f"Processing {filename} despite age: Not found in database") + + # File too new — probably still being written + if age < MIN_AGE_SECONDS: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)") + else: + logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)") + logo_skipped += 1 + continue + + # Skip if we've already processed this mtime + if stored_mtime and float(stored_mtime) >= mtime: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already processed this version") + else: + logger.debug(f"Skipping {filename}: Already processed this version") + logo_skipped += 1 + continue + + try: + from apps.channels.models import Logo + + # Create logo entry with just the filename (without extension) as name + logo_name = os.path.splitext(filename)[0] + + logo, created = Logo.objects.get_or_create( + url=filepath, + defaults={ + "name": logo_name, + } + ) + + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + if created: + logger.info(f"Created new logo entry: {logo_name}") + else: + logger.debug(f"Logo entry already exists: {logo_name}") + + logo_processed += 1 + + # Send websocket notification + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "logo_file", "filename": filename, "created": created} + }, + ) + + except Exception as e: + logger.error(f"Error processing logo file {filename}: {str(e)}", exc_info=True) + logo_errors += 1 + continue + + logger.trace(f"LOGO processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors") + # Mark that the first scan is complete _first_scan_completed = True From 13672919d0f7c10f0abcc3ebebcbc8169e47711f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 14:26:09 -0500 Subject: [PATCH 60/74] Fetch Playlists on successful m3u update. --- frontend/src/WebSocket.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 538ffda3..ae0316ad 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -218,6 +218,7 @@ export const WebsocketProvider = ({ children }) => { } updatePlaylist(updateData); + fetchPlaylists(); // Refresh playlists to ensure UI is up-to-date } else { // Log when playlist can't be found for debugging purposes console.warn( From 479826709bdd41c3bf1a2923ba11f3ddf30e6121 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 15:01:26 -0500 Subject: [PATCH 61/74] Fetch logos when logos are added by filesystem scan. --- core/tasks.py | 26 +++++++++++++++++--------- frontend/src/WebSocket.jsx | 11 +++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/core/tasks.py b/core/tasks.py index 41e5d707..3a738611 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -362,15 +362,7 @@ def scan_and_process_files(): logo_processed += 1 - # Send websocket notification - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - "updates", - { - "type": "update", - "data": {"success": True, "type": "logo_file", "filename": filename, "created": created} - }, - ) + # Remove individual websocket notification - will send summary instead except Exception as e: logger.error(f"Error processing logo file {filename}: {str(e)}", exc_info=True) @@ -379,6 +371,22 @@ def scan_and_process_files(): logger.trace(f"LOGO processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors") + # Send summary websocket update for logo processing + if logo_processed > 0 or logo_errors > 0: + send_websocket_update( + "updates", + "update", + { + "success": True, + "type": "logo_processing_summary", + "processed": logo_processed, + "skipped": logo_skipped, + "errors": logo_errors, + "total_files": len(logo_files), + "message": f"Logo processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors" + } + ) + # Mark that the first scan is complete _first_scan_completed = True diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index ae0316ad..156a7e29 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -418,6 +418,16 @@ export const WebsocketProvider = ({ children }) => { } break; + case 'logo_processing_summary': + notifications.show({ + title: 'Logo Processing Summary', + message: `Logo processing complete: ${parsedEvent.data.processed} logos processed, ${parsedEvent.data.duplicates_merged} duplicates merged.`, + color: 'blue', + autoClose: 5000, + }); + fetchLogos(); + break; + default: console.error( `Unknown websocket event type: ${parsedEvent.data?.type}` @@ -488,6 +498,7 @@ export const WebsocketProvider = ({ children }) => { const setProfilePreview = usePlaylistsStore((s) => s.setProfilePreview); const fetchEPGData = useEPGsStore((s) => s.fetchEPGData); const fetchEPGs = useEPGsStore((s) => s.fetchEPGs); + const fetchLogos = useChannelsStore((s) => s.fetchLogos); const ret = useMemo(() => { return [isReady, ws.current?.send.bind(ws.current), val]; From e876af1aa2a79b0b7046a56b31c99ab85010c08b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 15:04:34 -0500 Subject: [PATCH 62/74] Scan sub folders for logos. --- core/tasks.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/core/tasks.py b/core/tasks.py index 3a738611..47bc8cf0 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -269,10 +269,14 @@ def scan_and_process_files(): logger.trace(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors") - # Process Logo files + # Process Logo files (including subdirectories) try: - logo_files = os.listdir(LOGO_WATCH_DIR) if os.path.exists(LOGO_WATCH_DIR) else [] - logger.trace(f"Found {len(logo_files)} files in LOGO directory") + logo_files = [] + if os.path.exists(LOGO_WATCH_DIR): + for root, dirs, files in os.walk(LOGO_WATCH_DIR): + for filename in files: + logo_files.append(os.path.join(root, filename)) + logger.trace(f"Found {len(logo_files)} files in LOGO directory (including subdirectories)") except Exception as e: logger.error(f"Error listing LOGO directory: {e}") logo_files = [] @@ -281,8 +285,8 @@ def scan_and_process_files(): logo_skipped = 0 logo_errors = 0 - for filename in logo_files: - filepath = os.path.join(LOGO_WATCH_DIR, filename) + for filepath in logo_files: + filename = os.path.basename(filepath) if not os.path.isfile(filepath): if _first_scan_completed: @@ -362,8 +366,6 @@ def scan_and_process_files(): logo_processed += 1 - # Remove individual websocket notification - will send summary instead - except Exception as e: logger.error(f"Error processing logo file {filename}: {str(e)}", exc_info=True) logo_errors += 1 From d926d90dd913d266701193e8a4401c12930c591d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 15:14:11 -0500 Subject: [PATCH 63/74] Fix websocket message. --- frontend/src/WebSocket.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 156a7e29..2e210461 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -421,7 +421,7 @@ export const WebsocketProvider = ({ children }) => { case 'logo_processing_summary': notifications.show({ title: 'Logo Processing Summary', - message: `Logo processing complete: ${parsedEvent.data.processed} logos processed, ${parsedEvent.data.duplicates_merged} duplicates merged.`, + message: `${parsedEvent.data.message}`, color: 'blue', autoClose: 5000, }); From bc08cb1270a618deec0bc924c7f00a19013f9404 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 18 Jul 2025 15:23:30 -0500 Subject: [PATCH 64/74] Ask to delete local files as well. --- apps/channels/api_views.py | 34 ++++++++++++++++++- frontend/src/api.js | 19 ++++++++--- .../src/components/ConfirmationDialog.jsx | 28 +++++++++++++-- frontend/src/components/tables/LogosTable.jsx | 27 +++++++++++---- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index dbdd4271..0126aaf9 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1052,12 +1052,28 @@ class BulkDeleteLogosAPIView(APIView): ) def delete(self, request): logo_ids = request.data.get("logo_ids", []) + delete_files = request.data.get("delete_files", False) # Get logos and their usage info before deletion logos_to_delete = Logo.objects.filter(id__in=logo_ids) total_channels_affected = 0 - + local_files_deleted = 0 + for logo in logos_to_delete: + # Handle file deletion for local files + if delete_files and logo.url and logo.url.startswith('/data/logos'): + try: + if os.path.exists(logo.url): + os.remove(logo.url) + local_files_deleted += 1 + logger.info(f"Deleted local logo file: {logo.url}") + except Exception as e: + logger.error(f"Failed to delete logo file {logo.url}: {str(e)}") + return Response( + {"error": f"Failed to delete logo file {logo.url}: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + if logo.channels.exists(): channel_count = logo.channels.count() total_channels_affected += channel_count @@ -1071,6 +1087,8 @@ class BulkDeleteLogosAPIView(APIView): message = f"Successfully deleted {deleted_count} logos" if total_channels_affected > 0: message += f" and removed them from {total_channels_affected} channels" + if local_files_deleted > 0: + message += f" and deleted {local_files_deleted} local files" return Response( {"message": message}, @@ -1157,6 +1175,20 @@ class LogoViewSet(viewsets.ModelViewSet): def destroy(self, request, *args, **kwargs): """Delete a logo and remove it from any channels using it""" logo = self.get_object() + delete_file = request.query_params.get('delete_file', 'false').lower() == 'true' + + # Check if it's a local file that should be deleted + if delete_file and logo.url and logo.url.startswith('/data/logos'): + try: + if os.path.exists(logo.url): + os.remove(logo.url) + logger.info(f"Deleted local logo file: {logo.url}") + except Exception as e: + logger.error(f"Failed to delete logo file {logo.url}: {str(e)}") + return Response( + {"error": f"Failed to delete logo file: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) # Instead of preventing deletion, remove the logo from channels if logo.channels.exists(): diff --git a/frontend/src/api.js b/frontend/src/api.js index bcffc920..b285e2ea 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1337,9 +1337,15 @@ export default class API { } } - static async deleteLogo(id) { + static async deleteLogo(id, deleteFile = false) { try { - await request(`${host}/api/channels/logos/${id}/`, { + const params = new URLSearchParams(); + if (deleteFile) { + params.append('delete_file', 'true'); + } + + const url = `${host}/api/channels/logos/${id}/?${params.toString()}`; + await request(url, { method: 'DELETE', }); @@ -1351,11 +1357,16 @@ export default class API { } } - static async deleteLogos(ids) { + static async deleteLogos(ids, deleteFiles = false) { try { + const body = { logo_ids: ids }; + if (deleteFiles) { + body.delete_files = true; + } + await request(`${host}/api/channels/logos/bulk-delete/`, { method: 'DELETE', - body: { logo_ids: ids }, + body: body, }); // Remove multiple logos from store diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 8f96708d..1cfbe84d 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -29,12 +29,15 @@ const ConfirmationDialog = ({ onSuppressChange, size = 'md', zIndex = 1000, + showDeleteFileOption = false, + deleteFileLabel = "Also delete files from disk", }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const [suppressChecked, setSuppressChecked] = useState( isWarningSuppressed(actionKey) ); + const [deleteFiles, setDeleteFiles] = useState(false); const handleToggleSuppress = (e) => { setSuppressChecked(e.currentTarget.checked); @@ -47,13 +50,23 @@ const ConfirmationDialog = ({ if (suppressChecked) { suppressWarning(actionKey); } - onConfirm(); + if (showDeleteFileOption) { + onConfirm(deleteFiles); + } else { + onConfirm(); + } + setDeleteFiles(false); // Reset for next time + }; + + const handleClose = () => { + setDeleteFiles(false); // Reset for next time + onClose(); }; return ( )} + {showDeleteFileOption && ( + setDeleteFiles(event.currentTarget.checked)} + label={deleteFileLabel} + mb="md" + /> + )} + -