mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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
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:
parent
4b0066f6e7
commit
9c3ace6146
9 changed files with 122 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue