mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 17:16:26 +00:00
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.
227 lines
7.4 KiB
Python
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
|