fix(epg): prevent crashes for channels with null channel numbers in XMLTV export
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Backend Tests / (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled

Updated the EPG generation logic to ensure that channels without a channel number do not cause crashes in the XMLTV export. Implemented a deferred assignment mechanism for channels with null numbers, allowing them to receive valid integers during processing. Added tests to verify that the XMLTV output remains stable and valid even when channels are unnumbered.
This commit is contained in:
SergeantPanda 2026-07-10 02:42:50 +00:00
parent 5c5b866bcf
commit 3c2c42ce66
3 changed files with 60 additions and 13 deletions

View file

@ -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.

View file

@ -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, "")

View file

@ -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."""