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.
This commit is contained in:
SergeantPanda 2026-06-05 12:49:05 -05:00
parent d4c1622463
commit 70d900f346
8 changed files with 680 additions and 144 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<Switch
label="Auto-Apply EPG Logos to Channels"
description={description}
checked={enabled}
onChange={(e) => 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 }) => {
))}
</Group>
<Switch
label="Auto-Apply SD Logos to Channels"
<AutoApplyEpgLogosSwitch
sourceId={sourceId}
customProperties={customProperties}
description="When enabled, matched channels are updated to use the SD logo on each refresh. When disabled, logos are still fetched into EPG data and can be applied manually via Set Logo from EPG."
checked={autoApplySDLogos}
onChange={(e) => handleAutoApplyToggle(e.currentTarget.checked)}
disabled={saving}
size="sm"
mb="sm"
/>
<Divider my="sm" />
@ -902,6 +935,14 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
key={form.key('priority')}
/>
{sourceType === 'xmltv' && savedEpgId && (
<AutoApplyEpgLogosSwitch
sourceId={savedEpgId}
customProperties={sdCustomProps}
description="When enabled, matched channels are updated from EPG channel icons on each refresh. When disabled, icons are still stored in EPG data and can be applied manually via Set Logo from EPG."
/>
)}
<Box style={{ marginTop: 0 }}>
<Text size="sm" fw={500} mb={3}>
Status