diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index e522b618..4e3851b7 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -39,7 +39,7 @@ from .serializers import (
ChannelProfileSerializer,
RecordingSerializer,
)
-from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg
+from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg, match_selected_channels_epg
import django_filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
@@ -779,14 +779,36 @@ class ChannelViewSet(viewsets.ModelViewSet):
# ─────────────────────────────────────────────────────────
@swagger_auto_schema(
method="post",
- operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data.",
+ operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
+ request_body=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'channel_ids': openapi.Schema(
+ type=openapi.TYPE_ARRAY,
+ items=openapi.Schema(type=openapi.TYPE_INTEGER),
+ description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.'
+ )
+ }
+ ),
responses={202: "EPG matching task initiated"},
)
@action(detail=False, methods=["post"], url_path="match-epg")
def match_epg(self, request):
- match_epg_channels.delay()
+ # Get channel IDs from request body if provided
+ channel_ids = request.data.get('channel_ids', [])
+
+ if channel_ids:
+ # Process only selected channels
+ from .tasks import match_selected_channels_epg
+ match_selected_channels_epg.delay(channel_ids)
+ message = f"EPG matching task initiated for {len(channel_ids)} selected channel(s)."
+ else:
+ # Process all channels without EPG (original behavior)
+ match_epg_channels.delay()
+ message = "EPG matching task initiated for all channels without EPG."
+
return Response(
- {"message": "EPG matching task initiated."}, status=status.HTTP_202_ACCEPTED
+ {"message": message}, status=status.HTTP_202_ACCEPTED
)
@swagger_auto_schema(
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index 17d40ca2..6352ee84 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -526,6 +526,150 @@ def match_epg_channels():
cleanup_memory(log_usage=True, force_collection=True)
+@shared_task
+def match_selected_channels_epg(channel_ids):
+ """
+ Match EPG data for only the specified selected channels.
+ Uses the same integrated EPG matching logic but processes only selected channels.
+ """
+ try:
+ logger.info(f"Starting integrated EPG matching for {len(channel_ids)} selected channels...")
+
+ # Get region preference
+ try:
+ region_obj = CoreSettings.objects.get(key="preferred-region")
+ region_code = region_obj.value.strip().lower()
+ except CoreSettings.DoesNotExist:
+ region_code = None
+
+ # Get only the specified channels that don't have EPG data assigned
+ channels_without_epg = Channel.objects.filter(
+ id__in=channel_ids,
+ epg_data__isnull=True
+ )
+ logger.info(f"Found {channels_without_epg.count()} selected channels without EPG data")
+
+ if not channels_without_epg.exists():
+ logger.info("No selected channels need EPG matching.")
+
+ # Send WebSocket update
+ channel_layer = get_channel_layer()
+ async_to_sync(channel_layer.group_send)(
+ 'updates',
+ {
+ 'type': 'update',
+ "data": {
+ "success": True,
+ "type": "epg_match",
+ "refresh_channels": True,
+ "matches_count": 0,
+ "message": "No selected channels need EPG matching",
+ "associations": []
+ }
+ }
+ )
+ return "No selected channels needed EPG matching."
+
+ channels_data = []
+ for channel in channels_without_epg:
+ normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else ""
+ channels_data.append({
+ "id": channel.id,
+ "name": channel.name,
+ "tvg_id": normalized_tvg_id,
+ "original_tvg_id": channel.tvg_id,
+ "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name,
+ "norm_chan": normalize_name(channel.name)
+ })
+
+ # Get all EPG data
+ epg_data = []
+ for epg in EPGData.objects.all():
+ normalized_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else ""
+ epg_data.append({
+ 'id': epg.id,
+ 'tvg_id': normalized_tvg_id,
+ 'original_tvg_id': epg.tvg_id,
+ 'name': epg.name,
+ 'norm_name': normalize_name(epg.name),
+ 'epg_source_id': epg.epg_source.id if epg.epg_source else None,
+ })
+
+ logger.info(f"Processing {len(channels_data)} selected channels against {len(epg_data)} EPG entries")
+
+ # Run EPG matching with progress updates - automatically uses appropriate thresholds
+ result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True, send_progress=True)
+ channels_to_update_dicts = result["channels_to_update"]
+ matched_channels = result["matched_channels"]
+
+ # Update channels in database
+ if channels_to_update_dicts:
+ channel_ids_to_update = [d["id"] for d in channels_to_update_dicts]
+ channels_qs = Channel.objects.filter(id__in=channel_ids_to_update)
+ channels_list = list(channels_qs)
+
+ # Create mapping from channel_id to epg_data_id
+ epg_mapping = {d["id"]: d["epg_data_id"] for d in channels_to_update_dicts}
+
+ # Update each channel with matched EPG data
+ for channel_obj in channels_list:
+ epg_data_id = epg_mapping.get(channel_obj.id)
+ if epg_data_id:
+ try:
+ epg_data_obj = EPGData.objects.get(id=epg_data_id)
+ channel_obj.epg_data = epg_data_obj
+ except EPGData.DoesNotExist:
+ logger.error(f"EPG data {epg_data_id} not found for channel {channel_obj.id}")
+
+ # Bulk update all channels
+ Channel.objects.bulk_update(channels_list, ["epg_data"])
+
+ total_matched = len(matched_channels)
+ if total_matched:
+ logger.info(f"Selected Channel Match Summary: {total_matched} channel(s) matched.")
+ for (cid, cname, tvg) in matched_channels:
+ logger.info(f" - Channel ID={cid}, Name='{cname}' => tvg_id='{tvg}'")
+ else:
+ logger.info("No selected channels were matched.")
+
+ logger.info("Finished integrated EPG matching for selected channels.")
+
+ # Send WebSocket update
+ channel_layer = get_channel_layer()
+ associations = [
+ {"channel_id": chan["id"], "epg_data_id": chan["epg_data_id"]}
+ for chan in channels_to_update_dicts
+ ]
+
+ async_to_sync(channel_layer.group_send)(
+ 'updates',
+ {
+ 'type': 'update',
+ "data": {
+ "success": True,
+ "type": "epg_match",
+ "refresh_channels": True,
+ "matches_count": total_matched,
+ "message": f"EPG matching complete: {total_matched} selected channel(s) matched",
+ "associations": associations
+ }
+ }
+ )
+
+ return f"Done. Matched {total_matched} selected channel(s)."
+
+ finally:
+ # Clean up ML models from memory after bulk matching
+ if _ml_model_cache['sentence_transformer'] is not None:
+ logger.info("Cleaning up ML models from memory")
+ _ml_model_cache['sentence_transformer'] = None
+
+ # Memory cleanup
+ gc.collect()
+ from core.utils import cleanup_memory
+ cleanup_memory(log_usage=True, force_collection=True)
+
+
@shared_task
def match_single_channel_epg(channel_id):
"""
diff --git a/frontend/src/api.js b/frontend/src/api.js
index d3e222d2..09f8c3c1 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -1437,12 +1437,18 @@ export default class API {
}
}
- static async matchEpg() {
+ static async matchEpg(channelIds = null) {
try {
+ const requestBody = channelIds ? { channel_ids: channelIds } : {};
+
const response = await request(
`${host}/api/channels/channels/match-epg/`,
{
method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
}
);
diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
index 54fa2f8d..e9f5172d 100644
--- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
+++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
@@ -143,11 +143,18 @@ const ChannelTableHeader = ({
const matchEpg = async () => {
try {
// Hit our new endpoint that triggers the fuzzy matching Celery task
- await API.matchEpg();
-
- notifications.show({
- title: 'EPG matching task started!',
- });
+ // If channels are selected, only match those; otherwise match all
+ if (selectedTableIds.length > 0) {
+ await API.matchEpg(selectedTableIds);
+ notifications.show({
+ title: `EPG matching task started for ${selectedTableIds.length} selected channel(s)!`,
+ });
+ } else {
+ await API.matchEpg();
+ notifications.show({
+ title: 'EPG matching task started for all channels without EPG!',
+ });
+ }
} catch (err) {
notifications.show(`Error: ${err.message}`);
}
@@ -298,7 +305,12 @@ const ChannelTableHeader = ({
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={matchEpg}
>
-