diff --git a/CHANGELOG.md b/CHANGELOG.md index a66fbe4c..f555668f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **XC `get_live_streams` and `/panel_api.php` no longer crash when a visible channel has no channel number.** Since `channel_number` became nullable, auto-sync and compact numbering can leave a channel with `effective_channel_number=None` while it remains visible in output (`hidden_from_output=False`). The XC live-stream number map skipped those rows but still tried to emit them, causing `KeyError` on `get_live_streams` and a 500 on `/panel_api.php` (`xc_get_info`). Null-number channels are now deferred into the existing collision-free integer assignment pass (same second loop as fractional numbers) and receive the next available `num`; `_xc_channel_entry` also falls back to `channel.id` if a row is still unmapped. HDHR lineup behaviour is unchanged (null-number channels remain omitted there). +- **XC live streams, `/panel_api.php`, and `/xmltv.php` no longer crash when a visible channel has no channel number.** Since `channel_number` became nullable, auto-sync and compact numbering can leave a channel with `effective_channel_number=None` while it remains visible in output (`hidden_from_output=False`). The XC live-stream number map skipped those rows but still tried to emit them, causing `KeyError` on `get_live_streams` and a 500 on `/panel_api.php` (`xc_get_info`). Authenticated XMLTV export (`/xmltv.php`, profile EPG with a logged-in user) had the same gap and could fail chunk-cache rebuilds with `KeyError` on `channel_num_map[channel.id]`. Null-number channels are now deferred into the existing collision-free integer assignment pass (same second loop as fractional numbers) and receive the next available integer; `_xc_channel_entry` also falls back to `channel.id` if a row is still unmapped. HDHR lineup behaviour is unchanged (null-number channels remain omitted there). - **System events and Connect dispatch no longer close the DB connection mid-call outside worker contexts.** `log_system_event()` and `dispatch_event_system()` always called `close_old_connections()` in `finally` blocks, which broke Django `TestCase` transactions and could interrupt in-flight ORM work when Connect/plugin handlers ran synchronously. Closes are now limited to gevent uWSGI workers (and the Celery sync-dispatch path where gevent is patched but no hub runs). Celery workers still reset connections via existing `task_postrun` / `task_prerun` hooks on the standard PostgreSQL backend (no geventpool). - **Connect `trigger_event` no longer releases DB connections during plugin discovery.** Event dispatch calls `discover_plugins(use_cache=True)` to resolve plugin handlers; its unconditional `close_old_connections()` could tear down the caller's connection before `log_system_event()` or Schedules Direct refresh finished. Discovery from event dispatch now passes `release_connections=False`; boot-time discovery (`worker_process_init`, `post_migrate`, admin reload) still releases connections by default. - **EPG Celery tasks no longer reset the DB connection when invoked outside a worker.** `_release_task_db_connection()` is gated on `_is_celery_worker_context()` so synchronous test runs and in-process task calls do not close the test database connection; active Celery tasks still reset poisoned connections after transient ORM errors. diff --git a/apps/output/epg.py b/apps/output/epg.py index d34d209e..46c2b922 100644 --- a/apps/output/epg.py +++ b/apps/output/epg.py @@ -1192,26 +1192,26 @@ def generate_epg(request, profile_name=None, user=None, *, xc_catchup_prev_days= # XC clients require integer channel numbers, so we need to ensure no conflicts channel_num_map = {} if user is not None: - # This is an XC client - build collision-free mapping used_numbers = set() + deferred_channels = [] - # First pass: assign integers for channels that already have integer numbers for channel in channels: effective_num = channel.effective_channel_number - if effective_num is not None and effective_num == int(effective_num): + if effective_num is None: + deferred_channels.append((channel.id, None)) + elif effective_num == int(effective_num): num = int(effective_num) channel_num_map[channel.id] = num used_numbers.add(num) + else: + deferred_channels.append((channel.id, effective_num)) - # Second pass: assign integers for channels with float numbers - for channel in channels: - effective_num = channel.effective_channel_number - if effective_num is not None and effective_num != int(effective_num): - candidate = int(effective_num) - while candidate in used_numbers: - candidate += 1 - channel_num_map[channel.id] = candidate - used_numbers.add(candidate) + for channel_id, effective_num in deferred_channels: + candidate = 1 if effective_num is None else int(effective_num) + while candidate in used_numbers: + candidate += 1 + channel_num_map[channel_id] = candidate + used_numbers.add(candidate) # Host/port/scheme are constant per request; precompute logo URL prefix once. _base_url = build_absolute_uri_with_port(request, "") diff --git a/apps/output/tests/test_views.py b/apps/output/tests/test_views.py index 2968cdd3..6543a0ae 100644 --- a/apps/output/tests/test_views.py +++ b/apps/output/tests/test_views.py @@ -937,6 +937,53 @@ class XcLiveStreamsNullChannelNumberTests(TestCase): self.assertNotIn(by_id[unnumbered.id]["num"], {5}) +class XcXmltvNullChannelNumberTests(OutputEndpointTestMixin, TestCase): + """XC XMLTV EPG must not crash when a visible channel has no channel number.""" + + def setUp(self): + super().setUp() + self.client = Client() + self.user = User.objects.create_user( + username=f"xc-epg-{uuid4().hex[:8]}", + password="pass", + user_level=10, + custom_properties={"xc_password": "xcpass"}, + ) + self.group = ChannelGroup.objects.create(name=f"Group {uuid4().hex[:8]}") + + def test_xmltv_epg_assigns_number_for_null_channel_number(self): + Channel.objects.create( + name="Numbered", + channel_number=5, + channel_group=self.group, + user_level=0, + ) + Channel.objects.create( + name="Unnumbered", + channel_number=None, + channel_group=self.group, + user_level=0, + hidden_from_output=False, + ) + + response = self.client.get( + reverse("xc_xmltv"), + { + "username": self.user.username, + "password": "xcpass", + "tvg_id_source": "channel_number", + }, + ) + + self.assertEqual(response.status_code, 200) + content = _response_text(response) + try: + ET.fromstring(content) + except ET.ParseError as exc: + self.fail(f"Generated XMLTV EPG is not valid XML: {exc}") + self.assertIn("Unnumbered", content) + + class GenerateEpgPrevDaysTests(SimpleTestCase): """Profile EPG keeps legacy prev_days=0 unless URL or user setting says otherwise."""