From f6fa90178dfbf5c7f0cf59f0d184de50ecf048f0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 19 Jul 2025 15:23:10 -0500 Subject: [PATCH] Adds the ability to auto channel sync to only add channels where the stream name matches a regex. --- apps/m3u/tasks.py | 13 + .../src/components/forms/M3UGroupFilter.jsx | 248 ++++++++++++------ 2 files changed, 181 insertions(+), 80 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 63ef2a7f..e3893dc1 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -881,6 +881,7 @@ def sync_auto_channels(account_id, scan_start_time=None): override_group_id = None name_regex_pattern = None name_replace_pattern = None + name_match_regex = None if group_relation.custom_properties: try: group_custom_props = json.loads(group_relation.custom_properties) @@ -888,11 +889,13 @@ def sync_auto_channels(account_id, scan_start_time=None): override_group_id = group_custom_props.get("group_override") name_regex_pattern = group_custom_props.get("name_regex_pattern") name_replace_pattern = group_custom_props.get("name_replace_pattern") + name_match_regex = group_custom_props.get("name_match_regex") except Exception: force_dummy_epg = False override_group_id = None name_regex_pattern = None name_replace_pattern = None + name_match_regex = None # Determine which group to use for created channels target_group = channel_group @@ -912,6 +915,16 @@ def sync_auto_channels(account_id, scan_start_time=None): last_seen__gte=scan_start_time ).order_by('name') + # --- FILTER STREAMS BY NAME MATCH REGEX IF SPECIFIED --- + if name_match_regex: + try: + compiled_name_match_regex = re.compile(name_match_regex, re.IGNORECASE) + current_streams = current_streams.filter( + name__iregex=name_match_regex + ) + except re.error as e: + logger.warning(f"Invalid name_match_regex '{name_match_regex}' for group '{channel_group.name}': {e}. Skipping name filter.") + # Get existing auto-created channels for this account (regardless of current group) # We'll find them by their stream associations instead of just group location existing_channels = Channel.objects.filter( diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 6be574ad..0db7ff1c 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -1,5 +1,5 @@ // Modal.js -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, forwardRef } from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import API from '../../api'; @@ -26,12 +26,22 @@ import { Alert, Box, MultiSelect, + Tooltip, } from '@mantine/core'; import { Info } from 'lucide-react'; import useChannelsStore from '../../store/channels'; import { CircleCheck, CircleX } from 'lucide-react'; import { notifications } from '@mantine/notifications'; +// Custom item component for MultiSelect with tooltip +const OptionWithTooltip = forwardRef(({ label, description, ...others }, ref) => ( + +
+ {label} +
+
+)); + const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); const [groupStates, setGroupStates] = useState([]); @@ -254,7 +264,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { {/* Auto Sync Controls */} - + { label="Advanced Options" placeholder="Select options..." data={[ - { value: 'force_dummy_epg', label: 'Force Dummy EPG' }, - { value: 'group_override', label: 'Override Channel Group' }, - { value: 'name_regex', label: 'Channel Name Regex' }, + { + value: 'force_dummy_epg', + label: 'Force Dummy EPG', + description: 'Assign a dummy EPG to all channels in this group if no EPG is matched', + }, + { + value: 'group_override', + label: 'Override Channel Group', + description: 'Override the group assignment for all channels in this group', + }, + { + value: 'name_regex', + label: 'Channel Name Find & Replace (Regex)', + description: 'Find and replace part of the channel name using a regex pattern', + }, + { + value: 'name_match_regex', + label: 'Channel Name Filter (Regex)', + description: 'Only include channels whose names match this regex pattern', + }, ]} + itemComponent={OptionWithTooltip} value={(() => { const selectedValues = []; if (group.custom_properties?.force_dummy_epg) { @@ -294,10 +322,15 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { if (group.custom_properties?.group_override !== undefined) { selectedValues.push('group_override'); } - if (group.custom_properties?.name_regex_pattern !== undefined || - group.custom_properties?.name_replace_pattern !== undefined) { + if ( + group.custom_properties?.name_regex_pattern !== undefined || + group.custom_properties?.name_replace_pattern !== undefined + ) { selectedValues.push('name_regex'); } + if (group.custom_properties?.name_match_regex !== undefined) { + selectedValues.push('name_match_regex'); + } return selectedValues; })()} onChange={(values) => { @@ -338,6 +371,15 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { delete newCustomProps.name_replace_pattern; } + // Handle name_match_regex + if (selectedOptions.includes('name_match_regex')) { + if (newCustomProps.name_match_regex === undefined) { + newCustomProps.name_match_regex = ''; + } + } else { + delete newCustomProps.name_match_regex; + } + return { ...state, custom_properties: newCustomProps, @@ -353,87 +395,133 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { {/* Show group select only if group_override is selected */} {group.custom_properties?.group_override !== undefined && ( - { + 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, + }))} + clearable + searchable + size="xs" + /> + )} {/* Show regex fields only if name_regex is selected */} {(group.custom_properties?.name_regex_pattern !== undefined || group.custom_properties?.name_replace_pattern !== undefined) && ( <> - { - const val = e.currentTarget.value; - setGroupStates( - groupStates.map(state => - state.channel_group === group.channel_group - ? { - ...state, - custom_properties: { - ...state.custom_properties, - name_regex_pattern: val, - }, - } - : state - ) - ); - }} - size="xs" - /> - { - const val = e.currentTarget.value; - setGroupStates( - groupStates.map(state => - state.channel_group === group.channel_group - ? { - ...state, - custom_properties: { - ...state.custom_properties, - name_replace_pattern: val, - }, - } - : state - ) - ); - }} - size="xs" - /> + + { + const val = e.currentTarget.value; + setGroupStates( + groupStates.map(state => + state.channel_group === group.channel_group + ? { + ...state, + custom_properties: { + ...state.custom_properties, + name_regex_pattern: val, + }, + } + : state + ) + ); + }} + size="xs" + /> + + + { + const val = e.currentTarget.value; + setGroupStates( + groupStates.map(state => + state.channel_group === group.channel_group + ? { + ...state, + custom_properties: { + ...state.custom_properties, + name_replace_pattern: val, + }, + } + : state + ) + ); + }} + size="xs" + /> + )} + + {/* Show name_match_regex field only if selected */} + {group.custom_properties?.name_match_regex !== undefined && ( + + { + const val = e.currentTarget.value; + setGroupStates( + groupStates.map(state => + state.channel_group === group.channel_group + ? { + ...state, + custom_properties: { + ...state.custom_properties, + name_match_regex: val, + }, + } + : state + ) + ); + }} + size="xs" + /> + + )} )}