enhancement(epg): Performance improvements and API enhancements for EPG refreshes.

- Updated EPG import logic to reject dummy sources without loading the large `programme_index`, optimizing memory usage during API calls.
This commit is contained in:
SergeantPanda 2026-06-18 15:53:55 -05:00
parent 460677aeea
commit c2ac08fdfd
3 changed files with 114 additions and 8 deletions

View file

@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Channel list with nested streams loads faster.** `GET /api/channels/channels/?include_streams=true` (Channels UI and single-channel fetch) now builds nested stream payloads from the prefetched `channelstream_set` instead of issuing one extra streams M2M query per channel.
### Fixed
- **M3U refresh recovers DB connections and account status after failures.** Celery workers now call `close_old_connections()` before and after every task; `refresh_single_m3u_account` also resets its DB connection at task start and retries account loads once after a connection reset (avoids opaque `list index out of range` errors from poisoned worker connections). Accounts leave `fetching`/`parsing` for a terminal `error` or `success` state when refresh aborts. Error-path status updates use `QuerySet.update()` on a clean connection instead of `save()` on a connection that may already be INTRANS after `refresh_m3u_groups` or batch ORM failures. Failed group downloads now set `error` instead of returning silently. Refresh start replaces stale `last_message` text. `refresh_account_profiles` releases DB connections after each profile failure. (Fixes #1338)
- **M3U `custom_properties` JSON normalization.** Legacy rows and some API writes could store a JSON-encoded string instead of an object in `custom_properties`, causing refresh, profile sync, and auto-sync to fail with `'str' object has no attribute 'get'`. M3U account, profile, and group-relation models normalize on `save()`; group settings bulk writes and read/merge paths use shared helpers in `core.utils` without re-parsing dict values.
- **`POST /api/epg/import/` no longer loads `programme_index`.** The dummy-source guard uses a narrow existence query instead of `objects.get()`, so import triggers avoid pulling the multi-MB byte-offset index into the uWSGI worker. Request/response shape is unchanged for the UI, plugins, and external importers.
- **XC live playback URL building now normalizes account server URLs.** On-demand live URLs (introduced for Server Group profile rotation in 0.27.0) rebuild `/live/{user}/{pass}/{stream_id}.ts` from current credentials instead of reusing the synced `stream.url`. That path now runs `normalize_server_url()` so pasted API URLs (e.g. `/player_api.php` with query params) are stripped while sub-paths like `/server1` are preserved. `get_transformed_credentials()` normalizes the base URL at the source; VOD movie and episode relations use the same helper instead of constructing a throwaway `XCClient` (which also avoided per-request HTTP session setup). (Fixes #1363)
- **Live proxy now releases geventpool DB connections on more paths.** `stream_ts()` calls `close_old_connections()` before `StreamingHttpResponse` so clients waiting on channel init do not keep a pool slot while the generator polls Redis. `generate_stream_url()`, `get_stream_info_for_switch()`, `get_alternate_streams()`, and `get_connections_left()` release in `finally` blocks after ORM work on stream-manager failover paths that run outside Django's request cycle. TS client disconnect cleanup matches fMP4, and `ChannelService` metadata helpers release after their ORM lookups.

View file

@ -1119,18 +1119,24 @@ class EPGImportAPIView(APIView):
epg_id = request.data.get("id", None)
force = bool(request.data.get("force", False))
# Check if this is a dummy EPG source
try:
# Reject dummy sources without loading programme_index (multi-MB JSON).
if epg_id is not None:
from .models import EPGSource
epg_source = EPGSource.objects.get(id=epg_id)
if epg_source.source_type == 'dummy':
logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}")
if EPGSource.objects.filter(
id=epg_id, source_type="dummy"
).exists():
logger.info(
"EPGImportAPIView: Skipping refresh for dummy EPG source %s",
epg_id,
)
return Response(
{"success": False, "message": "Dummy EPG sources do not require refreshing."},
{
"success": False,
"message": "Dummy EPG sources do not require refreshing.",
},
status=status.HTTP_400_BAD_REQUEST,
)
except EPGSource.DoesNotExist:
pass # Let the task handle the missing source
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")

View file

@ -0,0 +1,95 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APIClient
from apps.epg.models import EPGSource
User = get_user_model()
IMPORT_URL = "/api/epg/import/"
class EPGImportAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="epg_import_admin", password="testpass123"
)
self.user.user_level = 10
self.user.save()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
@patch("apps.epg.api_views.refresh_epg_data.delay")
def test_import_dummy_source_rejected_without_dispatch(self, mock_delay):
source = EPGSource.objects.create(
name="Dummy EPG",
source_type="dummy",
)
response = self.client.post(
IMPORT_URL, {"id": source.id}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(response.data["success"])
mock_delay.assert_not_called()
@patch("apps.epg.api_views.refresh_epg_data.delay")
def test_import_xmltv_dispatches_without_loading_programme_index(
self, mock_delay
):
source = EPGSource.objects.create(
name="Large Index XMLTV",
source_type="xmltv",
url="http://example.com/epg.xml",
programme_index={
"channels": {f"ch.{i}": {"offsets": [0, 100]} for i in range(200)},
"interleaved_channels": [],
},
)
mock_delay.reset_mock()
with CaptureQueriesContext(connection) as ctx:
response = self.client.post(
IMPORT_URL, {"id": source.id}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertTrue(response.data["success"])
mock_delay.assert_called_once_with(source.id, force=False)
for query in ctx.captured_queries:
self.assertNotIn(
"programme_index",
query["sql"].lower(),
"import trigger should not read programme_index",
)
@patch("apps.epg.api_views.refresh_epg_data.delay")
def test_import_missing_source_still_dispatches(self, mock_delay):
response = self.client.post(IMPORT_URL, {"id": 99999}, format="json")
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
mock_delay.assert_called_once_with(99999, force=False)
@patch("apps.epg.api_views.refresh_epg_data.delay")
def test_import_honours_force_flag(self, mock_delay):
source = EPGSource.objects.create(
name="Force XMLTV",
source_type="xmltv",
url="http://example.com/epg.xml",
)
mock_delay.reset_mock()
response = self.client.post(
IMPORT_URL,
{"id": source.id, "force": True},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
mock_delay.assert_called_once_with(source.id, force=True)