mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-08-03 15:22:36 +00:00
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:
parent
20685b8344
commit
60e378b1ce
4 changed files with 195 additions and 11 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue