Dispatcharr/apps/channels/utils.py
SergeantPanda 388c4bd28d
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
Enhancement: update catch-up features and enhance stream ordering logic
- Improved multi-provider failover by preferring streams with sufficient catch-up days.
- Added new helper functions for calculating programme age and ordering catch-up streams.
- Removed deprecated XMLTV settings from the frontend and backend.
- Updated tests to validate new stream ordering logic and catch-up functionality.
2026-07-16 15:33:34 +00:00

308 lines
10 KiB
Python

import logging
import threading
from django.core.cache import cache
logger = logging.getLogger(__name__)
PROVIDER_ARCHIVE_CACHE_TTL_SECONDS = 300
MAX_AUTO_PREV_DAYS = 30
# 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 compute_provider_archive_days_capped():
"""Max ``catchup_days`` across active XC catch-up streams (capped, cached).
Cached briefly so XC XMLTV exports without an explicit ``prev_days`` do not
repeat the aggregate query on every request.
"""
def _scan():
from django.db.models import Max
from apps.channels.models import Stream
result = Stream.objects.filter(
m3u_account__account_type="XC",
m3u_account__is_active=True,
is_catchup=True,
).aggregate(max_days=Max("catchup_days"))
return min(result["max_days"] or 0, MAX_AUTO_PREV_DAYS)
return cache.get_or_set(
"channels:provider_archive_days_capped",
_scan,
PROVIDER_ARCHIVE_CACHE_TTL_SECONDS,
)
def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
"""Resolve ``prev_days`` for XC XMLTV and player_api EPG.
Args:
request: HTTP request (reads ``?prev_days=``).
user: Authenticated user (reads ``custom_properties.epg_prev_days``).
auto_detect_fallback: When True (XC XMLTV), fall back to the largest
provider archive depth. When False (per-channel EPG), return 0 so
``xc_get_epg`` can expand to each channel's ``catchup_days``.
Resolution order:
1. URL ``?prev_days=`` (explicit; 0 means no past programmes)
2. ``user.custom_properties.epg_prev_days``
3. Auto-detect (only when *auto_detect_fallback* is True)
"""
user_custom = (user.custom_properties or {}) if user else {}
url_prev = request.GET.get("prev_days")
user_prev = user_custom.get("epg_prev_days") if user_custom else None
if url_prev is not None:
try:
return max(0, min(int(url_prev), MAX_AUTO_PREV_DAYS))
except (ValueError, TypeError):
return 0
if user_prev not in (None, ""):
try:
return max(0, min(int(user_prev), MAX_AUTO_PREV_DAYS))
except (ValueError, TypeError):
return 0
if auto_detect_fallback:
return compute_provider_archive_days_capped()
return 0
def get_channel_catchup_streams(channel):
"""Active catch-up streams for a channel, in ``channelstream`` order.
Inactive M3U accounts are excluded, matching live dispatch.
"""
if not getattr(channel, "is_catchup", False):
return []
return list(
channel.streams.filter(is_catchup=True, m3u_account__is_active=True)
.order_by("channelstream__order")
.select_related("m3u_account")
)
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