From 70d900f346d87c43b18b8257bd38d679d383799e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 5 Jun 2026 12:49:05 -0500 Subject: [PATCH] feat(epg): implement auto-apply functionality for EPG logos Add support for automatically applying channel logos from EPG data during refresh. Introduce a new toggle in the UI for enabling/disabling this feature, and update the backend to handle both channel IDs and EPG source IDs for logo application. Enhance the logo application task to process large libraries efficiently in chunks, improving performance and memory usage. Update changelog to reflect these changes. --- CHANGELOG.md | 3 +- apps/channels/api_views.py | 40 ++- apps/channels/tasks.py | 185 ++++++++------ apps/channels/tests/test_epg_logo_apply.py | 272 +++++++++++++++++++++ apps/channels/utils.py | 190 ++++++++++++++ apps/epg/tasks.py | 38 +-- frontend/src/api.js | 19 +- frontend/src/components/forms/EPG.jsx | 77 ++++-- 8 files changed, 680 insertions(+), 144 deletions(-) create mode 100644 apps/channels/tests/test_epg_logo_apply.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 048ffbbf..a69f042b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **EPG logo auto-apply for XMLTV and Schedules Direct.** Channel logos are applied from `EPGData.icon_url` through a shared helper used by bulk "Set Logos from EPG", optional per-source auto-apply on refresh (`auto_apply_epg_logos` in source `custom_properties`), and a new `epg_source_id` option on the set-logos-from-epg API. Large libraries are processed in 500-channel chunks without loading every mapped row into memory. - **Schedules Direct EPG integration.** Dispatcharr now supports Schedules Direct as a first-class EPG source type alongside XMLTV and dummy EPG, with credential auth, lineup management, and guide refresh through the existing EPG pipeline and WebSocket progress. (Closes #1246) — Thanks [@Shokkstokk](https://github.com/Shokkstokk) - Lineup manager in EPG source settings: search by postal code, add/remove up to four active lineups, with SD's six-adds-per-24-hours limit and midnight-UTC reset surfaced in the UI. - MD5 delta refresh skips unchanged schedule and program downloads; a two-hour minimum interval between full refreshes is enforced (bypassable via force refresh). - - Station logos in dark, light, gray, or white variants, with an optional auto-apply toggle to push logos onto matched channels. + - Station logos in dark, light, gray, or white variants (`logo_style`), stored in `EPGData.icon_url` alongside XMLTV channel icons. - Optional program poster fetch (off by default): configurable style preference (SD Recommended via Gracenote's `primary` flag, or portrait/landscape banner/iconic variants with fallbacks), served through a backend proxy cached by nginx on first view. - XMLTV-compatible cast output (`role` for character names, `guest` for guest stars). - Lightweight stations-only fetch on source creation so EPG entries exist for auto-matching before the first full schedule pull. diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index d6fe5fea..9fd47400 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1525,32 +1525,52 @@ class ChannelViewSet(viewsets.ModelViewSet): @action(detail=False, methods=["post"], url_path="set-logos-from-epg") def set_logos_from_epg(self, request): """ - Trigger a Celery task to set channel logos from EPG data + Trigger a Celery task to set channel logos from EPG data. + Provide channel_ids or epg_source_id (not both). """ from .tasks import set_channels_logos_from_epg data = request.data - channel_ids = data.get("channel_ids", []) + channel_ids = data.get("channel_ids") + epg_source_id = data.get("epg_source_id") - if not channel_ids: + if channel_ids and epg_source_id: return Response( - {"error": "channel_ids is required"}, + {"error": "Provide either channel_ids or epg_source_id, not both"}, status=status.HTTP_400_BAD_REQUEST, ) - if not isinstance(channel_ids, list): + if not channel_ids and not epg_source_id: return Response( - {"error": "channel_ids must be a list"}, + {"error": "channel_ids or epg_source_id is required"}, status=status.HTTP_400_BAD_REQUEST, ) - # Start the Celery task - task = set_channels_logos_from_epg.delay(channel_ids) + if channel_ids is not None: + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be a list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not channel_ids: + return Response( + {"error": "channel_ids cannot be empty"}, + status=status.HTTP_400_BAD_REQUEST, + ) + task = set_channels_logos_from_epg.delay(channel_ids=channel_ids) + channel_count = len(channel_ids) + else: + from .utils import channels_with_epg_icon_queryset + + task = set_channels_logos_from_epg.delay(epg_source_id=epg_source_id) + channel_count = channels_with_epg_icon_queryset( + epg_source_id=epg_source_id, + ).count() return Response({ - "message": f"Started EPG logo setting task for {len(channel_ids)} channels", + "message": f"Started EPG logo setting task for {channel_count} channels", "task_id": task.id, - "channel_count": len(channel_ids) + "channel_count": channel_count, }) @action(detail=False, methods=["post"], url_path="set-tvg-ids-from-epg") diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 598ca297..fb676b7d 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -4088,97 +4088,136 @@ def set_channels_names_from_epg(self, channel_ids): @shared_task(bind=True) -def set_channels_logos_from_epg(self, channel_ids): +def set_channels_logos_from_epg(self, channel_ids=None, epg_source_id=None): """ - Celery task to set channel logos from EPG data for multiple channels - Creates logos from EPG icon URLs if they don't exist + Celery task to set channel logos from EPG data. + + Accepts either an explicit channel_ids list or an epg_source_id to target + all channels mapped to that source. """ - from .models import Logo from core.utils import send_websocket_update - import requests - from urllib.parse import urlparse + from .utils import ( + EPG_LOGO_APPLY_BATCH_SIZE, + EPG_LOGO_APPLY_MAX_ERRORS, + apply_logos_from_epg_icon_url, + channels_with_epg_icon_queryset, + ) task_id = self.request.id - total_channels = len(channel_ids) updated_count = 0 created_logos_count = 0 errors = [] + total_channels = 0 + batch_size = EPG_LOGO_APPLY_BATCH_SIZE try: - logger.info(f"Starting EPG logo setting task for {total_channels} channels") + if epg_source_id: + channels_qs = channels_with_epg_icon_queryset(epg_source_id=epg_source_id) + total_channels = channels_qs.count() + logger.info( + f"Starting EPG logo setting task for {total_channels} channels " + f"(source {epg_source_id})" + ) - # Send initial progress - send_websocket_update('updates', 'update', { - 'type': 'epg_logo_setting_progress', - 'task_id': task_id, - 'progress': 0, - 'total': total_channels, - 'status': 'running', - 'message': 'Starting EPG logo setting...' - }) - - batch_size = 50 # Smaller batch for logo processing - for i in range(0, total_channels, batch_size): - batch_ids = channel_ids[i:i + batch_size] - batch_updates = [] - - # Get channels and their EPG data - channels = Channel.objects.filter(id__in=batch_ids).select_related('epg_data', 'logo') - - for channel in channels: - try: - if channel.epg_data and channel.epg_data.icon_url: - icon_url = channel.epg_data.icon_url.strip() - - # Try to find existing logo with this URL - try: - logo = Logo.objects.get(url=icon_url) - except Logo.DoesNotExist: - # Create new logo from EPG icon URL - try: - # Generate a name for the logo - logo_name = channel.epg_data.name or f"Logo for {channel.epg_data.tvg_id}" - - # Create the logo record - logo = Logo.objects.create( - name=logo_name, - url=icon_url - ) - created_logos_count += 1 - logger.info(f"Created new logo from EPG: {logo_name} - {icon_url}") - - except Exception as create_error: - errors.append(f"Channel {channel.id}: Failed to create logo from {icon_url}: {str(create_error)}") - logger.error(f"Failed to create logo for channel {channel.id}: {create_error}") - continue - - # Update channel logo if different - if channel.logo != logo: - channel.logo = logo - batch_updates.append(channel) - updated_count += 1 - - except Exception as e: - errors.append(f"Channel {channel.id}: {str(e)}") - logger.error(f"Error processing channel {channel.id}: {e}") - - # Bulk update the batch - if batch_updates: - Channel.objects.bulk_update(batch_updates, ['logo']) - - # Send progress update - progress = min(i + batch_size, total_channels) send_websocket_update('updates', 'update', { 'type': 'epg_logo_setting_progress', 'task_id': task_id, - 'progress': progress, + 'progress': 0, 'total': total_channels, 'status': 'running', - 'message': f'Updated {updated_count} channel logos, created {created_logos_count} new logos...', - 'updated_count': updated_count, - 'created_logos_count': created_logos_count + 'message': 'Starting EPG logo setting...' }) + processed = 0 + pending_ids = [] + for channel_id in channels_qs.order_by('id').values_list('id', flat=True).iterator( + chunk_size=batch_size, + ): + pending_ids.append(channel_id) + if len(pending_ids) < batch_size: + continue + + batch = Channel.objects.filter( + id__in=pending_ids, + ).select_related('epg_data', 'logo') + batch_stats = apply_logos_from_epg_icon_url(batch) + updated_count += batch_stats['updated_count'] + created_logos_count += batch_stats['created_logos_count'] + remaining = EPG_LOGO_APPLY_MAX_ERRORS - len(errors) + if remaining > 0: + errors.extend(batch_stats['errors'][:remaining]) + + processed += len(pending_ids) + pending_ids = [] + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': processed, + 'total': total_channels, + 'status': 'running', + 'message': ( + f'Updated {updated_count} channel logos, ' + f'created {created_logos_count} new logos...' + ), + 'updated_count': updated_count, + 'created_logos_count': created_logos_count, + }) + + if pending_ids: + batch = Channel.objects.filter( + id__in=pending_ids, + ).select_related('epg_data', 'logo') + batch_stats = apply_logos_from_epg_icon_url(batch) + updated_count += batch_stats['updated_count'] + created_logos_count += batch_stats['created_logos_count'] + remaining = EPG_LOGO_APPLY_MAX_ERRORS - len(errors) + if remaining > 0: + errors.extend(batch_stats['errors'][:remaining]) + processed += len(pending_ids) + + elif channel_ids: + total_channels = len(channel_ids) + logger.info(f"Starting EPG logo setting task for {total_channels} channels") + + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'running', + 'message': 'Starting EPG logo setting...' + }) + + for i in range(0, total_channels, batch_size): + batch_ids = channel_ids[i:i + batch_size] + channels = Channel.objects.filter( + id__in=batch_ids, + ).select_related('epg_data', 'logo') + + batch_stats = apply_logos_from_epg_icon_url(channels) + updated_count += batch_stats['updated_count'] + created_logos_count += batch_stats['created_logos_count'] + remaining = EPG_LOGO_APPLY_MAX_ERRORS - len(errors) + if remaining > 0: + errors.extend(batch_stats['errors'][:remaining]) + + progress = min(i + batch_size, total_channels) + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': progress, + 'total': total_channels, + 'status': 'running', + 'message': ( + f'Updated {updated_count} channel logos, ' + f'created {created_logos_count} new logos...' + ), + 'updated_count': updated_count, + 'created_logos_count': created_logos_count, + }) + else: + raise ValueError("channel_ids or epg_source_id is required") + # Send completion notification send_websocket_update('updates', 'update', { 'type': 'epg_logo_setting_progress', diff --git a/apps/channels/tests/test_epg_logo_apply.py b/apps/channels/tests/test_epg_logo_apply.py new file mode 100644 index 00000000..b94b69e4 --- /dev/null +++ b/apps/channels/tests/test_epg_logo_apply.py @@ -0,0 +1,272 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from apps.channels.models import Channel, Logo +from apps.channels.utils import ( + apply_logos_from_epg_icon_url, + apply_logos_from_epg_for_source, + auto_apply_epg_logos_enabled, + maybe_auto_apply_epg_logos, +) +from apps.epg.models import EPGData, EPGSource + +User = get_user_model() + + +class AutoApplyEpgLogosEnabledTests(TestCase): + def test_enabled_when_flag_true(self): + self.assertTrue( + auto_apply_epg_logos_enabled({'auto_apply_epg_logos': True}) + ) + + def test_disabled_when_flag_false_or_missing(self): + self.assertFalse( + auto_apply_epg_logos_enabled({'auto_apply_epg_logos': False}) + ) + self.assertFalse(auto_apply_epg_logos_enabled({})) + self.assertFalse(auto_apply_epg_logos_enabled(None)) + + +class ApplyLogosFromEpgIconUrlTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name='XML EPG', + source_type='xmltv', + url='http://example.com/epg.xml', + ) + self.epg_one = EPGData.objects.create( + tvg_id='ch.one', + name='Channel One', + icon_url='https://example.com/one.png', + epg_source=self.source, + ) + self.epg_two = EPGData.objects.create( + tvg_id='ch.two', + name='Channel Two', + icon_url='https://example.com/one.png', + epg_source=self.source, + ) + self.channel_one = Channel.objects.create( + channel_number=1, + name='Channel One', + tvg_id='ch.one', + epg_data=self.epg_one, + ) + self.channel_two = Channel.objects.create( + channel_number=2, + name='Channel Two', + tvg_id='ch.two', + epg_data=self.epg_two, + ) + + def test_creates_logo_and_updates_channels(self): + channels = Channel.objects.filter( + id__in=[self.channel_one.id, self.channel_two.id], + ).select_related('epg_data', 'logo') + + stats = apply_logos_from_epg_icon_url(channels) + + self.assertEqual(stats['updated_count'], 2) + self.assertEqual(stats['created_logos_count'], 1) + self.assertEqual(Logo.objects.count(), 1) + + self.channel_one.refresh_from_db() + self.channel_two.refresh_from_db() + self.assertEqual(self.channel_one.logo.url, 'https://example.com/one.png') + self.assertEqual(self.channel_two.logo_id, self.channel_one.logo_id) + + def test_skips_channels_already_using_icon_url(self): + existing_logo = Logo.objects.create( + name='Existing', + url='https://example.com/one.png', + ) + self.channel_one.logo = existing_logo + self.channel_one.save(update_fields=['logo']) + + channels = Channel.objects.filter( + id__in=[self.channel_one.id, self.channel_two.id], + ).select_related('epg_data', 'logo') + + stats = apply_logos_from_epg_icon_url(channels) + + self.assertEqual(stats['updated_count'], 1) + self.assertEqual(stats['created_logos_count'], 0) + + def test_skips_channels_without_icon_url(self): + self.epg_one.icon_url = None + self.epg_one.save(update_fields=['icon_url']) + + channels = Channel.objects.filter( + id__in=[self.channel_one.id, self.channel_two.id], + ).select_related('epg_data', 'logo') + + stats = apply_logos_from_epg_icon_url(channels) + + self.assertEqual(stats['updated_count'], 1) + self.channel_one.refresh_from_db() + self.assertIsNone(self.channel_one.logo_id) + + +class ApplyLogosForSourceTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name='XML EPG', + source_type='xmltv', + url='http://example.com/epg.xml', + ) + self.other_source = EPGSource.objects.create( + name='Other EPG', + source_type='xmltv', + url='http://example.com/other.xml', + ) + self.epg = EPGData.objects.create( + tvg_id='mapped', + name='Mapped', + icon_url='https://example.com/mapped.png', + epg_source=self.source, + ) + self.other_epg = EPGData.objects.create( + tvg_id='other', + name='Other', + icon_url='https://example.com/other.png', + epg_source=self.other_source, + ) + self.mapped_channel = Channel.objects.create( + channel_number=1, + name='Mapped', + tvg_id='mapped', + epg_data=self.epg, + ) + Channel.objects.create( + channel_number=2, + name='Other', + tvg_id='other', + epg_data=self.other_epg, + ) + + def test_only_updates_channels_mapped_to_source(self): + stats = apply_logos_from_epg_for_source(self.source) + + self.assertEqual(stats['updated_count'], 1) + self.mapped_channel.refresh_from_db() + self.assertEqual( + self.mapped_channel.logo.url, + 'https://example.com/mapped.png', + ) + + def test_processes_source_in_batches(self): + stats = apply_logos_from_epg_for_source(self.source, batch_size=1) + + self.assertEqual(stats['updated_count'], 1) + self.mapped_channel.refresh_from_db() + self.assertEqual( + self.mapped_channel.logo.url, + 'https://example.com/mapped.png', + ) + + +class MaybeAutoApplyEpgLogosTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name='XML EPG', + source_type='xmltv', + url='http://example.com/epg.xml', + custom_properties={'auto_apply_epg_logos': True}, + ) + self.epg = EPGData.objects.create( + tvg_id='mapped', + name='Mapped', + icon_url='https://example.com/mapped.png', + epg_source=self.source, + ) + self.channel = Channel.objects.create( + channel_number=1, + name='Mapped', + tvg_id='mapped', + epg_data=self.epg, + ) + + def test_runs_when_enabled(self): + stats = maybe_auto_apply_epg_logos(self.source) + self.assertEqual(stats['updated_count'], 1) + self.channel.refresh_from_db() + self.assertIsNotNone(self.channel.logo_id) + + def test_skips_when_disabled(self): + self.source.custom_properties = {'auto_apply_epg_logos': False} + self.source.save(update_fields=['custom_properties']) + + stats = maybe_auto_apply_epg_logos(self.source) + self.assertIsNone(stats) + self.channel.refresh_from_db() + self.assertIsNone(self.channel.logo_id) + +class SetLogosFromEpgApiTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='testuser', password='testpass123') + self.user.user_level = 10 + self.user.save() + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.url = '/api/channels/channels/set-logos-from-epg/' + + self.source = EPGSource.objects.create( + name='XML EPG', + source_type='xmltv', + url='http://example.com/epg.xml', + ) + self.epg = EPGData.objects.create( + tvg_id='mapped', + name='Mapped', + icon_url='https://example.com/mapped.png', + epg_source=self.source, + ) + self.channel = Channel.objects.create( + channel_number=1, + name='Mapped', + tvg_id='mapped', + epg_data=self.epg, + ) + + @patch('apps.channels.tasks.set_channels_logos_from_epg.delay') + def test_accepts_channel_ids(self, mock_delay): + mock_delay.return_value.id = 'task-1' + + response = self.client.post( + self.url, + {'channel_ids': [self.channel.id]}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_delay.assert_called_once_with(channel_ids=[self.channel.id]) + + @patch('apps.channels.tasks.set_channels_logos_from_epg.delay') + def test_accepts_epg_source_id(self, mock_delay): + mock_delay.return_value.id = 'task-2' + + response = self.client.post( + self.url, + {'epg_source_id': self.source.id}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_delay.assert_called_once_with(epg_source_id=self.source.id) + self.assertEqual(response.data['channel_count'], 1) + + def test_rejects_both_parameters(self): + response = self.client.post( + self.url, + { + 'channel_ids': [self.channel.id], + 'epg_source_id': self.source.id, + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/apps/channels/utils.py b/apps/channels/utils.py index f41c2b72..60a27146 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -1,5 +1,12 @@ +import logging import threading +logger = logging.getLogger(__name__) + +# Bound memory/DB work per chunk for large libraries (20k+ channels). +EPG_LOGO_APPLY_BATCH_SIZE = 500 +EPG_LOGO_APPLY_MAX_ERRORS = 100 + lock = threading.Lock() # Dictionary to track usage: {account_id: current_usage} active_streams_map = {} @@ -35,3 +42,186 @@ def decrement_stream_count(account): active_streams_map[account.id] = current_usage account.active_streams = current_usage account.save(update_fields=['active_streams']) + + +def auto_apply_epg_logos_enabled(custom_properties): + """Return whether channel logos should be auto-applied after EPG refresh.""" + return bool((custom_properties or {}).get('auto_apply_epg_logos', False)) + + +def _empty_logo_apply_stats(): + return { + 'updated_count': 0, + 'created_logos_count': 0, + 'error_count': 0, + 'errors': [], + } + + +def _merge_logo_apply_stats(accumulated, batch_stats): + accumulated['updated_count'] += batch_stats['updated_count'] + accumulated['created_logos_count'] += batch_stats['created_logos_count'] + accumulated['error_count'] += batch_stats['error_count'] + remaining = EPG_LOGO_APPLY_MAX_ERRORS - len(accumulated['errors']) + if remaining > 0: + accumulated['errors'].extend(batch_stats['errors'][:remaining]) + return accumulated + + +def apply_logos_from_epg_icon_url(channels): + """ + Set channel.logo from epg_data.icon_url for the given channels. + + Expects channels to be pre-filtered with select_related('epg_data', 'logo'). + Uses bulk logo lookup/create and a single channel bulk_update for efficiency. + """ + from .models import Channel, Logo + + work = [] + url_to_meta = {} + + for channel in channels: + if not channel.epg_data: + continue + icon_url = (channel.epg_data.icon_url or '').strip() + if not icon_url: + continue + if channel.logo and channel.logo.url == icon_url: + continue + work.append((channel, icon_url)) + if icon_url not in url_to_meta: + url_to_meta[icon_url] = ( + channel.epg_data.name, + channel.epg_data.tvg_id, + ) + + if not work: + return _empty_logo_apply_stats() + + unique_urls = list(url_to_meta.keys()) + logo_by_url = { + logo.url: logo + for logo in Logo.objects.filter(url__in=unique_urls) + } + + missing_urls = [url for url in unique_urls if url not in logo_by_url] + created_logos_count = 0 + if missing_urls: + logos_to_create = [ + Logo( + name=(url_to_meta[url][0] or f"Logo for {url_to_meta[url][1]}"), + url=url, + ) + for url in missing_urls + ] + created_logos_count = len(logos_to_create) + Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True) + for logo in Logo.objects.filter(url__in=unique_urls): + logo_by_url[logo.url] = logo + + channels_to_update = [] + errors = [] + for channel, icon_url in work: + logo = logo_by_url.get(icon_url) + if not logo: + errors.append(f"Channel {channel.id}: Logo not found for {icon_url}") + continue + if channel.logo_id != logo.id: + channel.logo = logo + channels_to_update.append(channel) + + if channels_to_update: + Channel.objects.bulk_update(channels_to_update, ['logo'], batch_size=500) + + return { + 'updated_count': len(channels_to_update), + 'created_logos_count': created_logos_count, + 'error_count': len(errors), + 'errors': errors, + } + + +def channels_with_epg_icon_queryset(*, epg_source=None, epg_source_id=None): + """Channels mapped to a source that have a non-empty EPG icon URL.""" + from .models import Channel + + qs = Channel.objects.filter(epg_data__isnull=False) + if epg_source is not None: + qs = qs.filter(epg_data__epg_source=epg_source) + elif epg_source_id is not None: + qs = qs.filter(epg_data__epg_source_id=epg_source_id) + else: + raise ValueError("epg_source or epg_source_id is required") + + return qs.exclude( + epg_data__icon_url__isnull=True, + ).exclude( + epg_data__icon_url='', + ) + + +def apply_logos_from_epg_queryset(channels_qs, *, batch_size=EPG_LOGO_APPLY_BATCH_SIZE): + """ + Apply logos for a potentially large queryset without loading every row at once. + Streams channel IDs from the database and processes fixed-size chunks. + """ + from .models import Channel + + stats = _empty_logo_apply_stats() + batch_ids = [] + + id_stream = channels_qs.order_by('id').values_list('id', flat=True).iterator( + chunk_size=batch_size, + ) + for channel_id in id_stream: + batch_ids.append(channel_id) + if len(batch_ids) >= batch_size: + batch = Channel.objects.filter( + id__in=batch_ids, + ).select_related('epg_data', 'logo') + _merge_logo_apply_stats(stats, apply_logos_from_epg_icon_url(batch)) + batch_ids = [] + + if batch_ids: + batch = Channel.objects.filter( + id__in=batch_ids, + ).select_related('epg_data', 'logo') + _merge_logo_apply_stats(stats, apply_logos_from_epg_icon_url(batch)) + + return stats + + +def apply_logos_from_epg_for_source(epg_source, *, batch_size=EPG_LOGO_APPLY_BATCH_SIZE): + """Apply EPG icon URLs to all channels mapped to the given EPG source.""" + channels_qs = channels_with_epg_icon_queryset(epg_source=epg_source) + return apply_logos_from_epg_queryset(channels_qs, batch_size=batch_size) + + +def maybe_auto_apply_epg_logos(epg_source): + """Auto-apply logos after refresh when enabled on the source. Non-fatal on error.""" + if not auto_apply_epg_logos_enabled(epg_source.custom_properties): + return None + try: + stats = apply_logos_from_epg_for_source(epg_source) + if stats['updated_count'] or stats['created_logos_count']: + logger.info( + "Auto-applied EPG logos for source %s: updated %s channels, " + "created %s logos.", + epg_source.name, + stats['updated_count'], + stats['created_logos_count'], + ) + else: + logger.info( + "Auto-apply EPG logos for source %s: all matched channels already current.", + epg_source.name, + ) + return stats + except Exception as logo_error: + logger.warning( + "EPG logo auto-apply failed for source %s (non-fatal): %s", + epg_source.name, + logo_error, + exc_info=True, + ) + return None diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index a3e39fbe..55b7d9b0 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -1364,6 +1364,9 @@ def parse_channels_only(source): logger.info(f"Finished parsing channel info. Found {processed_channels} channels.") + from apps.channels.utils import maybe_auto_apply_epg_logos + maybe_auto_apply_epg_logos(source) + return True except FileNotFoundError: @@ -2295,39 +2298,8 @@ def fetch_schedules_direct(source, stations_only=False, force=False): elif fetch_posters: logger.info("Poster fetch enabled but all mapped programs already have artwork.") - auto_apply_sd_logos = (source.custom_properties or {}).get('auto_apply_sd_logos', False) - if auto_apply_sd_logos: - try: - from apps.channels.models import Channel as ChannelModel, Logo - - channels_to_update = [] - logos_created = 0 - for channel in ChannelModel.objects.filter( - epg_data__epg_source=source, - epg_data__isnull=False, - ).select_related('epg_data', 'logo'): - icon_url = (channel.epg_data.icon_url or '').strip() - if not icon_url: - continue - if channel.logo and channel.logo.url == icon_url: - continue - try: - logo = Logo.objects.get(url=icon_url) - except Logo.DoesNotExist: - logo_name = channel.epg_data.name or f"SD Logo {channel.epg_data.tvg_id}" - logo = Logo.objects.create(name=logo_name, url=icon_url) - logos_created += 1 - channel.logo = logo - channels_to_update.append(channel) - - if channels_to_update: - ChannelModel.objects.bulk_update(channels_to_update, ['logo'], batch_size=100) - logger.info(f"Applied SD logos to {len(channels_to_update)} channels " - f"({logos_created} new logos created).") - else: - logger.info("All matched channels already have current SD logos.") - except Exception as logo_error: - logger.warning(f"SD logo application failed (non-fatal): {logo_error}", exc_info=True) + from apps.channels.utils import maybe_auto_apply_epg_logos + maybe_auto_apply_epg_logos(source) today_utc = datetime(today.year, today.month, today.day, tzinfo=dt_timezone.utc) try: diff --git a/frontend/src/api.js b/frontend/src/api.js index 43f53040..8fd95fd9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1530,13 +1530,10 @@ export default class API { } static async getCurrentProgramForEpg(epgId) { - const response = await request( - `${host}/api/epg/current-programs/`, - { - method: 'POST', - body: { epg_data_ids: [epgId] }, - } - ); + const response = await request(`${host}/api/epg/current-programs/`, { + method: 'POST', + body: { epg_data_ids: [epgId] }, + }); if (response && response.length > 0) { if (response[0].parsing) { @@ -3879,7 +3876,7 @@ export default class API { } } - static async updateSDSettings(sourceId, settings) { + static async updateEpgSourceSettings(sourceId, settings) { try { // Read current custom_properties from the store to merge, not replace const epgs = useEPGsStore.getState().epgs; @@ -3894,10 +3891,14 @@ export default class API { useEPGsStore.getState().updateEPG(response); return response; } catch (e) { - errorNotification('Failed to update Schedules Direct settings', e); + errorNotification('Failed to update EPG source settings', e); } } + static async updateSDSettings(sourceId, settings) { + return API.updateEpgSourceSettings(sourceId, settings); + } + static async searchSDLineups(sourceId, country, postalcode) { try { const response = await request( diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index 10699566..f7e6ec92 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -102,16 +102,59 @@ const SD_POSTER_STYLES = [ }, ]; -// ─── SD Settings: Logo toggle + style selector + Poster toggle ────────────── +const autoApplyEpgLogosEnabled = (customProperties) => + !!(customProperties || {}).auto_apply_epg_logos; + +// ─── Auto-apply EPG logos (XMLTV + SD) ───────────────────────────────────── +const AutoApplyEpgLogosSwitch = ({ + sourceId, + customProperties, + description, +}) => { + const storeCustomProps = useEPGsStore((s) => + sourceId ? s.epgs[sourceId]?.custom_properties : null + ); + const resolvedCp = storeCustomProps || customProperties || {}; + + const [enabled, setEnabled] = useState(autoApplyEpgLogosEnabled(resolvedCp)); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const newCp = storeCustomProps || customProperties || {}; + setEnabled(autoApplyEpgLogosEnabled(newCp)); + }, [storeCustomProps, customProperties]); + + const handleToggle = async (checked) => { + setEnabled(checked); + setSaving(true); + try { + await API.updateEpgSourceSettings(sourceId, { + auto_apply_epg_logos: checked, + }); + } finally { + setSaving(false); + } + }; + + return ( + handleToggle(e.currentTarget.checked)} + disabled={saving} + size="sm" + /> + ); +}; + +// ─── SD Settings: Logo style selector + Poster toggle ─────────────────────── const SDSettings = ({ sourceId, customProperties }) => { const storeCustomProps = useEPGsStore((s) => sourceId ? s.epgs[sourceId]?.custom_properties : null ); const resolvedCp = storeCustomProps || customProperties || {}; - const [autoApplySDLogos, setAutoApplySDLogos] = useState( - !!resolvedCp.auto_apply_sd_logos - ); const [logoStyle, setLogoStyle] = useState(resolvedCp.logo_style || 'dark'); const [fetchPosters, setFetchPosters] = useState(!!resolvedCp.fetch_posters); const [posterStyle, setPosterStyle] = useState( @@ -122,7 +165,6 @@ const SDSettings = ({ sourceId, customProperties }) => { // Sync from store (preferred) or parent props when the form opens or settings save useEffect(() => { const newCp = storeCustomProps || customProperties || {}; - setAutoApplySDLogos(!!newCp.auto_apply_sd_logos); setLogoStyle(newCp.logo_style || 'dark'); setFetchPosters(!!newCp.fetch_posters); setPosterStyle(newCp.poster_style || 'sd_recommended'); @@ -131,17 +173,12 @@ const SDSettings = ({ sourceId, customProperties }) => { const saveSetting = async (key, value) => { setSaving(true); try { - await API.updateSDSettings(sourceId, { [key]: value }); + await API.updateEpgSourceSettings(sourceId, { [key]: value }); } finally { setSaving(false); } }; - const handleAutoApplyToggle = (checked) => { - setAutoApplySDLogos(checked); - saveSetting('auto_apply_sd_logos', checked); - }; - const handleLogoChange = (style) => { setLogoStyle(style); saveSetting('logo_style', style); @@ -213,14 +250,10 @@ const SDSettings = ({ sourceId, customProperties }) => { ))} - handleAutoApplyToggle(e.currentTarget.checked)} - disabled={saving} - size="sm" - mb="sm" /> @@ -902,6 +935,14 @@ const EPG = ({ epg = null, isOpen, onClose }) => { key={form.key('priority')} /> + {sourceType === 'xmltv' && savedEpgId && ( + + )} + Status