diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a5f6a0..9c0b1729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index b268ef65..fbc419c5 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -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.") diff --git a/apps/epg/tests/test_epg_import_api.py b/apps/epg/tests/test_epg_import_api.py new file mode 100644 index 00000000..c58f2a45 --- /dev/null +++ b/apps/epg/tests/test_epg_import_api.py @@ -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)