Dispatcharr/apps/channels/utils.py
SergeantPanda 70d900f346 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.
2026-06-05 12:49:05 -05:00

227 lines
7.4 KiB
Python

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 = {}
def format_channel_number(value, empty=""):
"""Display formatting for an effective channel_number. Returns int for
whole-valued floats (so ``123.0`` renders as ``123``), the float as-is
for fractional values, or ``empty`` when the value is ``None``.
"""
if value is None:
return empty
if value == int(value):
return int(value)
return value
def increment_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
current_usage += 1
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def decrement_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
if current_usage > 0:
current_usage -= 1
if current_usage == 0:
del active_streams_map[account.id]
else:
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