diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 513c84e5..31cfeaa6 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -777,7 +777,10 @@ class CurrentProgramsAPIView(APIView): # Limit to 50 IDs per request epg_data_ids = epg_data_ids[:50] - epg_data_entries = EPGData.objects.select_related('epg_source').filter(id__in=epg_data_ids) + # Defer the multi-MB programme_index the JOIN would pull once per row. The lookup reads it via a targeted refresh_from_db + epg_data_entries = EPGData.objects.select_related('epg_source').defer( + 'epg_source__programme_index' + ).filter(id__in=epg_data_ids) # Batch-fetch current programs for all requested EPG entries in one query db_programs = ProgramData.objects.filter( diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 13f3bc5d..bf00d966 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -318,8 +318,8 @@ def refresh_epg_data(source_id): gc.collect() return - # Build byte-offset index for preview lookups (runs before full program parse) - build_programme_index(source.id) + # Build byte-offset index for preview lookups in the background so refresh isn't blocked by it + build_programme_index_task.delay(source.id) parse_programs_for_source(source) @@ -2368,6 +2368,14 @@ _MAX_START_TAG = 4096 # generous upper bound for a start tag with namespaces/ex _OFFSET_CAP = 10 # max block-starts recorded per channel; exceeding this flags the channel as interleaved +def _decode_channel_id(raw): + """Match how EPGData.tvg_id is stored: resolve XML entities and strip, so byte-level index keys equal the lxml-parsed channel ids.""" + s = raw.decode('utf-8', errors='replace') + if '&' in s: + s = html.unescape(s) + return s.strip() + + def _find_programme_tag(buf, start): """ Find the next \n' + "\n" + ' \n' + ' \n' + " A and E Now\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="Entities", source_type="xmltv", file_path=tmp_path + ) + build_programme_index(src.id) + src.refresh_from_db() + self.assertIn("A&E.us", src.programme_index["channels"]) + + epg = EPGData.objects.create( + tvg_id="A&E.us", name="A&E", epg_source=src + ) + result = find_current_program_for_tvg_id(epg) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "A and E Now") + 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 @@ -176,44 +217,10 @@ class FindCurrentProgramTests(TestCase): epg_source=src, ) - mock_redis = MagicMock() - mock_redis.set.return_value = True - with patch( - "core.utils.RedisClient.get_client", - return_value=mock_redis, - ): - result = find_current_program_for_tvg_id(epg) + result = find_current_program_for_tvg_id(epg) self.assertEqual(result, "timeout") mock_build_task.delay.assert_called_once_with(src.id) - mock_redis.set.assert_called_once() - lock_key = mock_redis.set.call_args.args[0] - self.assertEqual(lock_key, f"building_programme_index_{src.id}") - - @patch("apps.epg.tasks.build_programme_index_task") - def test_no_index_skips_dispatch_when_lock_held(self, mock_build_task): - src = EPGSource.objects.create( - name="Lock Held", - source_type="xmltv", - file_path=FIXTURE_XML, - programme_index=None, - ) - epg = EPGData.objects.create( - tvg_id="channel.current", - name="Current", - epg_source=src, - ) - - mock_redis = MagicMock() - mock_redis.set.return_value = False # someone else already building - with patch( - "core.utils.RedisClient.get_client", - return_value=mock_redis, - ): - result = find_current_program_for_tvg_id(epg) - - self.assertEqual(result, "timeout") - mock_build_task.delay.assert_not_called() class BuildProgrammeIndexTests(TestCase): @@ -240,6 +247,40 @@ class BuildProgrammeIndexTests(TestCase): # Should log error but not raise build_programme_index(99999) + @patch("apps.epg.tasks.build_programme_index") + def test_task_builds_and_releases_lock_when_free(self, mock_build): + mock_redis = MagicMock() + mock_redis.set.return_value = True # lock acquired + with patch("core.utils.RedisClient.get_client", return_value=mock_redis): + build_programme_index_task(42) + + mock_build.assert_called_once_with(42) + mock_redis.set.assert_called_once() + self.assertEqual( + mock_redis.set.call_args.args[0], "building_programme_index_42" + ) + mock_redis.delete.assert_called_once_with("building_programme_index_42") + + @patch("apps.epg.tasks.build_programme_index") + def test_task_skips_when_lock_held(self, mock_build): + mock_redis = MagicMock() + mock_redis.set.return_value = False # another build in flight + with patch("core.utils.RedisClient.get_client", return_value=mock_redis): + build_programme_index_task(42) + + mock_build.assert_not_called() + mock_redis.delete.assert_not_called() + + @patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom")) + def test_task_releases_lock_on_failure(self, mock_build): + mock_redis = MagicMock() + mock_redis.set.return_value = True + with patch("core.utils.RedisClient.get_client", return_value=mock_redis): + with self.assertRaises(RuntimeError): + build_programme_index_task(42) + + mock_redis.delete.assert_called_once_with("building_programme_index_42") + def test_per_channel_interleaved_marking(self): xml = ( '\n' diff --git a/frontend/src/api.js b/frontend/src/api.js index 19e7d70b..370d9188 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1530,27 +1530,21 @@ export default class API { } static async getCurrentProgramForEpg(epgId) { - try { - const response = await request( - `${host}/api/epg/current-programs/`, - { - method: 'POST', - body: { epg_data_ids: [epgId] }, - } - ); - - // The endpoint returns an array, get the first item - if (response && response.length > 0) { - if (response[0].parsing) { - return { parsing: true }; - } - return response[0]; + const response = await request( + `${host}/api/epg/current-programs/`, + { + method: 'POST', + body: { epg_data_ids: [epgId] }, } - return null; - } catch (e) { - console.error('Failed to retrieve current program for EPG', e); - return null; + ); + + if (response && response.length > 0) { + if (response[0].parsing) { + return { parsing: true }; + } + return response[0]; } + return null; } // Notice there's a duplicated "refreshPlaylist" method above; diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index fc5dd570..a1748143 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -54,50 +54,6 @@ import { import useVideoStore from '../../store/useVideoStore'; import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js'; -const formatProgramTime = (seconds) => { - const absSeconds = Math.abs(seconds); - const hours = Math.floor(absSeconds / 3600); - const minutes = Math.floor((absSeconds % 3600) / 60); - const secs = Math.floor(absSeconds % 60); - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - return `${minutes}:${secs.toString().padStart(2, '0')}`; -}; - -const ProgramProgress = ({ currentProgram }) => { - const now = new Date(); - const startTime = new Date(currentProgram.start_time); - const endTime = new Date(currentProgram.end_time); - const totalDuration = (endTime - startTime) / 1000; // in seconds - const elapsed = (now - startTime) / 1000; // in seconds - const remaining = (endTime - now) / 1000; // in seconds - const percentage = Math.min( - 100, - Math.max(0, (elapsed / totalDuration) * 100) - ); - - return ( - - - - {formatProgramTime(elapsed)} elapsed - - - {formatProgramTime(remaining)} remaining - - - - - ); -}; // Create a separate component for each channel card to properly handle the hook const StreamConnectionCard = ({ diff --git a/frontend/src/components/forms/__tests__/ChannelEpgPreview.test.jsx b/frontend/src/components/forms/__tests__/ChannelEpgPreview.test.jsx index 49986de2..fb30f1f0 100644 --- a/frontend/src/components/forms/__tests__/ChannelEpgPreview.test.jsx +++ b/frontend/src/components/forms/__tests__/ChannelEpgPreview.test.jsx @@ -145,9 +145,10 @@ describe('Channel EPG preview hook', () => { await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - for (const delay of [3000, 4500, 6750, 10125, 15000]) { + // Backoff ramps to a 15s cap; advance past the 180s deadline that bounds the loop. + for (let elapsed = 0; elapsed <= 190000; elapsed += 15100) { await act(async () => { - await vi.advanceTimersByTimeAsync(delay + 100); + await vi.advanceTimersByTimeAsync(15100); }); } diff --git a/frontend/src/hooks/useEpgPreview.jsx b/frontend/src/hooks/useEpgPreview.jsx index a64571f7..f2cab900 100644 --- a/frontend/src/hooks/useEpgPreview.jsx +++ b/frontend/src/hooks/useEpgPreview.jsx @@ -19,7 +19,7 @@ export const useEpgPreview = (epgDataId) => { setHasFetchedProgram(false); const fetchWithRetry = async () => { - const maxRetries = 5; + const maxRetries = 20; const deadlineMs = 3 * 60 * 1000; const startTime = Date.now(); let delay = 3000;