Merge pull request #4 from PiratesIRC/claude/fix-visibility-add-emojis-011CUrgQDHW2FZ25e9XdPjNi

Claude/fix visibility add emojis 011 c urg qdhw2 fz25e9 xd pj ni
This commit is contained in:
Pirates IRC
2025-11-06 07:35:43 -06:00
committed by GitHub
2 changed files with 125 additions and 103 deletions

View File

@@ -158,7 +158,7 @@ class FuzzyMatcher:
callsign = re.sub(r'-(?:TV|CD|LP|DT|LD)$', '', callsign)
return callsign
def normalize_name(self, name, user_ignored_tags=None, remove_quality_tags=True):
def normalize_name(self, name, user_ignored_tags=None, remove_quality_tags=True, remove_cinemax=False):
"""
Normalize channel or stream name for matching by removing tags, prefixes, and other noise.
@@ -166,6 +166,7 @@ class FuzzyMatcher:
name: Name to normalize
user_ignored_tags: Additional user-configured tags to ignore (list of strings)
remove_quality_tags: If True, remove hardcoded quality patterns (for matching only, not display)
remove_cinemax: If True, remove "Cinemax" prefix (useful when channel name contains "max")
Returns:
Normalized name
@@ -176,6 +177,10 @@ class FuzzyMatcher:
# Remove leading parenthetical prefixes like (SP2), (D1), etc.
name = re.sub(r'^\([^\)]+\)\s*', '', name)
# Remove "Cinemax" prefix if requested (for channels containing "max")
if remove_cinemax:
name = re.sub(r'\bCinemax\s+', '', name, flags=re.IGNORECASE)
# Apply hardcoded ignore patterns only if remove_quality_tags is True
if remove_quality_tags:
for pattern in HARDCODED_IGNORE_PATTERNS:
@@ -315,7 +320,7 @@ class FuzzyMatcher:
tokens = sorted([token for token in cleaned_s.split() if token])
return " ".join(tokens)
def find_best_match(self, query_name, candidate_names, user_ignored_tags=None):
def find_best_match(self, query_name, candidate_names, user_ignored_tags=None, remove_cinemax=False):
"""
Find the best fuzzy match for a name among a list of candidate names.
@@ -323,6 +328,7 @@ class FuzzyMatcher:
query_name: Name to match
candidate_names: List of candidate names to match against
user_ignored_tags: User-configured tags to ignore
remove_cinemax: If True, remove "Cinemax" from candidate names
Returns:
Tuple of (matched_name, score) or (None, 0) if no match found
@@ -333,7 +339,7 @@ class FuzzyMatcher:
if user_ignored_tags is None:
user_ignored_tags = []
# Normalize the query
# Normalize the query (channel name - don't remove Cinemax from it)
normalized_query = self.normalize_name(query_name, user_ignored_tags)
if not normalized_query:
@@ -346,7 +352,9 @@ class FuzzyMatcher:
best_match = None
for candidate in candidate_names:
processed_candidate = self.process_string_for_matching(candidate)
# Normalize candidate (stream name) with Cinemax removal if requested
candidate_normalized = self.normalize_name(candidate, user_ignored_tags, remove_cinemax=remove_cinemax)
processed_candidate = self.process_string_for_matching(candidate_normalized)
score = self.calculate_similarity(processed_query, processed_candidate)
if score > best_score:
@@ -361,15 +369,16 @@ class FuzzyMatcher:
return None, 0
def fuzzy_match(self, query_name, candidate_names, user_ignored_tags=None):
def fuzzy_match(self, query_name, candidate_names, user_ignored_tags=None, remove_cinemax=False):
"""
Generic fuzzy matching function that can match any name against a list of candidates.
This is the main entry point for fuzzy matching.
Args:
query_name: Name to match
candidate_names: List of candidate names to match against
query_name: Name to match (channel name)
candidate_names: List of candidate names to match against (stream names)
user_ignored_tags: User-configured tags to ignore
remove_cinemax: If True, remove "Cinemax" from candidate names (for channels with "max")
Returns:
Tuple of (matched_name, score, match_type) or (None, 0, None) if no match found
@@ -380,7 +389,7 @@ class FuzzyMatcher:
if user_ignored_tags is None:
user_ignored_tags = []
# Normalize for matching
# Normalize query (channel name - don't remove Cinemax from it)
normalized_query = self.normalize_name(query_name, user_ignored_tags)
if not normalized_query:
@@ -395,7 +404,8 @@ class FuzzyMatcher:
normalized_query_nospace = re.sub(r'[\s&\-]+', '', normalized_query_lower)
for candidate in candidate_names:
candidate_normalized = self.normalize_name(candidate, user_ignored_tags)
# Normalize candidate (stream name) with Cinemax removal if requested
candidate_normalized = self.normalize_name(candidate, user_ignored_tags, remove_cinemax=remove_cinemax)
candidate_lower = candidate_normalized.lower()
candidate_nospace = re.sub(r'[\s&\-]+', '', candidate_lower)
@@ -415,7 +425,8 @@ class FuzzyMatcher:
# Stage 2: Substring matching
for candidate in candidate_names:
candidate_normalized = self.normalize_name(candidate, user_ignored_tags)
# Normalize candidate (stream name) with Cinemax removal if requested
candidate_normalized = self.normalize_name(candidate, user_ignored_tags, remove_cinemax=remove_cinemax)
candidate_lower = candidate_normalized.lower()
# Check if one is a substring of the other
@@ -431,7 +442,7 @@ class FuzzyMatcher:
return best_match, int(best_ratio * 100), match_type
# Stage 3: Fuzzy matching with token sorting
fuzzy_match, score = self.find_best_match(query_name, candidate_names, user_ignored_tags)
fuzzy_match, score = self.find_best_match(query_name, candidate_names, user_ignored_tags, remove_cinemax=remove_cinemax)
if fuzzy_match:
return fuzzy_match, score, f"fuzzy ({score})"

View File

@@ -32,27 +32,27 @@ class Plugin:
name = "Stream-Mapparr"
version = "0.5.0a"
description = "Automatically add matching streams to channels based on name similarity and quality precedence with enhanced fuzzy matching"
description = "🎯 Automatically add matching streams to channels based on name similarity and quality precedence with enhanced fuzzy matching"
# Settings rendered by UI
fields = [
{
"id": "overwrite_streams",
"label": "Overwrite Existing Streams",
"label": "🔄 Overwrite Existing Streams",
"type": "boolean",
"default": True,
"help_text": "If enabled, all existing streams will be removed and replaced with matched streams. If disabled, only new streams will be added (existing streams preserved).",
},
{
"id": "fuzzy_match_threshold",
"label": "Fuzzy Match Threshold",
"label": "🎯 Fuzzy Match Threshold",
"type": "number",
"default": 85,
"help_text": "Minimum similarity score (0-100) for fuzzy matching. Higher values require closer matches. Default: 85",
},
{
"id": "dispatcharr_url",
"label": "Dispatcharr URL",
"label": "🌐 Dispatcharr URL",
"type": "string",
"default": "",
"placeholder": "http://192.168.1.10:9191",
@@ -60,20 +60,20 @@ class Plugin:
},
{
"id": "dispatcharr_username",
"label": "Dispatcharr Admin Username",
"label": "👤 Dispatcharr Admin Username",
"type": "string",
"help_text": "Your admin username for the Dispatcharr UI. Required for API access.",
},
{
"id": "dispatcharr_password",
"label": "Dispatcharr Admin Password",
"label": "🔑 Dispatcharr Admin Password",
"type": "string",
"input_type": "password",
"help_text": "Your admin password for the Dispatcharr UI. Required for API access.",
},
{
"id": "profile_name",
"label": "Profile Name",
"label": "📋 Profile Name",
"type": "string",
"default": "",
"placeholder": "Sports",
@@ -81,7 +81,7 @@ class Plugin:
},
{
"id": "selected_groups",
"label": "Channel Groups (comma-separated)",
"label": "📁 Channel Groups (comma-separated)",
"type": "string",
"default": "",
"placeholder": "Sports, News, Entertainment",
@@ -89,7 +89,7 @@ class Plugin:
},
{
"id": "ignore_tags",
"label": "Ignore Tags (comma-separated)",
"label": "🏷️ Ignore Tags (comma-separated)",
"type": "string",
"default": "",
"placeholder": "4K, [4K], [Dead]",
@@ -97,7 +97,7 @@ class Plugin:
},
{
"id": "visible_channel_limit",
"label": "Visible Channel Limit",
"label": "👁️ Visible Channel Limit",
"type": "number",
"default": 1,
"help_text": "Number of channels that will be visible and have streams added. Channels are prioritized by quality tags, then by channel number.",
@@ -108,17 +108,17 @@ class Plugin:
actions = [
{
"id": "load_process_channels",
"label": "Load/Process Channels",
"label": "📥 Load/Process Channels",
"description": "Validate settings and load channels from the specified profile and groups",
},
{
"id": "preview_changes",
"label": "Preview Changes (Dry Run)",
"label": "👀 Preview Changes (Dry Run)",
"description": "Preview which streams will be added to channels without making changes",
},
{
"id": "add_streams_to_channels",
"label": "Add Stream(s) to Channels",
"label": "Add Stream(s) to Channels",
"description": "Add matching streams to channels and replace existing stream assignments",
"confirm": {
"required": True,
@@ -128,17 +128,17 @@ class Plugin:
},
{
"id": "manage_channel_visibility",
"label": "Manage Channel Visibility",
"description": "Disable all channels, then enable only channels with 0 or 1 stream (excluding channels attached to others)",
"label": "👁️ Manage Channel Visibility",
"description": "Disable all channels, then enable only channels with 1 or more streams (excluding channels attached to others)",
"confirm": {
"required": True,
"title": "Manage Channel Visibility?",
"message": "This will disable ALL channels in the profile, then enable only channels with 0 or 1 stream that are not attached to other channels. Continue?"
"message": "This will disable ALL channels in the profile, then enable only channels with 1 or more streams that are not attached to other channels. Continue?"
}
},
{
"id": "clear_csv_exports",
"label": "Clear CSV Exports",
"label": "🗑️ Clear CSV Exports",
"description": "Delete all CSV export files created by this plugin",
"confirm": {
"required": True,
@@ -348,14 +348,19 @@ class Plugin:
logger.warning(f"[Stream-Mapparr] Could not trigger frontend refresh: {e}")
return False
def _clean_channel_name(self, name, ignore_tags=None):
def _clean_channel_name(self, name, ignore_tags=None, remove_cinemax=False):
"""
Remove brackets and their contents from channel name for matching, and remove ignore tags.
Uses fuzzy matcher's normalization if available, otherwise falls back to basic cleaning.
Args:
name: Channel or stream name to clean
ignore_tags: List of tags to ignore
remove_cinemax: If True, remove "Cinemax" prefix (for streams when channel contains "max")
"""
if self.fuzzy_matcher:
# Use fuzzy matcher's normalization
return self.fuzzy_matcher.normalize_name(name, ignore_tags, remove_quality_tags=True)
return self.fuzzy_matcher.normalize_name(name, ignore_tags, remove_quality_tags=True, remove_cinemax=remove_cinemax)
# Fallback to basic cleaning
if ignore_tags is None:
@@ -491,6 +496,9 @@ class Plugin:
# Get channel info from JSON
channel_info = self._get_channel_info_from_json(channel_name, channels_data, logger)
# Check if channel name contains "max" (case insensitive) - used for Cinemax handling
channel_has_max = 'max' in channel_name.lower()
cleaned_channel_name = self._clean_channel_name(channel_name, ignore_tags)
if "24/7" in channel_name.lower():
@@ -518,7 +526,7 @@ class Plugin:
sorted_streams = self._sort_streams_by_quality(matching_streams)
logger.info(f"[Stream-Mapparr] Sorted {len(sorted_streams)} streams by quality (callsign matching)")
cleaned_stream_names = [self._clean_channel_name(s['name'], ignore_tags) for s in sorted_streams]
cleaned_stream_names = [self._clean_channel_name(s['name'], ignore_tags, remove_cinemax=channel_has_max) for s in sorted_streams]
match_reason = "Callsign match"
return sorted_streams, cleaned_channel_name, cleaned_stream_names, match_reason
@@ -534,19 +542,21 @@ class Plugin:
stream_names = [stream['name'] for stream in all_streams]
# Use fuzzy matcher to find best match
# Pass remove_cinemax flag if channel contains "max"
matched_stream_name, score, match_type = self.fuzzy_matcher.fuzzy_match(
channel_name,
stream_names,
ignore_tags
ignore_tags,
remove_cinemax=channel_has_max
)
if matched_stream_name:
# Find all streams that match this name (different qualities)
matching_streams = []
cleaned_matched = self._clean_channel_name(matched_stream_name, ignore_tags)
cleaned_matched = self._clean_channel_name(matched_stream_name, ignore_tags, remove_cinemax=channel_has_max)
for stream in all_streams:
cleaned_stream = self._clean_channel_name(stream['name'], ignore_tags)
cleaned_stream = self._clean_channel_name(stream['name'], ignore_tags, remove_cinemax=channel_has_max)
if cleaned_stream.lower() == cleaned_matched.lower():
matching_streams.append(stream)
@@ -555,7 +565,7 @@ class Plugin:
sorted_streams = self._sort_streams_by_quality(matching_streams)
logger.info(f"[Stream-Mapparr] Found {len(sorted_streams)} streams via fuzzy match (score: {score}, type: {match_type})")
cleaned_stream_names = [self._clean_channel_name(s['name'], ignore_tags) for s in sorted_streams]
cleaned_stream_names = [self._clean_channel_name(s['name'], ignore_tags, remove_cinemax=channel_has_max) for s in sorted_streams]
match_reason = f"Fuzzy match ({match_type}, score: {score})"
return sorted_streams, cleaned_channel_name, cleaned_stream_names, match_reason
@@ -579,7 +589,7 @@ class Plugin:
# Look for streams that match this channel name exactly
for stream in all_streams:
cleaned_stream_name = self._clean_channel_name(stream['name'], ignore_tags)
cleaned_stream_name = self._clean_channel_name(stream['name'], ignore_tags, remove_cinemax=channel_has_max)
if cleaned_stream_name.lower() == cleaned_channel_name.lower():
matching_streams.append(stream)
@@ -588,14 +598,14 @@ class Plugin:
sorted_streams = self._sort_streams_by_quality(matching_streams)
logger.info(f"[Stream-Mapparr] Found {len(sorted_streams)} streams matching exact channel name")
cleaned_stream_names = [self._clean_channel_name(s['name'], ignore_tags) for s in sorted_streams]
cleaned_stream_names = [self._clean_channel_name(s['name'], ignore_tags, remove_cinemax=channel_has_max) for s in sorted_streams]
match_reason = "Exact match (channels.json)"
return sorted_streams, cleaned_channel_name, cleaned_stream_names, match_reason
# Fallback to basic substring matching
for stream in all_streams:
cleaned_stream_name = self._clean_channel_name(stream['name'], ignore_tags)
cleaned_stream_name = self._clean_channel_name(stream['name'], ignore_tags, remove_cinemax=channel_has_max)
# Simple case-insensitive substring matching
if cleaned_channel_name.lower() in cleaned_stream_name.lower() or cleaned_stream_name.lower() in cleaned_channel_name.lower():
@@ -605,7 +615,7 @@ class Plugin:
sorted_streams = self._sort_streams_by_quality(matching_streams)
logger.info(f"[Stream-Mapparr] Found {len(sorted_streams)} streams matching via basic substring match")
cleaned_stream_names = [self._clean_channel_name(s['name'], ignore_tags) for s in sorted_streams]
cleaned_stream_names = [self._clean_channel_name(s['name'], ignore_tags, remove_cinemax=channel_has_max) for s in sorted_streams]
match_reason = "Basic substring match"
return sorted_streams, cleaned_channel_name, cleaned_stream_names, match_reason
@@ -1408,7 +1418,7 @@ class Plugin:
# If there are eligible channels, enable only the highest priority one
enabled_in_group = False
for ch in group_channels:
for ch in sorted_channels:
channel_id = ch['id']
channel_name = ch['name']
@@ -1426,17 +1436,18 @@ class Plugin:
if is_attached:
reason = 'Attached to another channel'
should_enable = False
elif stream_count >= 2:
reason = f'{stream_count} streams (too many)'
should_enable = False
elif not enabled_in_group and (stream_count == 0 or stream_count == 1):
# This is the highest priority channel with 0-1 streams
reason = f'{stream_count} stream{"" if stream_count == 1 else "s"}'
elif not enabled_in_group and stream_count >= 1:
# This is the highest priority, non-attached channel WITH streams
reason = f'{stream_count} stream{"s" if stream_count != 1 else ""}'
should_enable = True
enabled_in_group = True
elif stream_count == 0:
# This channel has no streams
reason = 'No streams found'
should_enable = False
else:
# Another channel in this group is already enabled
reason = 'Duplicate - higher priority channel in group already enabled'
# This is a duplicate (a lower-priority channel in the group)
reason = 'Duplicate - higher priority channel enabled'
should_enable = False
channel_stream_counts[channel_id] = {