diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index bf00d966..244545bf 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -52,6 +52,12 @@ def _build_html_entity_doctype() -> bytes: _HTML_ENTITY_DOCTYPE = _build_html_entity_doctype() +def _parse_programme_element(element_bytes): + """Parse a single element, prepending the HTML-entity DOCTYPE so references like é in the text resolve instead of failing.""" + parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=True) + return etree.fromstring(_HTML_ENTITY_DOCTYPE + element_bytes, parser) + + class _PrependStream: """Wraps an open binary file and prepends a bytes prefix to its content. @@ -2360,7 +2366,7 @@ def _resolve_source_file(epg_source): return file_path -_CHANNEL_ATTR_RE = re.compile(rb"""channel=(?:"([^"]+)"|'([^']+)')""") +_CHANNEL_ATTR_RE = re.compile(rb"""channel\s*=\s*(?:"([^"]+)"|'([^']+)')""") _PROGRAMME_TAG = b'/' @@ -2654,7 +2660,7 @@ def _read_programs_at_offsets(file_path, tvg_id, offsets, now): search_from = close_end try: - prog = etree.fromstring(element_bytes) + prog = _parse_programme_element(element_bytes) except etree.XMLSyntaxError: continue @@ -2747,7 +2753,7 @@ def _scan_from_offset_for_tvg_id(file_path, tvg_id, start_offset, now, timeout_s search_from = close_end try: - prog = etree.fromstring(element_bytes) + prog = _parse_programme_element(element_bytes) except etree.XMLSyntaxError: continue diff --git a/apps/epg/tests/test_programme_index.py b/apps/epg/tests/test_programme_index.py index e25ec664..7ddce9e0 100644 --- a/apps/epg/tests/test_programme_index.py +++ b/apps/epg/tests/test_programme_index.py @@ -202,6 +202,39 @@ class FindCurrentProgramTests(TestCase): finally: os.unlink(tmp_path) + def test_offset_lookup_resolves_named_html_entities_in_programme_text(self): + xml = ( + '\n' + "\n" + ' \n' + ' \n' + " Café Live\n" + " \n" + "\n" + ) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".xml", delete=False + ) as f: + f.write(xml) + tmp_path = f.name + + try: + src = EPGSource.objects.create( + name="Named Entities", source_type="xmltv", file_path=tmp_path + ) + build_programme_index(src.id) + + epg = EPGData.objects.create( + tvg_id="entity.channel", name="Entity Channel", epg_source=src + ) + result = find_current_program_for_tvg_id(epg) + + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Caf\u00e9 Live") + finally: + os.unlink(tmp_path) + @patch("apps.epg.tasks.build_programme_index_task") def test_no_index_dispatches_build_and_returns_timeout(self, mock_build_task): # Source with no index and file on disk @@ -243,6 +276,36 @@ class BuildProgrammeIndexTests(TestCase): # Small fixture has no interleaved channels self.assertEqual(index["interleaved_channels"], []) + def test_builds_index_when_channel_attribute_has_valid_xml_spacing(self): + xml = ( + '\n' + "\n" + ' \n' + ' \n' + " Spaced Attribute Current\n" + " \n" + "\n" + ) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".xml", delete=False + ) as f: + f.write(xml) + tmp_path = f.name + + try: + src = EPGSource.objects.create( + name="Spaced Attribute", source_type="xmltv", file_path=tmp_path + ) + build_programme_index(src.id) + src.refresh_from_db() + + self.assertIn( + "spaced.channel", src.programme_index["channels"] + ) + finally: + os.unlink(tmp_path) + def test_nonexistent_source_does_not_raise(self): # Should log error but not raise build_programme_index(99999) diff --git a/core/tasks.py b/core/tasks.py index cbbae557..e9d79d37 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -399,7 +399,6 @@ def _rebuild_programme_indices(): """Queue index builds for active EPG sources that are missing their DB index.""" try: from apps.epg.tasks import build_programme_index_task - redis_client = RedisClient.get_client() sources = EPGSource.objects.filter( is_active=True, @@ -408,10 +407,9 @@ def _rebuild_programme_indices(): count = 0 for source in sources: - lock_key = f"building_programme_index_{source.id}" - if redis_client.set(lock_key, "1", nx=True, ex=300): - build_programme_index_task.delay(source.id) - count += 1 + # The task acquires its own build lock; taking it here would make the task no-op. + build_programme_index_task.delay(source.id) + count += 1 if count: logger.info(f"Queued programme index rebuild for {count} EPG source(s)") diff --git a/core/tests.py b/core/tests.py index 9c758e76..e7f45c5a 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2,9 +2,58 @@ from unittest.mock import patch, MagicMock from django.test import TestCase +from apps.epg.models import EPGSource from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY +class ProgrammeIndexRebuildTests(TestCase): + def test_startup_rebuild_does_not_lock_out_queued_build_task(self): + EPGSource.objects.update( + programme_index={"channels": {}, "interleaved_channels": []} + ) + source = EPGSource.objects.create( + name="Missing Index", + source_type="xmltv", + is_active=True, + programme_index=None, + ) + + class FakeRedis: + def __init__(self): + self.keys = set() + + def set(self, key, value, nx=False, ex=None): + if nx and key in self.keys: + return False + self.keys.add(key) + return True + + def delete(self, key): + self.keys.discard(key) + + fake_redis = FakeRedis() + + from apps.epg.tasks import build_programme_index_task + from core.tasks import _rebuild_programme_indices + + def run_task_immediately(source_id): + build_programme_index_task(source_id) + + with patch( + "core.tasks.RedisClient.get_client", return_value=fake_redis + ), patch( + "core.utils.RedisClient.get_client", return_value=fake_redis + ), patch( + "apps.epg.tasks.build_programme_index" + ) as mock_build, patch( + "apps.epg.tasks.build_programme_index_task.delay", + side_effect=run_task_immediately, + ): + _rebuild_programme_indices() + + mock_build.assert_called_once_with(source.id) + + class GetDvrSeriesRulesTest(TestCase): """Verify get_dvr_series_rules handles corrupted stored data."""