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.
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (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

This commit is contained in:
SergeantPanda 2026-07-03 09:09:59 -05:00
parent 4b0066f6e7
commit 9c3ace6146
9 changed files with 122 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -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}
/>

View file

@ -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 = ({
<Text size="xs">Has Overrides</Text>
</MenuItem>
<MenuItem
onClick={toggleShowOnlyCatchupChannels}
leftSection={
showOnlyCatchupChannels ? (
<SquareCheck size={18} />
) : (
<Square size={18} />
)
}
>
<Text size="xs">Only Catch-up</Text>
</MenuItem>
<MenuDivider />
<MenuLabel>
<Text size="xs">Visibility</Text>

View file

@ -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(<ChannelTableHeader {...props} />);
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();

View file

@ -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 }) => {
>
<Text size="xs">Hide Stale</Text>
</MenuItem>
<MenuItem
onClick={toggleCatchupOnly}
leftSection={
filters.is_catchup === true ? (
<SquareCheck size={18} />
) : (
<Square size={18} />
)
}
>
<Text size="xs">Only Catch-up</Text>
</MenuItem>
</MenuDropdown>
</Menu>

View file

@ -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) {

View file

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