diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5282ff54..c834174d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663)
+- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647)
### Changed
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index fcf50f49..8ea5db8a 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -96,7 +96,7 @@ class StreamFilter(django_filters.FilterSet):
channel_group_name = OrInFilter(
field_name="channel_group__name", lookup_expr="icontains"
)
- m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
+ m3u_account = django_filters.BaseInFilter(field_name="m3u_account__id")
m3u_account_name = django_filters.CharFilter(
field_name="m3u_account__name", lookup_expr="icontains"
)
@@ -194,6 +194,73 @@ class StreamViewSet(viewsets.ModelViewSet):
# Return the response with the list of unique group names
return Response(list(group_names))
+ @action(detail=False, methods=["get"], url_path="filter-options")
+ def get_filter_options(self, request, *args, **kwargs):
+ """
+ Get available filter options based on current filter state.
+ Uses a hierarchical approach: M3U is the parent filter, Group filters based on M3U.
+ """
+ # For group options: we need to bypass the channel_group custom queryset filtering
+ # Store original request params
+ original_params = request.query_params
+
+ # Create modified params without channel_group for getting group options
+ params_without_group = request.GET.copy()
+ params_without_group.pop('channel_group', None)
+ params_without_group.pop('channel_group_name', None)
+
+ # Temporarily modify request to exclude channel_group
+ request._request.GET = params_without_group
+ base_queryset_for_groups = self.get_queryset()
+
+ # Apply filterset (which will apply M3U filters)
+ group_filterset = self.filterset_class(
+ params_without_group,
+ queryset=base_queryset_for_groups
+ )
+ group_queryset = group_filterset.qs
+
+ group_names = (
+ group_queryset.exclude(channel_group__isnull=True)
+ .order_by("channel_group__name")
+ .values_list("channel_group__name", flat=True)
+ .distinct()
+ )
+
+ # For M3U options: show ALL M3Us (don't filter by anything except name search)
+ params_for_m3u = request.GET.copy()
+ params_for_m3u.pop('m3u_account', None)
+ params_for_m3u.pop('channel_group', None)
+ params_for_m3u.pop('channel_group_name', None)
+
+ # Temporarily modify request to exclude filters for M3U options
+ request._request.GET = params_for_m3u
+ base_queryset_for_m3u = self.get_queryset()
+
+ m3u_filterset = self.filterset_class(
+ params_for_m3u,
+ queryset=base_queryset_for_m3u
+ )
+ m3u_queryset = m3u_filterset.qs
+
+ m3u_accounts = (
+ m3u_queryset.exclude(m3u_account__isnull=True)
+ .order_by("m3u_account__name")
+ .values("m3u_account__id", "m3u_account__name")
+ .distinct()
+ )
+
+ # Restore original params
+ request._request.GET = original_params
+
+ return Response({
+ "groups": list(group_names),
+ "m3u_accounts": [
+ {"id": m3u["m3u_account__id"], "name": m3u["m3u_account__name"]}
+ for m3u in m3u_accounts
+ ]
+ })
+
@swagger_auto_schema(
method="post",
operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
diff --git a/frontend/src/api.js b/frontend/src/api.js
index c33ff1ee..ab22848c 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -727,6 +727,18 @@ export default class API {
}
}
+ static async getStreamFilterOptions(params) {
+ try {
+ const response = await request(
+ `${host}/api/channels/streams/filter-options/?${params.toString()}`
+ );
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve filter options', e);
+ }
+ }
+
static async addStream(values) {
try {
const response = await request(`${host}/api/channels/streams/`, {
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 72d50d49..02dae3d5 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -186,6 +186,7 @@ const StreamsTable = ({ onReady }) => {
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [groupOptions, setGroupOptions] = useState([]);
+ const [m3uOptions, setM3uOptions] = useState([]);
const [initialDataCount, setInitialDataCount] = useState(null);
const [data, setData] = useState([]); // Holds fetched data
@@ -371,14 +372,14 @@ const StreamsTable = ({ onReady }) => {
const handleGroupChange = (value) => {
setFilters((prev) => ({
...prev,
- channel_group: value ? value : '',
+ channel_group: value && value.length > 0 ? value.join(',') : '',
}));
};
const handleM3UChange = (value) => {
setFilters((prev) => ({
...prev,
- m3u_account: value ? value : '',
+ m3u_account: value && value.length > 0 ? value.join(',') : '',
}));
};
@@ -419,16 +420,24 @@ const StreamsTable = ({ onReady }) => {
});
try {
- const [result, ids, groups] = await Promise.all([
+ const [result, ids, filterOptions] = await Promise.all([
API.queryStreams(params),
API.getAllStreamIds(params),
- API.getStreamGroups(),
+ API.getStreamFilterOptions(params),
]);
setAllRowIds(ids);
setData(result.results);
setPageCount(Math.ceil(result.count / pagination.pageSize));
- setGroupOptions(groups);
+
+ // Set filtered options based on current filters
+ setGroupOptions(filterOptions.groups);
+ setM3uOptions(
+ filterOptions.m3u_accounts.map((m3u) => ({
+ label: m3u.name,
+ value: `${m3u.id}`,
+ }))
+ );
// Calculate the starting and ending item indexes
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
@@ -844,7 +853,10 @@ const StreamsTable = ({ onReady }) => {
);
- case 'group':
+ case 'group': {
+ const selectedGroups = filters.channel_group
+ ? filters.channel_group.split(',').filter(Boolean)
+ : [];
return (
{
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleGroupChange}
+ value={selectedGroups}
data={groupOptions}
variant="unstyled"
className="table-input-header custom-multiselect"
clearable
+ valueComponent={({ value }) => {
+ const index = selectedGroups.indexOf(value);
+ if (index === 0) {
+ return (
+
+
+ {value}
+
+ {selectedGroups.length > 1 && (
+
+ +{selectedGroups.length - 1}
+
+ )}
+
+ );
+ }
+ return null;
+ }}
style={{ flex: 1, minWidth: 0 }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
@@ -871,11 +916,15 @@ const StreamsTable = ({ onReady }) => {
/>
);
+ }
- case 'm3u':
+ case 'm3u': {
+ const selectedM3Us = filters.m3u_account
+ ? filters.m3u_account.split(',').filter(Boolean)
+ : [];
return (
-
);
+ }
}
};
@@ -972,6 +1055,38 @@ const StreamsTable = ({ onReady }) => {
fetchData();
}, [fetchData]);
+ // Clear dependent filters if selected values are no longer in filtered options
+ useEffect(() => {
+ // Clear group filter if the selected groups are no longer available
+ if (filters.channel_group) {
+ const selectedGroups = filters.channel_group.split(',').filter(Boolean);
+ const stillValid = selectedGroups.filter((group) =>
+ groupOptions.includes(group)
+ );
+
+ if (stillValid.length !== selectedGroups.length) {
+ setFilters((prev) => ({
+ ...prev,
+ channel_group: stillValid.join(','),
+ }));
+ }
+ }
+
+ // Clear M3U filter if the selected M3Us are no longer available
+ if (filters.m3u_account) {
+ const selectedIds = filters.m3u_account.split(',').filter(Boolean);
+ const availableIds = m3uOptions.map((opt) => opt.value);
+ const stillValid = selectedIds.filter((id) => availableIds.includes(id));
+
+ if (stillValid.length !== selectedIds.length) {
+ setFilters((prev) => ({
+ ...prev,
+ m3u_account: stillValid.join(','),
+ }));
+ }
+ }
+ }, [groupOptions, m3uOptions, filters.channel_group, filters.m3u_account]);
+
return (
<>