mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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:
parent
d4c1622463
commit
70d900f346
8 changed files with 680 additions and 144 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
272
apps/channels/tests/test_epg_logo_apply.py
Normal file
272
apps/channels/tests/test_epg_logo_apply.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue