diff --git a/Channel-Maparr/UK_channels.json b/Channel-Maparr/UK_channels.json index 5e4ced5..1ff6022 100644 --- a/Channel-Maparr/UK_channels.json +++ b/Channel-Maparr/UK_channels.json @@ -8575,166 +8575,331 @@ }, { "channel_name": "U&alibi", + "aliases": [ + "Alibi", + "UK Alibi", + "UKTV Alibi" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&alibi +1", + "aliases": [ + "Alibi +1", + "UK Alibi +1", + "UKTV Alibi +1" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&alibi HD", + "aliases": [ + "Alibi HD", + "UK Alibi HD", + "UKTV Alibi HD" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Dave", + "aliases": [ + "Dave", + "UK Dave", + "UKTV Dave" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Dave HD", + "aliases": [ + "Dave HD", + "UK Dave HD", + "UKTV Dave HD" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&DaveJaVu", + "aliases": [ + "DaveJaVu", + "UK DaveJaVu", + "UKTV DaveJaVu" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Drama", + "aliases": [ + "Drama", + "UK Drama", + "UKTV Drama" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Drama HD", + "aliases": [ + "Drama HD", + "UK Drama HD", + "UKTV Drama HD" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Drama+1", + "aliases": [ + "Drama +1", + "UK Drama +1", + "UKTV Drama +1" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Eden", + "aliases": [ + "Eden", + "UK Eden", + "UKTV Eden" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Eden HD", "category": "United Kingdom", - "type": "Regional" + "type": "Regional", + "aliases": [ + "Eden HD", + "UK Eden HD", + "UKTV Eden HD" + ] }, { "channel_name": "U&Eden HD", "category": "United Kingdom", - "type": "Regional" + "type": "Regional", + "aliases": [ + "Eden HD", + "UK Eden HD", + "UKTV Eden HD" + ] }, { "channel_name": "U&Eden+1", + "aliases": [ + "Eden +1", + "UK Eden +1", + "UKTV Eden +1" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&GOLD", + "aliases": [ + "Gold", + "UK Gold", + "UKTV Gold" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&GOLD +1", + "aliases": [ + "Gold +1", + "UK Gold +1", + "UKTV Gold +1" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&GOLD HD", + "aliases": [ + "Gold HD", + "UK Gold HD", + "UKTV Gold HD" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Laughs", + "aliases": [ + "Laughs", + "UK Laughs", + "UKTV Laughs" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Laughs (TiVo)", + "aliases": [ + "Laughs (TiVo)", + "UK Laughs (TiVo)", + "UKTV Laughs (TiVo)" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Real Heroes", + "aliases": [ + "Real Heroes", + "UK Real Heroes", + "UKTV Real Heroes" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Real Heroes (TiVo)", + "aliases": [ + "Real Heroes (TiVo)", + "UK Real Heroes (TiVo)", + "UKTV Real Heroes (TiVo)" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&The Past", + "aliases": [ + "The Past", + "UK The Past", + "UKTV The Past" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&The Past (TiVo)", + "aliases": [ + "The Past (TiVo)", + "UK The Past (TiVo)", + "UKTV The Past (TiVo)" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Transport", + "aliases": [ + "Transport", + "UK Transport", + "UKTV Transport" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Transport (TiVo)", + "aliases": [ + "Transport (TiVo)", + "UK Transport (TiVo)", + "UKTV Transport (TiVo)" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&W", + "aliases": [ + "W", + "UK W", + "UKTV W" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&W HD", + "aliases": [ + "W HD", + "UK W HD", + "UKTV W HD" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&W+1", + "aliases": [ + "W +1", + "UK W +1", + "UKTV W +1" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Yesterday", + "aliases": [ + "Yesterday", + "UK Yesterday", + "UKTV Yesterday" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Yesterday HD", + "aliases": [ + "Yesterday HD", + "UK Yesterday HD", + "UKTV Yesterday HD" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Yesterday HD", + "aliases": [ + "Yesterday HD", + "UK Yesterday HD", + "UKTV Yesterday HD" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Yesterday+1", + "aliases": [ + "Yesterday +1", + "UK Yesterday +1", + "UKTV Yesterday +1" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Yesterday+1", + "aliases": [ + "Yesterday +1", + "UK Yesterday +1", + "UKTV Yesterday +1" + ], "category": "United Kingdom", "type": "Regional" }, { "channel_name": "U&Yesterday+1 (Freeview)", + "aliases": [ + "Yesterday +1 (Freeview)", + "UK Yesterday +1 (Freeview)", + "UKTV Yesterday +1 (Freeview)" + ], "category": "United Kingdom", "type": "Regional" }, diff --git a/Channel-Maparr/fuzzy_matcher.py b/Channel-Maparr/fuzzy_matcher.py index 9386f45..8779768 100644 --- a/Channel-Maparr/fuzzy_matcher.py +++ b/Channel-Maparr/fuzzy_matcher.py @@ -99,6 +99,7 @@ def __init__(self, plugin_dir=None, match_threshold=85, logger=None): self.broadcast_channels = [] # Channels with callsigns self.premium_channels = [] # Channel names only (for fuzzy matching) self.premium_channels_full = [] # Full channel objects with category + self.premium_alias_map = {} # Alias -> canonical channel_name mapping self.channel_lookup = {} # Callsign -> channel data mapping self.country_codes = None # Track which country databases are currently loaded @@ -152,7 +153,18 @@ def _load_channel_databases(self): channel_name = channel.get('channel_name', '').strip() if channel_name: self.premium_channels.append(channel_name) + self.premium_alias_map[channel_name] = channel_name self.premium_channels_full.append(channel) + + # Load aliases for fuzzy matching + aliases = channel.get('aliases', []) + if isinstance(aliases, list): + for alias in aliases: + alias = str(alias).strip() + if alias: + self.premium_channels.append(alias) + self.premium_alias_map[alias] = channel_name + file_premium += 1 total_broadcast += file_broadcast @@ -182,6 +194,7 @@ def reload_databases(self, country_codes=None): self.premium_channels = [] self.premium_channels_full = [] self.channel_lookup = {} + self.premium_alias_map = {} # Update country_codes tracking self.country_codes = country_codes @@ -242,7 +255,18 @@ def reload_databases(self, country_codes=None): channel_name = channel.get('channel_name', '').strip() if channel_name: self.premium_channels.append(channel_name) + self.premium_alias_map[channel_name] = channel_name self.premium_channels_full.append(channel) + + # Load aliases for fuzzy matching + aliases = channel.get('aliases', []) + if isinstance(aliases, list): + for alias in aliases: + alias = str(alias).strip() + if alias: + self.premium_channels.append(alias) + self.premium_alias_map[alias] = channel_name + file_premium += 1 total_broadcast += file_broadcast @@ -673,6 +697,133 @@ def fuzzy_match(self, query_name, candidate_names, user_ignored_tags=None, remov return None, 0, None + + def fuzzy_match_debug(self, query_name, candidate_names, user_ignored_tags=None, remove_cinemax=False, top_n=5): + """ + Fuzzy match with detailed debug output. + Returns: (matched_name, score, match_type, debug_dict) + """ + debug = { + "query_name": query_name, + "normalized_query": None, + "normalized_query_nospace": None, + "stage1_best": None, + "stage2_best": None, + "stage3_top": [], + "threshold": self.match_threshold, + } + + if not candidate_names: + return None, 0, None, debug + + if user_ignored_tags is None: + user_ignored_tags = [] + + normalized_query = self.normalize_name(query_name, user_ignored_tags) + debug["normalized_query"] = normalized_query + + if not normalized_query: + return None, 0, None, debug + + normalized_query_lower = normalized_query.lower() + debug["normalized_query_nospace"] = re.sub(r'[\s&\-]+', '', normalized_query_lower) + + # Stage 1: Exact / near-exact + best_match = None + best_ratio = 0.0 + for candidate in candidate_names: + cand_norm = self.normalize_name(candidate, user_ignored_tags, remove_cinemax=remove_cinemax) + if not cand_norm or len(cand_norm) < 2: + continue + + cand_lower = cand_norm.lower() + cand_nospace = re.sub(r'[\s&\-]+', '', cand_lower) + + if debug["normalized_query_nospace"] == cand_nospace: + debug["stage1_best"] = { + "candidate": candidate, + "candidate_normalized": cand_norm, + "score": 100, + "type": "exact_nospace" + } + return candidate, 100, "exact", debug + + ratio = self.calculate_similarity(normalized_query_lower, cand_lower) + if ratio >= 0.97 and ratio > best_ratio: + best_match = candidate + best_ratio = ratio + debug["stage1_best"] = { + "candidate": candidate, + "candidate_normalized": cand_norm, + "score": int(ratio * 100), + "type": "near_exact" + } + + if best_match: + return best_match, int(best_ratio * 100), "exact", debug + + # Stage 2: Substring + best_match = None + best_ratio = 0.0 + for candidate in candidate_names: + cand_norm = self.normalize_name(candidate, user_ignored_tags, remove_cinemax=remove_cinemax) + if not cand_norm or len(cand_norm) < 2: + continue + + cand_lower = cand_norm.lower() + if normalized_query_lower in cand_lower or cand_lower in normalized_query_lower: + ratio = self.calculate_similarity(normalized_query_lower, cand_lower) + if ratio > best_ratio: + best_match = candidate + best_ratio = ratio + debug["stage2_best"] = { + "candidate": candidate, + "candidate_normalized": cand_norm, + "score": int(ratio * 100), + "type": "substring" + } + + if best_match and int(best_ratio * 100) >= self.match_threshold: + return best_match, int(best_ratio * 100), "substring", debug + + # Stage 3: token-sort fuzzy (capture top N) + processed_query = self.process_string_for_matching(normalized_query) + scored = [] + best_score = -1.0 + best_match = None + + for candidate in candidate_names: + cand_norm = self.normalize_name(candidate, user_ignored_tags, remove_cinemax=remove_cinemax) + if not cand_norm or len(cand_norm) < 2: + continue + + processed_candidate = self.process_string_for_matching(cand_norm) + score = self.calculate_similarity(processed_query, processed_candidate) + + scored.append((score, candidate, cand_norm, processed_candidate)) + + if score > best_score: + best_score = score + best_match = candidate + + scored.sort(key=lambda x: x[0], reverse=True) + debug["stage3_top"] = [ + { + "candidate": c, + "candidate_normalized": cn, + "processed_query": processed_query, + "processed_candidate": pc, + "score": int(s * 100) + } + for (s, c, cn, pc) in scored[:max(1, int(top_n))] + ] + + percentage_score = int(best_score * 100) if best_score >= 0 else 0 + if best_match and percentage_score >= self.match_threshold: + return best_match, percentage_score, f"fuzzy ({percentage_score})", debug + + return None, 0, None, debug + def match_broadcast_channel(self, channel_name): """ Match broadcast (OTA) channel by callsign. diff --git a/Channel-Maparr/plugin.py b/Channel-Maparr/plugin.py index 50c7689..45fef31 100644 --- a/Channel-Maparr/plugin.py +++ b/Channel-Maparr/plugin.py @@ -157,6 +157,14 @@ def fields(self): "placeholder": str(self.DEFAULT_FUZZY_MATCH_THRESHOLD), "help_text": f"Minimum similarity score (0-100) for fuzzy matching. Higher values require closer matches. Default: {self.DEFAULT_FUZZY_MATCH_THRESHOLD}", }, + { + "id": "debug_top_n", + "label": "🔍 Debug Export - Top N Candidates", + "type": "number", + "default": 5, + "placeholder": "5", + "help_text": "For the Debug Match Export action, include the top N fuzzy candidates (stage 3 token-sort) per channel.", + }, { "id": "selected_groups", "label": "📂 Channel Groups to Process (comma-separated)", @@ -219,6 +227,11 @@ def fields(self): "label": "Preview Changes (Dry Run)", "description": "Export a CSV showing which channels would be renamed and their new names", }, + { + "id": "debug_export", + "label": "Debug Match Export", + "description": "Run matching in debug mode and export a CSV with extra details", + }, { "id": "rename_channels", "label": "Rename Channels", @@ -407,6 +420,34 @@ def _generate_csv_settings_header(self, settings): header_lines.append("#") return '\n'.join(header_lines) + '\n' + def _prepare_csv_export_response(self, csv_path, csv_filename, summary_message): + """Prepare CSV export response with file information.""" + try: + # Verify file exists + if not os.path.exists(csv_path): + return { + "status": "error", + "message": f"CSV file could not be created: {csv_filename}" + } + + # Get file size for info + file_size_bytes = os.path.getsize(csv_path) + file_size_kb = round(file_size_bytes / 1024, 2) + + # Return success response with file info + return { + "status": "success", + "message": ( + f"{summary_message}\n\n" + f"CSV file saved: {csv_filename} ({file_size_kb} KB)" + ) + } + except Exception as e: + return { + "status": "error", + "message": f"Error creating CSV export: {str(e)}" + } + def _get_api_token(self, settings, logger): """Get an API access token using username and password with caching.""" @@ -675,6 +716,36 @@ def _format_ota_name(self, station_data, format_string, callsign): return result + def _expand_ignored_tags(self, ignored_tags_list): + """ + Expand ignored tags to include bracket and parentheses versions plus bare tags. + + For a tag like "[HD]", returns: ["[HD]", "(HD)", "HD"] + For a tag like "(4K)", returns: ["(4K)", "[4K]", "4K"] + For a bare tag like "Unknown", returns: ["Unknown"] + + Args: + ignored_tags_list: List of tags from settings (parsed from comma-separated string) + + Returns: + List of expanded tags for comprehensive matching + """ + expanded_ignored_tags = [] + for tag in ignored_tags_list: + expanded_ignored_tags.append(tag) + + if tag.startswith('[') and tag.endswith(']'): + inner = tag[1:-1].strip() + expanded_ignored_tags.append(f"({inner})") + expanded_ignored_tags.append(inner) + + elif tag.startswith('(') and tag.endswith(')'): + inner = tag[1:-1].strip() + expanded_ignored_tags.append(f"[{inner}]") + expanded_ignored_tags.append(inner) + + return expanded_ignored_tags + def run(self, action, params, context): """Main plugin entry point""" LOGGER.info(f"{self.name} run called with action: {action}") @@ -692,6 +763,7 @@ def run(self, action, params, context): "category_groups_dry_run": self.category_groups_dry_run_action, "organize_by_category": self.organize_by_category_action, "clear_csv_exports": self.clear_csv_exports_action, + "debug_export": self.debug_export_action, } if action not in action_map: @@ -777,20 +849,8 @@ def load_and_process_channels_action(self, settings, logger): ignored_tags_str = settings.get("ignored_tags", self.DEFAULT_IGNORED_TAGS) ignored_tags_list = [tag.strip() for tag in ignored_tags_str.split(',') if tag.strip()] - # Also create versions with parentheses for tags that use brackets - expanded_ignored_tags = [] - for tag in ignored_tags_list: - expanded_ignored_tags.append(tag) - # If tag is in brackets, also add parentheses version - if tag.startswith('[') and tag.endswith(']'): - inner = tag[1:-1] - expanded_ignored_tags.append(f"({inner})") - # If tag is in parentheses, also add brackets version - elif tag.startswith('(') and tag.endswith(')'): - inner = tag[1:-1] - expanded_ignored_tags.append(f"[{inner}]") - - ignored_tags_list = expanded_ignored_tags + # Expand ignored tags to include bracket/parentheses/bare versions + ignored_tags_list = self._expand_ignored_tags(ignored_tags_list) # Track matching statistics debug_stats = { @@ -855,6 +915,10 @@ def load_and_process_channels_action(self, settings, logger): self.matcher.premium_channels, ignored_tags_list ) + + # Map alias back to canonical name + if matched_premium: + matched_premium = self.matcher.premium_alias_map.get(matched_premium, matched_premium) if matched_premium: new_name = self.matcher.build_final_channel_name(matched_premium, regional, extra_tags, quality_tags) @@ -946,6 +1010,170 @@ def load_and_process_channels_action(self, settings, logger): logger.error(f"{PLUGIN_LOG_PREFIX} Error loading and processing channels: {e}") return {"status": "error", "message": f"Error loading and processing channels: {e}"} + + def debug_export_action(self, settings, logger): + """ + Run matching in debug mode and export a CSV with: + - raw name + - extracted tags (regional/extra/quality) + - normalized query used for matching + - callsign detection result + - premium match candidate, score and match stage + - top N fuzzy candidates with scores (token-sort stage) + """ + try: + import json + + # Load channel data from selected country databases + channels_loaded = self._load_channel_data(settings, logger) + if not channels_loaded: + return {"status": "error", "message": "Channel databases could not be loaded. Please check your channel_databases setting and ensure the files exist."} + + # Get API token + token, error = self._get_api_token(settings, logger) + if error: + return {"status": "error", "message": error} + + # Build group maps + all_groups = self._get_api_data("/api/channels/groups/", token, settings, logger) + group_id_to_name = {g['id']: g['name'] for g in all_groups if 'name' in g and 'id' in g} + + # Filter by selected groups + selected_groups_str = settings.get("selected_groups", "").strip() + if selected_groups_str: + group_name_to_id = {g['name']: g['id'] for g in all_groups if 'name' in g and 'id' in g} + input_names = {name.strip() for name in selected_groups_str.split(',') if name.strip()} + valid_names = {n for n in input_names if n in group_name_to_id} + target_group_ids = {group_name_to_id[name] for name in valid_names} + else: + target_group_ids = set(group_id_to_name.keys()) + + all_channels = self._get_api_data("/api/channels/channels/", token, settings, logger) + channels_to_process = [ch for ch in all_channels if ch.get('channel_group_id') in target_group_ids] + + for ch in channels_to_process: + gid = ch.get('channel_group_id') + ch['_group_name'] = group_id_to_name.get(gid, 'No Group') + + ota_format = settings.get("ota_format", self.DEFAULT_OTA_FORMAT) + + # Parse ignored tags + ignored_tags_str = settings.get("ignored_tags", self.DEFAULT_IGNORED_TAGS) + ignored_tags_list = [tag.strip() for tag in ignored_tags_str.split(',') if tag.strip()] + + # Expand ignored tags to include bracket/parentheses/bare versions + ignored_tags_list = self._expand_ignored_tags(ignored_tags_list) + + top_n = int(settings.get("debug_top_n", 5) or 5) + + # Export dir + export_dir = self.EXPORT_DIR + os.makedirs(export_dir, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_filename = f"channel_mapparr_debug_{timestamp}.csv" + csv_path = os.path.join(export_dir, csv_filename) + + # CSV columns + fieldnames = [ + "Channel ID", "Channel Number", "Group", "Current Name", + "OTA Callsign", "OTA Station Found", "OTA New Name", + "Regional", "Extra Tags", "Quality Tags", + "Normalized Query", "Normalized Query NoSpace", + "Matched Candidate", "Match Type", "Score", + "Stage1 Candidate", "Stage1 Score", + "Stage2 Candidate", "Stage2 Score", + "Stage3 Top Candidates (score|name|normalized)" + ] + + with open(csv_path, 'w', newline='', encoding='utf-8') as csvfile: + csvfile.write(self._generate_csv_settings_header(settings)) + + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + + for ch in channels_to_process: + current_name = (ch.get("name") or "").strip() + channel_id = ch.get("id", "") + channel_number = ch.get("channel_number", "") + group_name = ch.get("_group_name", "No Group") + + ota_callsign = "" + ota_station_found = False + ota_new_name = "" + + # OTA attempt (callsign-based - mostly US style) + callsign, station = self.matcher.match_broadcast_channel(current_name) if self.matcher.broadcast_channels else (None, None) + if callsign: + ota_callsign = callsign + if station: + ota_station_found = True + ota_new_name = self._format_ota_name(station, ota_format, callsign) or "" + + # Premium attempt (only if no callsign was found at all) + regional, extra_tags, quality_tags = self.matcher.extract_tags(current_name, ignored_tags_list) + + matched = None + score = 0 + match_type = None + dbg = {} + + if self.matcher.premium_channels and not callsign: + # Use debug matcher (added in fuzzy_matcher.py) + if hasattr(self.matcher, "fuzzy_match_debug"): + matched, score, match_type, dbg = self.matcher.fuzzy_match_debug( + current_name, + self.matcher.premium_channels, + ignored_tags_list, + top_n=top_n + ) + else: + matched, score, match_type = self.matcher.fuzzy_match( + current_name, + self.matcher.premium_channels, + ignored_tags_list + ) + dbg = {"normalized_query": self.matcher.normalize_name(current_name, ignored_tags_list)} + + stage1 = (dbg.get("stage1_best") or {}) + stage2 = (dbg.get("stage2_best") or {}) + stage3 = dbg.get("stage3_top") or [] + + stage3_str = "; ".join([ + f"{item.get('score')}|{item.get('candidate')}|{item.get('candidate_normalized')}" + for item in stage3 + ]) + + writer.writerow({ + "Channel ID": channel_id, + "Channel Number": channel_number, + "Group": group_name, + "Current Name": current_name, + "OTA Callsign": ota_callsign, + "OTA Station Found": "Y" if ota_station_found else "", + "OTA New Name": ota_new_name, + "Regional": regional or "", + "Extra Tags": " ".join(extra_tags) if extra_tags else "", + "Quality Tags": " ".join(quality_tags) if quality_tags else "", + "Normalized Query": dbg.get("normalized_query") or "", + "Normalized Query NoSpace": dbg.get("normalized_query_nospace") or "", + "Matched Candidate": matched or "", + "Match Type": match_type or "", + "Score": score or 0, + "Stage1 Candidate": stage1.get("candidate") or "", + "Stage1 Score": stage1.get("score") or "", + "Stage2 Candidate": stage2.get("candidate") or "", + "Stage2 Score": stage2.get("score") or "", + "Stage3 Top Candidates (score|name|normalized)": stage3_str + }) + + logger.info(f"{PLUGIN_LOG_PREFIX} Debug CSV exported to {csv_path}") + summary = f"✓ Debug export created: {csv_filename}\n\nIncludes normalization + top {top_n} fuzzy candidates per channel." + return self._prepare_csv_export_response(csv_path, csv_filename, summary) + + except Exception as e: + logger.error(f"{PLUGIN_LOG_PREFIX} Error creating debug export: {e}") + return {"status": "error", "message": f"Debug export failed: {e}"} + def preview_changes_action(self, settings, logger): """Export a CSV showing the preview of channel renaming changes.""" try: @@ -998,10 +1226,8 @@ def preview_changes_action(self, settings, logger): renamed_count = sum(1 for c in all_changes if c.get('status') == 'Renamed') skipped_count = sum(1 for c in all_changes if c.get('status') == 'Skipped') - return { - "status": "success", - "message": f"✓ Preview exported to: {csv_filename}\n\n{renamed_count} channels will be renamed, {skipped_count} will be skipped." - } + summary = f"✓ Preview exported to: {csv_filename}\n\n{renamed_count} channels will be renamed, {skipped_count} will be skipped." + return self._prepare_csv_export_response(csv_path, csv_filename, summary) except Exception as e: logger.error(f"{PLUGIN_LOG_PREFIX} Error exporting preview: {e}") @@ -1268,17 +1494,8 @@ def category_groups_dry_run_action(self, settings, logger): ignored_tags_str = settings.get("ignored_tags", self.DEFAULT_IGNORED_TAGS) ignored_tags_list = [tag.strip() for tag in ignored_tags_str.split(',') if tag.strip()] - # Expand ignored tags - expanded_ignored_tags = [] - for tag in ignored_tags_list: - expanded_ignored_tags.append(tag) - if tag.startswith('[') and tag.endswith(']'): - inner = tag[1:-1] - expanded_ignored_tags.append(f"({inner})") - elif tag.startswith('(') and tag.endswith(')'): - inner = tag[1:-1] - expanded_ignored_tags.append(f"[{inner}]") - ignored_tags_list = expanded_ignored_tags + # Expand ignored tags to include bracket/parentheses/bare versions + ignored_tags_list = self._expand_ignored_tags(ignored_tags_list) # Process channels and determine moves moves = [] @@ -1315,6 +1532,10 @@ def category_groups_dry_run_action(self, settings, logger): self.matcher.premium_channels, ignored_tags_list ) + + # Map alias back to canonical name + if matched_premium: + matched_premium = self.matcher.premium_alias_map.get(matched_premium, matched_premium) if matched_premium and matched_premium.lower() in category_map_premium: matched_name, category = category_map_premium[matched_premium.lower()] @@ -1382,10 +1603,8 @@ def category_groups_dry_run_action(self, settings, logger): broadcast_count = sum(1 for m in moves if 'Broadcast' in m['match_type']) premium_count = sum(1 for m in moves if 'Premium' in m['match_type']) - return { - "status": "success", - "message": f"✓ Preview exported to: {csv_filename}\n\n{len(moves)} channels will be moved ({broadcast_count} broadcast, {premium_count} premium).\n{new_groups_needed} new groups will be created." - } + summary = f"✓ Preview exported to: {csv_filename}\n\n{len(moves)} channels will be moved ({broadcast_count} broadcast, {premium_count} premium).\n{new_groups_needed} new groups will be created." + return self._prepare_csv_export_response(csv_path, csv_filename, summary) except Exception as e: logger.error(f"{PLUGIN_LOG_PREFIX} Error generating category groups preview: {e}") @@ -1455,17 +1674,8 @@ def organize_by_category_action(self, settings, logger): ignored_tags_str = settings.get("ignored_tags", self.DEFAULT_IGNORED_TAGS) ignored_tags_list = [tag.strip() for tag in ignored_tags_str.split(',') if tag.strip()] - # Expand ignored tags - expanded_ignored_tags = [] - for tag in ignored_tags_list: - expanded_ignored_tags.append(tag) - if tag.startswith('[') and tag.endswith(']'): - inner = tag[1:-1] - expanded_ignored_tags.append(f"({inner})") - elif tag.startswith('(') and tag.endswith(')'): - inner = tag[1:-1] - expanded_ignored_tags.append(f"[{inner}]") - ignored_tags_list = expanded_ignored_tags + # Expand ignored tags to include bracket/parentheses/bare versions + ignored_tags_list = self._expand_ignored_tags(ignored_tags_list) # Process channels and determine moves moves = [] @@ -1498,6 +1708,10 @@ def organize_by_category_action(self, settings, logger): self.matcher.premium_channels, ignored_tags_list ) + + # Map alias back to canonical name + if matched_premium: + matched_premium = self.matcher.premium_alias_map.get(matched_premium, matched_premium) if matched_premium and matched_premium.lower() in category_map_premium: matched_name, category = category_map_premium[matched_premium.lower()] diff --git a/README.md b/README.md index 88b58c6..4f2c6d2 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,11 @@ To update Channel Mapparr from a previous version: | **Dispatcharr Admin Password**| `password`| - | Password for API authentication. | | **Channel Databases** | `string` | `US` | Comma-separated country codes (e.g., `US, UK, CA`). | | **Fuzzy Match Threshold** | `number` | `85` | Minimum similarity score (0-100) for matching. Higher values require closer matches. | +| **Debug Export - Top N Candidates** | `number` | `5` | For the Debug Match Export action, include the top N fuzzy match candidates (stage 3 token-sort) per channel. | | **Channel Groups to Process** | `string` | - | Comma-separated group names for renaming operations. Empty = all groups. | | **Channel Groups for Category Organization** | `string` | - | Comma-separated group names for category sorting. Empty = all groups. | | **OTA Channel Name Format** | `string` | `{NETWORK} - {STATE} {CITY} ({CALLSIGN})` | Format template for OTA channels. Available tags: `{NETWORK}`, `{STATE}`, `{CITY}`, `{CALLSIGN}`. | -| **Ignored Tags** | `string` | `[4K], [FHD], ...` | Comma-separated list of tags to remove before matching (handles `[]` and `()`). | +| **Ignored Tags** | `string` | `[4K], [FHD], ...` | Comma-separated list of tags to remove before matching. Tags are auto-expanded to match bracketed, parenthesized, and bare versions (e.g., `[HD]` matches `[HD]`, `(HD)`, and `HD`). | | **Suffix for Unknown Channels**| `string` | ` [Unk]` | Suffix to append to unmatched channels. | | **Default Logo** | `string` | - | Logo display name from Dispatcharr's logo manager to apply to channels without logos. | @@ -101,6 +102,7 @@ To update Channel Mapparr from a previous version: | :--- | :--- | | **Load/Process Channels** | Load channels from API and match against selected country databases. | | **Preview Changes (Dry Run)** | Export a CSV showing proposed renames and match sources. | +| **Debug Match Export** | Export a detailed CSV with matching stages, normalized queries, and top fuzzy match candidates. | | **Rename Channels** | Apply standardized names to matched channels. | | **Add Suffix to Unknown Channels**| Tag unmatched channels with the configured suffix. | | **Apply Default Logos** | Bulk assign a logo to channels without artwork. | @@ -114,7 +116,7 @@ To update Channel Mapparr from a previous version: * **Exports**: `/data/exports/` (CSV previews and reports) ## CSV Export Format -The plugin generates CSVs for both renaming and categorization previews. +The plugin generates CSVs for renaming, categorization, and debugging. **Renaming Preview:** * **dbase**: Indicates the source of the match (e.g., `Broadcast (OTA)`, `Premium/Cable`). @@ -124,6 +126,14 @@ The plugin generates CSVs for both renaming and categorization previews. * **New Group**: The target group based on the channel's category. * **Group Exists**: Indicates if the plugin needs to create a new group via the API. +**Debug Match Export:** +* **OTA Callsign/Station/New Name**: Results from broadcast (OTA) callsign matching. +* **Regional/Extra Tags/Quality Tags**: Extracted tag components from the channel name. +* **Normalized Query**: The cleaned channel name used for fuzzy matching. +* **Matched Candidate**: The best match found in the channel database. +* **Stage1/Stage2/Stage3**: Intermediate fuzzy matching stages with candidates and scores. +* **Stage3 Top Candidates**: The top N fuzzy match candidates (configurable via Debug Export - Top N Candidates setting). + ## Performance Notes * **Token Caching**: API tokens are cached for 30 minutes to reduce authentication overhead. * **WebSocket Updates**: The plugin triggers a WebSocket frontend refresh upon completion, ensuring the UI updates immediately without a full page reload.