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