Add EPG auto-match functionality for specific channels and update UI

This commit is contained in:
SergeantPanda 2025-09-16 08:55:10 -05:00
parent eccc5d709a
commit f1739f2394
4 changed files with 227 additions and 2 deletions

View file

@ -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
# ─────────────────────────────────────────────────────────

View file

@ -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

View file

@ -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`);

View file

@ -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
</Button>
<Button
size="xs"
variant="transparent"
color="blue"
onClick={(e) => {
e.stopPropagation();
handleAutoMatchEpg();
}}
disabled={!channel || !channel.id}
title={!channel || !channel.id ? "Auto-match is only available for existing channels" : "Automatically match EPG data"}
leftSection={<Zap size="14" />}
>
Auto Match
</Button>
</Group>
}
readOnly