Add support for matching selected channels with EPG data

- Updated API to accept optional channel IDs for EPG matching.
- Enhanced match_epg method to process only specified channels if provided.
- Implemented new task for matching selected channels in the backend.
- Updated frontend to trigger EPG matching for selected channels with notifications.
This commit is contained in:
SergeantPanda 2025-09-16 14:38:16 -05:00
parent 20685b8344
commit 60e378b1ce
4 changed files with 195 additions and 11 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, 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(

View file

@ -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):
"""

View file

@ -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),
}
);

View file

@ -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}
>
<Text size="xs">Auto-Match</Text>
<Text size="xs">
{selectedTableIds.length > 0
? `Auto-Match (${selectedTableIds.length} selected)`
: 'Auto-Match EPG'
}
</Text>
</Menu.Item>
<Menu.Item