Add selectable channel databases feature (v0.6.0)
This update introduces GUI-based channel database management, allowing users to enable or disable specific country databases for channel matching. Key Changes: - Convert fields from static list to @property method for dynamic database detection - Add _get_channel_databases() method to scan and extract database metadata - Update _load_channels_data() to filter by enabled databases from settings - Support new database format with country_code, country_name, and version metadata - Maintain backward compatibility with legacy array format - Add dynamic checkbox fields for each detected database in plugin settings - Default behavior: US enabled, all others disabled (or enable if only one database exists) - Update fuzzy_matcher.py GEOGRAPHIC_PATTERNS to handle any country code prefix (CC:, CC , CCC:, CCC ) - Add comprehensive README documentation for new database format and GUI management - Include sample CA_channels.json demonstrating new metadata format Features: - Selectable channel databases through GUI settings - Multi-country support with automatic country code prefix handling - Clear database labels showing country name and version in settings - Improved matching accuracy by enabling only relevant regional databases Version: 0.6.0
This commit is contained in:
107
Stream-Mapparr/CA_channels.json
Normal file
107
Stream-Mapparr/CA_channels.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"country_code": "CA",
|
||||
"country_name": "Canada",
|
||||
"version": "2025-11-11",
|
||||
"channels": [
|
||||
{
|
||||
"channel_name": "CBC",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "CBC News Network",
|
||||
"category": "News",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "CTV",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "CTV News Channel",
|
||||
"category": "News",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "Global",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "Citytv",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "TSN",
|
||||
"category": "Sports",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "The Sports Network",
|
||||
"category": "Sports",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "Sportsnet",
|
||||
"category": "Sports",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "TVA",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "Ici Radio-Canada",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "CTV Comedy Channel",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "CTV Drama Channel",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "Discovery Channel",
|
||||
"category": "Documentary",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "History",
|
||||
"category": "Documentary",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "Food Network Canada",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "HGTV Canada",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "W Network",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "Showcase",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
},
|
||||
{
|
||||
"channel_name": "Space",
|
||||
"category": "Entertainment",
|
||||
"type": "National"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -44,11 +44,14 @@ REGIONAL_PATTERNS = [
|
||||
r'\s[Ee][Aa][Ss][Tt]',
|
||||
]
|
||||
|
||||
# Geographic prefix patterns: US:, USA:, etc.
|
||||
# Geographic prefix patterns: US:, USA:, CA:, UK:, etc.
|
||||
GEOGRAPHIC_PATTERNS = [
|
||||
# Geographic prefixes
|
||||
r'\bUSA?:\s', # "US:" or "USA:"
|
||||
r'\bUS\s', # "US " at word boundary
|
||||
# Geographic prefixes at start with colon: "US:", "CA:", "UK:", etc. (any 2-3 letter code followed by colon)
|
||||
r'^[A-Z]{2,3}:\s*',
|
||||
# Geographic prefixes at start with space: "US ", "CA ", "UK ", etc. (any 2-3 letter code followed by space)
|
||||
r'^[A-Z]{2,3}\s+',
|
||||
# Legacy USA pattern for backward compatibility
|
||||
r'\bUSA?:\s',
|
||||
]
|
||||
|
||||
# Miscellaneous patterns: (CX), (Backup), single-letter tags, etc.
|
||||
|
||||
@@ -29,108 +29,151 @@ LOGGER.setLevel(logging.INFO)
|
||||
|
||||
class Plugin:
|
||||
"""Dispatcharr Stream-Mapparr Plugin"""
|
||||
|
||||
|
||||
name = "Stream-Mapparr"
|
||||
version = "0.5.0d"
|
||||
version = "0.6.0"
|
||||
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",
|
||||
"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",
|
||||
"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",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "http://192.168.1.10:9191",
|
||||
"help_text": "URL of your Dispatcharr instance (from your browser address bar). Example: http://127.0.0.1:9191",
|
||||
},
|
||||
{
|
||||
"id": "dispatcharr_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",
|
||||
"type": "string",
|
||||
"input_type": "password",
|
||||
"help_text": "Your admin password for the Dispatcharr UI. Required for API access.",
|
||||
},
|
||||
{
|
||||
"id": "profile_name",
|
||||
"label": "📋 Profile Name",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "Sports, Movies, News",
|
||||
"help_text": "*** Required Field *** - The name(s) of existing Channel Profile(s) to process channels from. Multiple profiles can be specified separated by commas.",
|
||||
},
|
||||
{
|
||||
"id": "selected_groups",
|
||||
"label": "📁 Channel Groups (comma-separated)",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "Sports, News, Entertainment",
|
||||
"help_text": "Specific channel groups to process, or leave empty for all groups.",
|
||||
},
|
||||
{
|
||||
"id": "ignore_tags",
|
||||
"label": "🏷️ Ignore Tags (comma-separated)",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "4K, [4K], \" East\", \"[Dead]\"",
|
||||
"help_text": "Tags to ignore when matching streams. Use quotes to preserve spaces/special chars (e.g., \" East\" for tags with leading space).",
|
||||
},
|
||||
{
|
||||
"id": "ignore_quality_tags",
|
||||
"label": "🎬 Ignore Quality Tags",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"help_text": "If enabled, hardcoded quality tags like [4K], [HD], (UHD), etc., will be ignored during matching.",
|
||||
},
|
||||
{
|
||||
"id": "ignore_regional_tags",
|
||||
"label": "🌍 Ignore Regional Tags",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"help_text": "If enabled, hardcoded regional tags like 'East' will be ignored during matching.",
|
||||
},
|
||||
{
|
||||
"id": "ignore_geographic_tags",
|
||||
"label": "🗺️ Ignore Geographic Tags",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"help_text": "If enabled, hardcoded geographic prefixes like 'US:', 'USA:' will be ignored during matching.",
|
||||
},
|
||||
{
|
||||
"id": "ignore_misc_tags",
|
||||
"label": "🏷️ Ignore Miscellaneous Tags",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"help_text": "If enabled, miscellaneous tags like (CX), (Backup), and single-letter tags will be ignored during matching.",
|
||||
},
|
||||
{
|
||||
"id": "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.",
|
||||
},
|
||||
]
|
||||
|
||||
@property
|
||||
def fields(self):
|
||||
"""Dynamically generate settings fields including channel database selection."""
|
||||
# Static fields that are always present
|
||||
static_fields = [
|
||||
{
|
||||
"id": "overwrite_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",
|
||||
"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",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "http://192.168.1.10:9191",
|
||||
"help_text": "URL of your Dispatcharr instance (from your browser address bar). Example: http://127.0.0.1:9191",
|
||||
},
|
||||
{
|
||||
"id": "dispatcharr_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",
|
||||
"type": "string",
|
||||
"input_type": "password",
|
||||
"help_text": "Your admin password for the Dispatcharr UI. Required for API access.",
|
||||
},
|
||||
{
|
||||
"id": "profile_name",
|
||||
"label": "📋 Profile Name",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "Sports, Movies, News",
|
||||
"help_text": "*** Required Field *** - The name(s) of existing Channel Profile(s) to process channels from. Multiple profiles can be specified separated by commas.",
|
||||
},
|
||||
{
|
||||
"id": "selected_groups",
|
||||
"label": "📁 Channel Groups (comma-separated)",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "Sports, News, Entertainment",
|
||||
"help_text": "Specific channel groups to process, or leave empty for all groups.",
|
||||
},
|
||||
{
|
||||
"id": "ignore_tags",
|
||||
"label": "🏷️ Ignore Tags (comma-separated)",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"placeholder": "4K, [4K], \" East\", \"[Dead]\"",
|
||||
"help_text": "Tags to ignore when matching streams. Use quotes to preserve spaces/special chars (e.g., \" East\" for tags with leading space).",
|
||||
},
|
||||
{
|
||||
"id": "ignore_quality_tags",
|
||||
"label": "🎬 Ignore Quality Tags",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"help_text": "If enabled, hardcoded quality tags like [4K], [HD], (UHD), etc., will be ignored during matching.",
|
||||
},
|
||||
{
|
||||
"id": "ignore_regional_tags",
|
||||
"label": "🌍 Ignore Regional Tags",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"help_text": "If enabled, hardcoded regional tags like 'East' will be ignored during matching.",
|
||||
},
|
||||
{
|
||||
"id": "ignore_geographic_tags",
|
||||
"label": "🗺️ Ignore Geographic Tags",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"help_text": "If enabled, hardcoded geographic prefixes like 'US:', 'USA:' will be ignored during matching.",
|
||||
},
|
||||
{
|
||||
"id": "ignore_misc_tags",
|
||||
"label": "🏷️ Ignore Miscellaneous Tags",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"help_text": "If enabled, miscellaneous tags like (CX), (Backup), and single-letter tags will be ignored during matching.",
|
||||
},
|
||||
{
|
||||
"id": "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.",
|
||||
},
|
||||
]
|
||||
|
||||
# Add channel database section header
|
||||
static_fields.append({
|
||||
"id": "channel_databases_header",
|
||||
"type": "info",
|
||||
"label": "📚 Channel Databases",
|
||||
})
|
||||
|
||||
# Dynamically add channel database enable/disable fields
|
||||
try:
|
||||
databases = self._get_channel_databases()
|
||||
|
||||
if databases:
|
||||
for db_info in databases:
|
||||
db_id = db_info['id']
|
||||
db_label = db_info['label']
|
||||
db_default = db_info['default']
|
||||
|
||||
static_fields.append({
|
||||
"id": f"db_enabled_{db_id}",
|
||||
"type": "boolean",
|
||||
"label": f"Enable {db_label}",
|
||||
"help_text": f"Enable or disable the {db_label} channel database for matching.",
|
||||
"default": db_default
|
||||
})
|
||||
else:
|
||||
static_fields.append({
|
||||
"id": "no_databases_found",
|
||||
"type": "info",
|
||||
"label": "⚠️ No channel databases found. Place XX_channels.json files in the plugin directory.",
|
||||
})
|
||||
except Exception as e:
|
||||
LOGGER.error(f"[Stream-Mapparr] Error loading channel databases for settings: {e}")
|
||||
static_fields.append({
|
||||
"id": "database_error",
|
||||
"type": "info",
|
||||
"label": f"⚠️ Error loading channel databases: {e}",
|
||||
})
|
||||
|
||||
return static_fields
|
||||
|
||||
# Actions for Dispatcharr UI
|
||||
actions = [
|
||||
@@ -201,9 +244,71 @@ class Plugin:
|
||||
self.loaded_streams = []
|
||||
self.channel_stream_matches = []
|
||||
self.fuzzy_matcher = None
|
||||
|
||||
|
||||
LOGGER.info(f"[Stream-Mapparr] {self.name} Plugin v{self.version} initialized")
|
||||
|
||||
def _get_channel_databases(self):
|
||||
"""
|
||||
Scan for channel database files and return metadata for each.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'id', 'label', 'default', and 'file_path' keys
|
||||
"""
|
||||
plugin_dir = os.path.dirname(__file__)
|
||||
databases = []
|
||||
|
||||
try:
|
||||
from glob import glob
|
||||
pattern = os.path.join(plugin_dir, '*_channels.json')
|
||||
channel_files = sorted(glob(pattern))
|
||||
|
||||
for channel_file in channel_files:
|
||||
try:
|
||||
filename = os.path.basename(channel_file)
|
||||
# Extract country code from filename (e.g., "US" from "US_channels.json")
|
||||
country_code = filename.split('_')[0].upper()
|
||||
|
||||
# Try to read the file and extract metadata
|
||||
with open(channel_file, 'r', encoding='utf-8') as f:
|
||||
file_data = json.load(f)
|
||||
|
||||
# Check if it's the new format with metadata
|
||||
if isinstance(file_data, dict) and 'country_code' in file_data:
|
||||
country_name = file_data.get('country_name', filename)
|
||||
version = file_data.get('version', '')
|
||||
if version:
|
||||
label = f"{country_name} (v{version})"
|
||||
else:
|
||||
label = country_name
|
||||
else:
|
||||
# Old format or missing metadata - use filename
|
||||
label = filename
|
||||
|
||||
# Determine default value: US enabled by default, or if only one database, enable it
|
||||
# We'll check the count later
|
||||
default = (country_code == 'US')
|
||||
|
||||
databases.append({
|
||||
'id': country_code,
|
||||
'label': label,
|
||||
'default': default,
|
||||
'file_path': channel_file,
|
||||
'filename': filename
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"[Stream-Mapparr] Error reading database file {channel_file}: {e}")
|
||||
continue
|
||||
|
||||
# If only one database exists, enable it by default
|
||||
if len(databases) == 1:
|
||||
databases[0]['default'] = True
|
||||
|
||||
except Exception as e:
|
||||
LOGGER.error(f"[Stream-Mapparr] Error scanning for channel databases: {e}")
|
||||
|
||||
return databases
|
||||
|
||||
def _initialize_fuzzy_matcher(self, match_threshold=85):
|
||||
"""Initialize the fuzzy matcher with configured threshold."""
|
||||
if self.fuzzy_matcher is None:
|
||||
@@ -545,33 +650,86 @@ class Plugin:
|
||||
|
||||
return sorted(streams, key=get_quality_index)
|
||||
|
||||
def _load_channels_data(self, logger):
|
||||
"""Load channel data from *_channels.json files."""
|
||||
def _load_channels_data(self, logger, settings=None):
|
||||
"""
|
||||
Load channel data from enabled *_channels.json files.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
settings: Plugin settings dict (optional, for filtering by enabled databases)
|
||||
|
||||
Returns:
|
||||
List of channel data from enabled databases
|
||||
"""
|
||||
plugin_dir = os.path.dirname(__file__)
|
||||
channels_data = []
|
||||
|
||||
|
||||
try:
|
||||
# Find all *_channels.json files
|
||||
from glob import glob
|
||||
pattern = os.path.join(plugin_dir, '*_channels.json')
|
||||
channel_files = glob(pattern)
|
||||
|
||||
if channel_files:
|
||||
for channel_file in channel_files:
|
||||
try:
|
||||
with open(channel_file, 'r', encoding='utf-8') as f:
|
||||
file_data = json.load(f)
|
||||
channels_data.extend(file_data)
|
||||
logger.info(f"[Stream-Mapparr] Loaded {len(file_data)} channels from {os.path.basename(channel_file)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Stream-Mapparr] Error loading {channel_file}: {e}")
|
||||
|
||||
logger.info(f"[Stream-Mapparr] Loaded total of {len(channels_data)} channels from {len(channel_files)} file(s)")
|
||||
else:
|
||||
# Get all available databases
|
||||
databases = self._get_channel_databases()
|
||||
|
||||
if not databases:
|
||||
logger.warning(f"[Stream-Mapparr] No *_channels.json files found in {plugin_dir}")
|
||||
return channels_data
|
||||
|
||||
# Filter to only enabled databases
|
||||
enabled_databases = []
|
||||
for db_info in databases:
|
||||
db_id = db_info['id']
|
||||
setting_key = f"db_enabled_{db_id}"
|
||||
|
||||
# Check if this database is enabled in settings
|
||||
if settings:
|
||||
is_enabled = settings.get(setting_key, db_info['default'])
|
||||
else:
|
||||
# No settings provided, use default
|
||||
is_enabled = db_info['default']
|
||||
|
||||
if is_enabled:
|
||||
enabled_databases.append(db_info)
|
||||
|
||||
if not enabled_databases:
|
||||
logger.warning("[Stream-Mapparr] No channel databases are enabled. Please enable at least one database in settings.")
|
||||
return channels_data
|
||||
|
||||
# Load data from enabled databases
|
||||
for db_info in enabled_databases:
|
||||
channel_file = db_info['file_path']
|
||||
db_label = db_info['label']
|
||||
country_code = db_info['id']
|
||||
|
||||
try:
|
||||
with open(channel_file, 'r', encoding='utf-8') as f:
|
||||
file_data = json.load(f)
|
||||
|
||||
# Handle both old and new format
|
||||
if isinstance(file_data, dict) and 'channels' in file_data:
|
||||
# New format with metadata
|
||||
channels_list = file_data['channels']
|
||||
# Add country_code to each channel for prefix handling
|
||||
for channel in channels_list:
|
||||
channel['_country_code'] = country_code
|
||||
elif isinstance(file_data, list):
|
||||
# Old format - direct array
|
||||
channels_list = file_data
|
||||
# Add country_code to each channel for prefix handling
|
||||
for channel in channels_list:
|
||||
channel['_country_code'] = country_code
|
||||
else:
|
||||
logger.error(f"[Stream-Mapparr] Invalid format in {channel_file}")
|
||||
continue
|
||||
|
||||
channels_data.extend(channels_list)
|
||||
logger.info(f"[Stream-Mapparr] Loaded {len(channels_list)} channels from {db_label}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Stream-Mapparr] Error loading {channel_file}: {e}")
|
||||
|
||||
logger.info(f"[Stream-Mapparr] Loaded total of {len(channels_data)} channels from {len(enabled_databases)} enabled database(s)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Stream-Mapparr] Error loading channel data files: {e}")
|
||||
|
||||
|
||||
return channels_data
|
||||
|
||||
def _is_ota_channel(self, channel_info):
|
||||
@@ -1313,7 +1471,7 @@ class Plugin:
|
||||
logger.info("[Stream-Mapparr] Settings validated successfully, proceeding with preview...")
|
||||
|
||||
# Load channel data from channels.json
|
||||
channels_data = self._load_channels_data(logger)
|
||||
channels_data = self._load_channels_data(logger, settings)
|
||||
|
||||
# Load processed data
|
||||
with open(self.processed_data_file, 'r') as f:
|
||||
@@ -1522,9 +1680,9 @@ class Plugin:
|
||||
token, error = self._get_api_token(settings, logger)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
|
||||
# Load channel data from channels.json
|
||||
channels_data = self._load_channels_data(logger)
|
||||
channels_data = self._load_channels_data(logger, settings)
|
||||
|
||||
# Load processed data
|
||||
with open(self.processed_data_file, 'r') as f:
|
||||
|
||||
Reference in New Issue
Block a user