mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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:
parent
460677aeea
commit
c2ac08fdfd
3 changed files with 114 additions and 8 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
95
apps/epg/tests/test_epg_import_api.py
Normal file
95
apps/epg/tests/test_epg_import_api.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue