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();