diff --git a/CHANGELOG.md b/CHANGELOG.md index 47127cd3..8b45dfa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Performance: `generate_m3u`, `generate_epg`, and `xc_get_live_streams` now use `select_related('channel_group', 'logo')` (or `select_related('logo')` for EPG) on every Channel queryset in `apps/output/views.py`. Previously each channel in the loop triggered a separate database query for its `logo` and `channel_group` foreign keys; with the JOIN-based prefetch this is reduced to a single query per request. On deployments with ~2 000 channels, `xc_get_live_streams` response time drops from ~2.5–4 s to ~250–450 ms. (Closes #1127) — Thanks [@xBOBxSAGETx](https://github.com/xBOBxSAGETx) - Performance: `generate_epg` now uses `select_related('epg_data__epg_source')` on all EPG channel querysets, eliminating N+1 database queries for `EPGSource` traversal per channel (~15 s improvement on ~2000-channel deployments; total EPG generation time dropped from ~87 s to ~72 s in benchmarks). +- Performance: `xc_get_epg` now uses `select_related('epg_data__epg_source')` on all three channel fetch paths. Previously each request triggered 2 additional queries to resolve `channel.epg_data` and `channel.epg_data.epg_source`. +- Performance: `generate_m3u` now uses `prefetch_related` for streams when `?direct=true` is requested, eliminating N+1 stream queries (one per channel) on that code path. +- Performance: `EPGGridAPIView` (`apps/epg/api_views.py`) now uses `select_related('epg_data__epg_source')` on the `channels_with_custom_dummy` queryset, eliminating 2 extra queries per channel (for `epg_data` and `epg_source`) in the dummy EPG generation loop. - Performance: `generate_epg` now issues a single cross-channel `ProgramData` bulk query. `.values()` returns plain dicts, bypassing per-row Django model instantiation. Results are consumed in independent 5000-row keyset-paginated chunks. Combined with the `select_related` improvements above, EPG generation time on large deployments is significantly reduced. - Performance: `xc_get_live_streams` no longer calls `ChannelGroup.objects.get_or_create(name="Default Group")` once per null-group channel; replaced with a lazy-initialised closure that executes at most one query regardless of how many ungrouped channels are present. - AIO containers now connect to the internal PostgreSQL instance via a Unix domain socket instead of TCP loopback. Users who have `POSTGRES_HOST` explicitly set to `localhost` or `127.0.0.1` in their compose file are automatically migrated to the socket path; any other explicit value (external host/IP) is left untouched. — Thanks [@JCBird1012](https://github.com/JCBird1012) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 1a53f5ae..33483e60 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -175,7 +175,7 @@ class EPGGridAPIView(APIView): # Get channels with custom dummy EPG sources (generate on-demand with patterns) channels_with_custom_dummy = Channel.objects.filter( epg_data__epg_source__source_type='dummy' - ).distinct() + ).select_related('epg_data__epg_source').distinct() # Log what we found without_count = channels_without_epg.count() diff --git a/apps/output/views.py b/apps/output/views.py index d09ad200..5b061f1f 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1,8 +1,8 @@ -import ipaddress from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse from rest_framework.response import Response from django.urls import reverse -from apps.channels.models import Channel, ChannelProfile, ChannelGroup +from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream +from django.db.models import Prefetch from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from apps.epg.models import ProgramData @@ -11,9 +11,8 @@ from dispatcharr.utils import network_access_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 from datetime import datetime, timedelta -import html # Add this import for XML escaping -import json # Add this import for JSON parsing -import time # Add this import for keep-alive delays +import html +import time from tzlocal import get_localzone from urllib.parse import urlparse import base64 @@ -175,6 +174,12 @@ def generate_m3u(request, profile_name=None, user=None): # Check if direct stream URLs should be used instead of proxy use_direct_urls = request.GET.get('direct', 'false').lower() == 'true' + # Prefetch streams only when direct URLs are requested (avoids N+1 per channel) + if use_direct_urls: + channels = channels.prefetch_related( + Prefetch('streams', queryset=Stream.objects.order_by('channelstream__order')) + ) + # Get the source to use for tvg-id value # Options: 'channel_number' (default), 'tvg_id', 'gracenote' tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() @@ -262,7 +267,8 @@ def generate_m3u(request, profile_name=None, user=None): stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}" elif use_direct_urls: # Try to get the first stream's direct URL - first_stream = channel.streams.order_by('channelstream__order').first() + all_streams = channel.streams.all() + first_stream = all_streams[0] if all_streams else None if first_stream and first_stream.url: # Use the direct stream URL stream_url = first_stream.url @@ -2253,7 +2259,7 @@ def xc_get_epg(request, user, short=False): # Hide adult content if user preference is set if (user.custom_properties or {}).get('hide_adult_content', False): filters["is_adult"] = False - channel = Channel.objects.filter(**filters).first() + channel = Channel.objects.filter(**filters).select_related('epg_data__epg_source').first() else: # User has specific limited profiles assigned filters = { @@ -2265,12 +2271,12 @@ def xc_get_epg(request, user, short=False): # Hide adult content if user preference is set if (user.custom_properties or {}).get('hide_adult_content', False): filters["is_adult"] = False - channel = Channel.objects.filter(**filters).distinct().first() + channel = Channel.objects.filter(**filters).select_related('epg_data__epg_source').distinct().first() if not channel: raise Http404() else: - channel = get_object_or_404(Channel, id=channel_id) + channel = get_object_or_404(Channel.objects.select_related('epg_data__epg_source'), id=channel_id) if not channel: raise Http404()