diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 92755252..6537e6b8 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 +from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg import django_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter @@ -789,6 +789,33 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": "EPG matching task initiated."}, status=status.HTTP_202_ACCEPTED ) + @swagger_auto_schema( + method="post", + operation_description="Try to auto-match this specific channel with EPG data.", + responses={200: "EPG matching completed", 202: "EPG matching task initiated"}, + ) + @action(detail=True, methods=["post"], url_path="match-epg") + def match_channel_epg(self, request, pk=None): + channel = self.get_object() + + # Import the matching logic + from apps.channels.tasks import match_single_channel_epg + + try: + # Try to match this specific channel - call synchronously for immediate response + result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30) + + # Refresh the channel from DB to get any updates + channel.refresh_from_db() + + return Response({ + "message": result.get("message", "Channel matching completed"), + "matched": result.get("matched", False), + "channel": self.get_serializer(channel).data + }) + except Exception as e: + return Response({"error": str(e)}, status=400) + # ───────────────────────────────────────────────────────── # 7) Set EPG and Refresh # ───────────────────────────────────────────────────────── diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index e0954210..f4d58f46 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -241,6 +241,128 @@ def match_epg_channels(): cleanup_memory(log_usage=True, force_collection=True) +@shared_task +def match_single_channel_epg(channel_id): + """ + Try to match a single channel with EPG data using the same logic as match_epg_channels + but for just one channel. Returns a dict with match status and message. + """ + try: + from apps.channels.models import Channel + from apps.epg.models import EPGData + import tempfile + import subprocess + import json + + logger.info(f"Starting single channel EPG matching for channel ID {channel_id}") + + # Get the channel + try: + channel = Channel.objects.get(id=channel_id) + except Channel.DoesNotExist: + return {"matched": False, "message": "Channel not found"} + + # If channel already has EPG data, skip + if channel.epg_data: + return {"matched": False, "message": f"Channel '{channel.name}' already has EPG data assigned"} + + # 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 + + # Prepare channel data for matching script + normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" + channel_json = { + "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(normalized_tvg_id if normalized_tvg_id else channel.name) + } + + # Prepare EPG data + epg_json = [] + for epg in EPGData.objects.all(): + normalized_epg_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" + epg_json.append({ + 'id': epg.id, + 'tvg_id': normalized_epg_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, + }) + + # Create payload for matching script + payload = { + "channels": [channel_json], # Only one channel + "epg_data": epg_json, + } + + if region_code: + payload["region_code"] = region_code + + # Write to temporary file and run the matching script + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + json.dump(payload, temp_file) + temp_file_path = temp_file.name + + try: + # Run the matching script + from django.conf import settings + import os + + project_root = settings.BASE_DIR + script_path = os.path.join(project_root, 'scripts', 'epg_match.py') + + process = subprocess.Popen( + ['python', script_path, temp_file_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=project_root + ) + + stdout, stderr = process.communicate(timeout=60) # 1 minute timeout for single channel + + if process.returncode != 0: + logger.error(f"EPG matching script failed: {stderr}") + return {"matched": False, "message": "EPG matching failed"} + + result = json.loads(stdout) + channels_to_update = result.get("channels_to_update", []) + + if channels_to_update: + # Update the channel with the matched EPG data + epg_data_id = channels_to_update[0].get("epg_data_id") + if epg_data_id: + try: + epg_data = EPGData.objects.get(id=epg_data_id) + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + + return { + "matched": True, + "message": f"Channel '{channel.name}' matched with EPG '{epg_data.name}' (TVG ID: {epg_data.tvg_id})" + } + except EPGData.DoesNotExist: + return {"matched": False, "message": "Matched EPG data not found"} + + return {"matched": False, "message": f"No suitable EPG match found for channel '{channel.name}'"} + + finally: + # Clean up temp file + os.remove(temp_file_path) + + except Exception as e: + logger.error(f"Error in single channel EPG matching: {e}", exc_info=True) + return {"matched": False, "message": f"Error during matching: {str(e)}"} + + def evaluate_series_rules_impl(tvg_id: str | None = None): """Synchronous implementation of series rule evaluation; returns details for debugging.""" from django.utils import timezone diff --git a/frontend/src/api.js b/frontend/src/api.js index 956f3ece..d3e222d2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1452,6 +1452,26 @@ export default class API { } } + static async matchChannelEpg(channelId) { + try { + const response = await request( + `${host}/api/channels/channels/${channelId}/match-epg/`, + { + method: 'POST', + } + ); + + // Update the channel in the store with the refreshed data if provided + if (response.channel) { + useChannelsStore.getState().updateChannel(response.channel); + } + + return response; + } catch (e) { + errorNotification('Failed to run EPG auto-match for channel', e); + } + } + static async fetchActiveChannelStats() { try { const response = await request(`${host}/proxy/ts/status`); diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index d07fa44c..62da50c1 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -34,7 +34,7 @@ import { UnstyledButton, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react'; +import { ListOrdered, SquarePlus, SquareX, X, Zap } from 'lucide-react'; import useEPGsStore from '../../store/epgs'; import { Dropzone } from '@mantine/dropzone'; import { FixedSizeList as List } from 'react-window'; @@ -121,6 +121,48 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } }; + const handleAutoMatchEpg = async () => { + // Only attempt auto-match for existing channels (editing mode) + if (!channel || !channel.id) { + notifications.show({ + title: 'Info', + message: 'Auto-match is only available when editing existing channels.', + color: 'blue', + }); + return; + } + + try { + const response = await API.matchChannelEpg(channel.id); + + if (response.matched) { + // Update the form with the new EPG data + if (response.channel && response.channel.epg_data_id) { + formik.setFieldValue('epg_data_id', response.channel.epg_data_id); + } + + notifications.show({ + title: 'Success', + message: response.message, + color: 'green', + }); + } else { + notifications.show({ + title: 'No Match Found', + message: response.message, + color: 'orange', + }); + } + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to auto-match EPG data', + color: 'red', + }); + console.error('Auto-match error:', error); + } + }; + const formik = useFormik({ initialValues: { name: '', @@ -707,6 +749,20 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { > Use Dummy + } readOnly