From 9c3ace6146d292f620c2b5f60d15d182d74223d3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 3 Jul 2026 09:09:59 -0500 Subject: [PATCH] Enhancement: Introduce 'Only Catch-up' filter in Channels and Streams tables, allowing users to easily narrow down to catch-up entries. Update changelog to reflect this new feature. --- CHANGELOG.md | 2 +- apps/channels/api_views.py | 7 +++ apps/channels/tests/test_channel_api.py | 45 +++++++++++++++++++ .../src/components/tables/ChannelsTable.jsx | 9 +++- .../ChannelsTable/ChannelTableHeader.jsx | 21 +++++++++ .../__tests__/ChannelTableHeader.test.jsx | 10 +++++ .../src/components/tables/StreamsTable.jsx | 20 +++++++++ .../src/utils/tables/ChannelsTableUtils.js | 2 + .../__tests__/ChannelsTableUtils.test.js | 9 ++++ 9 files changed, 122 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2d9ec7..b8b737c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. - - **Catch-up indicators in Channels and Streams tables.** Channels and streams with catch-up enabled show a grey history icon beside the name (tooltip includes archive days when known). Expanded channel stream rows show a catch-up badge. Channel and stream API serializers expose `is_catchup` and `catchup_days` for the UI. + - **Catch-up indicators in Channels and Streams tables.** Channels and streams with catch-up enabled show a grey history icon beside the name (tooltip includes archive days when known). Expanded channel stream rows show a catch-up badge. Channel and stream API serializers expose `is_catchup` and `catchup_days` for the UI. The Channels and Streams table filter menus include **Only Catch-up** to narrow each list to catch-up entries. - **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810) ### Changed diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 3f28903c..15b4cd07 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -177,6 +177,10 @@ class StreamViewSet(viewsets.ModelViewSet): if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"): qs = qs.filter(is_stale=False) + is_catchup = self.request.query_params.get("is_catchup") + if is_catchup and str(is_catchup).lower() in ("1", "true", "yes", "on"): + qs = qs.filter(is_catchup=True) + return qs def list(self, request, *args, **kwargs): @@ -941,6 +945,7 @@ class ChannelViewSet(viewsets.ModelViewSet): only_streamless = self.request.query_params.get("only_streamless", None) only_stale = self.request.query_params.get("only_stale", None) only_has_overrides = self.request.query_params.get("only_has_overrides", None) + only_catchup = self.request.query_params.get("only_catchup", None) visibility_filter = self.request.query_params.get("visibility_filter", "active") if channel_profile_id: @@ -966,6 +971,8 @@ class ChannelViewSet(viewsets.ModelViewSet): q_filters &= Q(streams__is_stale=True) if only_has_overrides: q_filters &= Q(override__isnull=False) + if only_catchup: + q_filters &= Q(is_catchup=True) # Visibility filter applies to list-style reads only; retrieve / # update / delete must still reach a hidden channel by id so the diff --git a/apps/channels/tests/test_channel_api.py b/apps/channels/tests/test_channel_api.py index 5d60a8da..5932becf 100644 --- a/apps/channels/tests/test_channel_api.py +++ b/apps/channels/tests/test_channel_api.py @@ -573,3 +573,48 @@ class ChannelListIncludeStreamsQueryTests(TestCase): "include_streams list should use prefetched channelstream_set, " "not one streams M2M query per channel", ) + + +class ChannelListOnlyCatchupFilterTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="catchup_filter", password="x") + self.user.user_level = 10 + self.user.save() + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + self.catchup_channel = Channel.objects.create( + channel_number=1.0, + name="Catch-up Channel", + is_catchup=True, + catchup_days=7, + ) + self.live_channel = Channel.objects.create( + channel_number=2.0, + name="Live Channel", + is_catchup=False, + ) + + def test_only_catchup_returns_catchup_channels(self): + response = self.client.get( + "/api/channels/channels/", + {"only_catchup": "true", "page_size": 50}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + ids = {row["id"] for row in response.data["results"]} + self.assertEqual(ids, {self.catchup_channel.id}) + + def test_only_catchup_does_not_force_distinct(self): + from django.db import connection + from django.test.utils import CaptureQueriesContext + + with CaptureQueriesContext(connection) as ctx: + response = self.client.get( + "/api/channels/channels/", + {"only_catchup": "true", "page_size": 50}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + sql = " ".join(q["sql"] for q in ctx.captured_queries).upper() + self.assertNotIn("DISTINCT", sql) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index dcb9e6e0..900243dd 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -333,6 +333,8 @@ const ChannelsTable = ({ onReady }) => { const [showOnlyStaleChannels, setShowOnlyStaleChannels] = useState(false); const [showOnlyOverriddenChannels, setShowOnlyOverriddenChannels] = useState(false); + const [showOnlyCatchupChannels, setShowOnlyCatchupChannels] = + useState(false); const [visibilityFilter, setVisibilityFilter] = useState('active'); const [paginationString, setPaginationString] = useState(''); @@ -436,7 +438,7 @@ const ChannelsTable = ({ onReady }) => { const params = buildFetchParams({ pagination, sorting, debouncedFilters, selectedProfileId, showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels, - showOnlyOverriddenChannels, visibilityFilter, + showOnlyOverriddenChannels, visibilityFilter, showOnlyCatchupChannels, }); const paramsString = params.toString(); @@ -462,7 +464,8 @@ const ChannelsTable = ({ onReady }) => { }, [ pagination, sorting, debouncedFilters, selectedProfileId, showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels, - showOnlyOverriddenChannels, visibilityFilter, handleFetchSuccess, + showOnlyOverriddenChannels, visibilityFilter, showOnlyCatchupChannels, + handleFetchSuccess, ]); const stopPropagation = useCallback((e) => { @@ -1524,6 +1527,8 @@ const ChannelsTable = ({ onReady }) => { setShowOnlyStaleChannels={setShowOnlyStaleChannels} showOnlyOverriddenChannels={showOnlyOverriddenChannels} setShowOnlyOverriddenChannels={setShowOnlyOverriddenChannels} + showOnlyCatchupChannels={showOnlyCatchupChannels} + setShowOnlyCatchupChannels={setShowOnlyCatchupChannels} visibilityFilter={visibilityFilter} setVisibilityFilter={setVisibilityFilter} /> diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 42e07be1..f1bcf68c 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -128,6 +128,8 @@ const ChannelTableHeader = ({ setShowOnlyStaleChannels, showOnlyOverriddenChannels, setShowOnlyOverriddenChannels, + showOnlyCatchupChannels, + setShowOnlyCatchupChannels, visibilityFilter, setVisibilityFilter, }) => { @@ -229,6 +231,12 @@ const ChannelTableHeader = ({ } }; + const toggleShowOnlyCatchupChannels = () => { + if (setShowOnlyCatchupChannels) { + setShowOnlyCatchupChannels(!showOnlyCatchupChannels); + } + }; + const toggleHeaderPinned = () => { setHeaderPinned(!headerPinned); }; @@ -342,6 +350,19 @@ const ChannelTableHeader = ({ Has Overrides + + ) : ( + + ) + } + > + Only Catch-up + + Visibility diff --git a/frontend/src/components/tables/ChannelsTable/__tests__/ChannelTableHeader.test.jsx b/frontend/src/components/tables/ChannelsTable/__tests__/ChannelTableHeader.test.jsx index 6979046b..91b07c40 100644 --- a/frontend/src/components/tables/ChannelsTable/__tests__/ChannelTableHeader.test.jsx +++ b/frontend/src/components/tables/ChannelsTable/__tests__/ChannelTableHeader.test.jsx @@ -225,6 +225,8 @@ const makeDefaultProps = (overrides = {}) => ({ setShowOnlyStaleChannels: vi.fn(), showOnlyOverriddenChannels: false, setShowOnlyOverriddenChannels: vi.fn(), + showOnlyCatchupChannels: false, + setShowOnlyCatchupChannels: vi.fn(), visibilityFilter: 'active', setVisibilityFilter: vi.fn(), ...overrides, @@ -398,6 +400,14 @@ describe('ChannelTableHeader', () => { expect(props.setShowOnlyOverriddenChannels).toHaveBeenCalledWith(true); }); + it('calls setShowOnlyCatchupChannels when Only Catch-up is clicked', () => { + const props = makeDefaultProps({ showOnlyCatchupChannels: false }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Only Catch-up')); + expect(props.setShowOnlyCatchupChannels).toHaveBeenCalledWith(true); + }); + it('calls setVisibilityFilter with "hidden" when Hidden Only is clicked', () => { const props = makeDefaultProps(); setupMocks(); diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 0762ff5c..7dcf6549 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -247,6 +247,7 @@ const StreamsTable = ({ onReady }) => { m3u_account: '', unassigned: false, hide_stale: false, + is_catchup: false, }); const [columnSizing, setColumnSizing] = useLocalStorage( 'streams-table-column-sizing', @@ -566,6 +567,13 @@ const StreamsTable = ({ onReady }) => { })); }; + const toggleCatchupOnly = () => { + setFilters((prev) => ({ + ...prev, + is_catchup: !prev.is_catchup, + })); + }; + // Build a URLSearchParams object containing only the filter portion of the // query. Page-rows fetches add page/page_size/ordering on top of this. const buildFilterParams = useCallback(() => { @@ -1468,6 +1476,18 @@ const StreamsTable = ({ onReady }) => { > Hide Stale + + ) : ( + + ) + } + > + Only Catch-up + diff --git a/frontend/src/utils/tables/ChannelsTableUtils.js b/frontend/src/utils/tables/ChannelsTableUtils.js index a5cadb45..725e3818 100644 --- a/frontend/src/utils/tables/ChannelsTableUtils.js +++ b/frontend/src/utils/tables/ChannelsTableUtils.js @@ -202,6 +202,7 @@ export const buildFetchParams = ({ showOnlyStaleChannels, showOnlyOverriddenChannels, visibilityFilter, + showOnlyCatchupChannels, }) => { const params = new URLSearchParams(); params.append('page', pagination.pageIndex + 1); @@ -214,6 +215,7 @@ export const buildFetchParams = ({ if (showOnlyStreamlessChannels) params.append('only_streamless', true); if (showOnlyStaleChannels) params.append('only_stale', true); if (showOnlyOverriddenChannels) params.append('only_has_overrides', true); + if (showOnlyCatchupChannels) params.append('only_catchup', true); if (visibilityFilter && visibilityFilter !== 'active') params.append('visibility_filter', visibilityFilter); if (sorting.length > 0) { diff --git a/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js index c4fc9e3e..e71637a4 100644 --- a/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js +++ b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js @@ -414,6 +414,7 @@ describe('ChannelsTableUtils', () => { showOnlyStreamlessChannels: false, showOnlyStaleChannels: false, showOnlyOverriddenChannels: false, + showOnlyCatchupChannels: false, visibilityFilter: 'active', }; @@ -478,6 +479,14 @@ describe('ChannelsTableUtils', () => { expect(params.get('only_has_overrides')).toBe('true'); }); + it('includes only_catchup when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showOnlyCatchupChannels: true, + }); + expect(params.get('only_catchup')).toBe('true'); + }); + it('does not include visibility_filter when "active"', () => { const params = ChannelsTableUtils.buildFetchParams(defaults); expect(params.get('visibility_filter')).toBeNull();