diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 828bdc43..b279a82a 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -2,9 +2,9 @@ name: Frontend Tests on: push: - branches: [ main ] + branches: [main, dev] pull_request: - branches: [ main ] + branches: [main, dev] jobs: test: @@ -21,15 +21,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: './frontend/package-lock.json' - name: Install dependencies run: npm ci -# - name: Run linter -# run: npm run lint + # - name: Run linter + # run: npm run lint - name: Run tests run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d42062..c636e281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 + +- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence. +- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. +- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `table.headerPinned`) instead of calling hooks directly, improving maintainability and providing consistent API across all tables. + +### Fixed + +- Fixed VOD logo cleanup button count: The "Cleanup Unused" button now displays the total count of all unused logos across all pages instead of only counting unused logos on the current page. +- Fixed VOD refresh failures when logos are deleted: Changed logo comparisons to use `logo_id` (raw FK integer) instead of `logo` (related object) to avoid Django's lazy loading, which triggers a database fetch that fails if the referenced logo no longer exists. Also improved orphaned logo detection to properly clear stale references when logo URLs exist but logos are missing from the database. +- Fixed channel profile filtering to properly restrict content based on assigned channel profiles for all non-admin users (user_level < 10) instead of only streamers (user_level == 0). This corrects the XtreamCodes API endpoints (`get_live_categories` and `get_live_streams`) along with M3U and EPG generation, ensuring standard users (level 1) are properly restricted by their assigned channel profiles. Previously, "Standard" users with channel profiles assigned would see all channels instead of only those in their assigned profiles. +- Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. +- Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access. + ## [0.17.0] - 2026-01-13 ### Added 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/apps/output/views.py b/apps/output/views.py index 47798ee2..2cdd4dac 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -128,7 +128,7 @@ def generate_m3u(request, profile_name=None, user=None): return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode())) if user is not None: - if user.user_level == 0: + if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access @@ -1258,7 +1258,7 @@ def generate_epg(request, profile_name=None, user=None): # Get channels based on user/profile if user is not None: - if user.user_level == 0: + if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access @@ -2079,7 +2079,7 @@ def xc_get_live_categories(user): from django.db.models import Min response = [] - if user.user_level == 0: + if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access @@ -2116,7 +2116,7 @@ def xc_get_live_categories(user): def xc_get_live_streams(request, user, category_id=None): streams = [] - if user.user_level == 0: + if user.user_level < 10: user_profile_count = user.channel_profiles.count() # If user has ALL profiles or NO profiles, give unrestricted access diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 0dcd9cfd..c24023d6 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -73,7 +73,9 @@ def refresh_vod_content(account_id): return f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds" except Exception as e: + import traceback logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}") + logger.error(f"Full traceback:\n{traceback.format_exc()}") # Send error notification send_m3u_update(account_id, "vod_refresh", 100, status="error", @@ -555,12 +557,19 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N # Handle logo assignment for existing movies logo_updated = False - if logo_url and len(logo_url) <= 500 and logo_url in existing_logos: - new_logo = existing_logos[logo_url] - if movie.logo != new_logo: - movie._logo_to_update = new_logo + if logo_url and len(logo_url) <= 500: + if logo_url in existing_logos: + new_logo = existing_logos[logo_url] + if movie.logo_id != new_logo.id: + movie._logo_to_update = new_logo + logo_updated = True + elif movie.logo_id: + # Logo URL exists but logo creation failed or logo not found + # Clear the orphaned logo reference + logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference") + movie._logo_to_update = None logo_updated = True - elif (not logo_url or len(logo_url) > 500) and movie.logo: + elif (not logo_url or len(logo_url) > 500) and movie.logo_id: # Clear logo if no logo URL provided or URL is too long movie._logo_to_update = None logo_updated = True @@ -905,12 +914,19 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= # Handle logo assignment for existing series logo_updated = False - if logo_url and len(logo_url) <= 500 and logo_url in existing_logos: - new_logo = existing_logos[logo_url] - if series.logo != new_logo: - series._logo_to_update = new_logo + if logo_url and len(logo_url) <= 500: + if logo_url in existing_logos: + new_logo = existing_logos[logo_url] + if series.logo_id != new_logo.id: + series._logo_to_update = new_logo + logo_updated = True + elif series.logo_id: + # Logo URL exists but logo creation failed or logo not found + # Clear the orphaned logo reference + logger.warning(f"Logo URL provided but logo not found in database for series '{series.name}', clearing logo reference") + series._logo_to_update = None logo_updated = True - elif (not logo_url or len(logo_url) > 500) and series.logo: + elif (not logo_url or len(logo_url) > 500) and series.logo_id: # Clear logo if no logo URL provided or URL is too long series._logo_to_update = None logo_updated = True @@ -2176,33 +2192,3 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False): except Exception as e: logger.error(f"Error refreshing advanced movie data for relation {m3u_movie_relation_id}: {str(e)}") return f"Error: {str(e)}" - - -def validate_logo_reference(obj, obj_type="object"): - """ - Validate that a VOD logo reference exists in the database. - If not, set it to None to prevent foreign key constraint violations. - - Args: - obj: Object with a logo attribute - obj_type: String description of the object type for logging - - Returns: - bool: True if logo was valid or None, False if logo was invalid and cleared - """ - if not hasattr(obj, 'logo') or not obj.logo: - return True - - if not obj.logo.pk: - # Logo doesn't have a primary key, so it's not saved - obj.logo = None - return False - - try: - # Verify the logo exists in the database - VODLogo.objects.get(pk=obj.logo.pk) - return True - except VODLogo.DoesNotExist: - logger.warning(f"VOD Logo with ID {obj.logo.pk} does not exist in database for {obj_type} '{getattr(obj, 'name', 'Unknown')}', setting to None") - obj.logo = None - return False diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1622097b..a50f2f49 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -30,7 +30,7 @@ echo_with_timestamp() { # --- NumPy version switching for legacy hardware --- if [ "$USE_LEGACY_NUMPY" = "true" ]; then # Check if NumPy was compiled with baseline support - if /dispatcharrpy/bin/python -c "import numpy; print(str(numpy.show_config()).lower())" 2>/dev/null | grep -q "baseline"; then + if /dispatcharrpy/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." /dispatcharrpy/bin/pip install --no-cache-dir --force-reinstall --no-deps /opt/numpy-*.whl echo_with_timestamp "✅ Legacy NumPy installed" diff --git a/frontend/src/api.js b/frontend/src/api.js index f8b1f1c8..f878c047 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -774,6 +774,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/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index dc130254..0723dcf7 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -236,7 +236,6 @@ export default function BackupManager() { // Read user's preferences from settings const [timeFormat] = useLocalStorage('time-format', '12h'); const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); - const [tableSize] = useLocalStorage('table-size', 'default'); const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone()); const is12Hour = timeFormat === '12h'; @@ -309,10 +308,10 @@ export default function BackupManager() { { id: 'actions', header: 'Actions', - size: tableSize === 'compact' ? 75 : 100, + size: 100, }, ], - [tableSize] + [] ); const renderHeaderCell = (header) => { diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 62d6e62f..a00f3664 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -71,7 +71,6 @@ const StreamConnectionCard = ({ // Get Date-format from localStorage const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM'; - const [tableSize] = useLocalStorage('table-size', 'default'); // Create a map of M3U account IDs to names for quick lookup const m3uAccountsMap = useMemo(() => { @@ -296,7 +295,7 @@ const StreamConnectionCard = ({ { id: 'actions', header: 'Actions', - size: tableSize == 'compact' ? 75 : 100, + size: 100, }, ], [] diff --git a/frontend/src/components/forms/settings/UiSettingsForm.jsx b/frontend/src/components/forms/settings/UiSettingsForm.jsx index dc123916..68977b5b 100644 --- a/frontend/src/components/forms/settings/UiSettingsForm.jsx +++ b/frontend/src/components/forms/settings/UiSettingsForm.jsx @@ -1,18 +1,18 @@ import useSettingsStore from '../../../store/settings.jsx'; import useLocalStorage from '../../../hooks/useLocalStorage.jsx'; +import useTablePreferences from '../../../hooks/useTablePreferences.jsx'; import { buildTimeZoneOptions, getDefaultTimeZone, } from '../../../utils/dateTimeUtils.js'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { showNotification } from '../../../utils/notificationUtils.js'; -import { Select } from '@mantine/core'; +import { Select, Switch, Stack } from '@mantine/core'; import { saveTimeZoneSetting } from '../../../utils/forms/settings/UiSettingsFormUtils.js'; const UiSettingsForm = React.memo(() => { const settings = useSettingsStore((s) => s.settings); - const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h'); const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy'); const [timeZone, setTimeZone] = useLocalStorage( @@ -20,6 +20,10 @@ const UiSettingsForm = React.memo(() => { getDefaultTimeZone() ); + // Use shared table preferences hook + const { headerPinned, setHeaderPinned, tableSize, setTableSize } = + useTablePreferences(); + const timeZoneOptions = useMemo( () => buildTimeZoneOptions(timeZone), [timeZone] @@ -74,11 +78,14 @@ const UiSettingsForm = React.memo(() => { persistTimeZoneSetting(value); } break; + case 'header-pinned': + setHeaderPinned(value); + break; } }; return ( - <> + { onChange={(val) => onUISettingsChange('time-zone', val)} data={timeZoneOptions} /> - + ); }); diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index dc82c131..b986e70e 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -114,6 +114,7 @@ const ChannelRowActions = React.memo( ({ theme, row, + table, editChannel, deleteChannel, handleWatchStream, @@ -123,7 +124,6 @@ const ChannelRowActions = React.memo( // Extract the channel ID once to ensure consistency const channelId = row.original.id; const channelUuid = row.original.uuid; - const [tableSize, _] = useLocalStorage('table-size', 'default'); const authUser = useAuthStore((s) => s.user); @@ -149,6 +149,7 @@ const ChannelRowActions = React.memo( createRecording(row.original); }, [channelId]); + const tableSize = table?.tableSize ?? 'default'; const iconSize = tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; @@ -272,7 +273,6 @@ const ChannelsTable = ({ onReady }) => { // store/settings const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); - const [tableSize, _] = useLocalStorage('table-size', 'default'); // store/warnings const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -429,9 +429,10 @@ const ChannelsTable = ({ onReady }) => { setIsLoading(false); hasFetchedData.current = true; - setTablePrefs({ + setTablePrefs((prev) => ({ + ...prev, pageSize: pagination.pageSize, - }); + })); setAllRowIds(ids); // Signal ready after first successful data fetch AND EPG data is loaded @@ -949,13 +950,14 @@ const ChannelsTable = ({ onReady }) => { }, { id: 'actions', - size: tableSize == 'compact' ? 75 : 100, + size: 100, enableResizing: false, header: '', - cell: ({ row }) => ( + cell: ({ row, table }) => ( s.user); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); + + const headerPinned = table?.headerPinned ?? false; + const setHeaderPinned = table?.setHeaderPinned || (() => {}); const closeAssignChannelNumbersModal = () => { setAssignNumbersModalOpen(false); }; @@ -229,6 +235,10 @@ const ChannelTableHeader = ({ setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels); }; + const toggleHeaderPinned = () => { + setHeaderPinned(!headerPinned); + }; + return ( @@ -346,6 +356,19 @@ const ChannelTableHeader = ({ + : + } + onClick={toggleHeaderPinned} + > + + {headerPinned ? 'Unpin Headers' : 'Pin Headers'} + + + + + } disabled={ diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index 90407d49..d6315818 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -4,10 +4,9 @@ import { useCallback, useState, useRef, useMemo } from 'react'; import { flexRender } from '@tanstack/react-table'; import table from '../../../helpers/table'; import CustomTableBody from './CustomTableBody'; -import useLocalStorage from '../../../hooks/useLocalStorage'; const CustomTable = ({ table }) => { - const [tableSize, _] = useLocalStorage('table-size', 'default'); + const tableSize = table?.tableSize ?? 'default'; // Get column sizing state for dependency tracking const columnSizing = table.getState().columnSizing; @@ -34,7 +33,6 @@ const CustomTable = ({ table }) => { minWidth: `${minTableWidth}px`, display: 'flex', flexDirection: 'column', - overflow: 'hidden', }} > { } selectedTableIds={table.selectedTableIds} tableCellProps={table.tableCellProps} + headerPinned={table.headerPinned} /> { const renderHeaderCell = (header) => { if (headerCellRenderFns[header.id]) { @@ -59,15 +60,22 @@ const CustomTableHeader = ({ return width; }, [headerGroups]); + // Memoize the style object to ensure it updates when headerPinned changes + const headerStyle = useMemo( + () => ({ + position: headerPinned ? 'sticky' : 'relative', + top: headerPinned ? 0 : 'auto', + backgroundColor: '#3E3E45', + zIndex: headerPinned ? 10 : 1, + }), + [headerPinned] + ); + return ( {getHeaderGroups().map((headerGroup) => ( { if (e.key === 'Shift') { @@ -244,8 +249,22 @@ const useTable = ({ expandedRowRenderer, setSelectedTableIds, isShiftKeyDown, // Include shift key state in the table instance + headerPinned, + setHeaderPinned, + tableSize, + setTableSize, }), - [selectedTableIdsSet, expandedRowIds, allRowIds, isShiftKeyDown] + [ + selectedTableIdsSet, + expandedRowIds, + allRowIds, + isShiftKeyDown, + options, + headerPinned, + setHeaderPinned, + tableSize, + setTableSize, + ] ); return { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 64bf425a..167d2532 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -71,8 +71,9 @@ const StreamRowActions = ({ handleWatchStream, selectedChannelIds, createChannelFromStream, + table, }) => { - const [tableSize, _] = useLocalStorage('table-size', 'default'); + const tableSize = table?.tableSize ?? 'default'; const channelSelectionStreams = useChannelsTableStore( (state) => state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams @@ -188,6 +189,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 [paginationString, setPaginationString] = useState(''); @@ -261,7 +263,6 @@ const StreamsTable = ({ onReady }) => { const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); - const [tableSize, _] = useLocalStorage('table-size', 'default'); const data = useStreamsTableStore((s) => s.streams); const pageCount = useStreamsTableStore((s) => s.pageCount); @@ -293,7 +294,7 @@ const StreamsTable = ({ onReady }) => { () => [ { id: 'actions', - size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80), + size: columnSizing.actions || 75, }, { id: 'select', @@ -361,7 +362,7 @@ const StreamsTable = ({ onReady }) => { ), }, ], - [channelGroups, playlists, columnSizing, tableSize] + [channelGroups, playlists, columnSizing] ); /** @@ -378,14 +379,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(',') : '', })); }; @@ -425,14 +426,22 @@ const StreamsTable = ({ onReady }) => { }); try { - const [result, ids, groups] = await Promise.all([ + const [result, ids, filterOptions] = await Promise.all([ API.queryStreamsTable(params), API.getAllStreamIds(params), - API.getStreamGroups(), + API.getStreamFilterOptions(params), ]); setAllRowIds(ids); - setGroupOptions(groups); + + // Set filtered options based on current filters + setGroupOptions(filterOptions.groups); + setM3uOptions( + filterOptions.m3u_accounts.map((m3u) => ({ + label: m3u.name, + value: `${m3u.id}`, + })) + ); if (initialDataCount === null) { setInitialDataCount(result.count); @@ -840,26 +849,67 @@ const StreamsTable = ({ onReady }) => { ); - case 'group': + case 'group': { + const selectedGroups = filters.channel_group + ? filters.channel_group.split(',').filter(Boolean) + : []; return ( { + const index = selectedGroups.indexOf(value); + if (index === 0) { + return ( + + + {value} + + {selectedGroups.length > 1 && ( + + +{selectedGroups.length - 1} + + )} + + ); + } + return null; + }} style={{ width: '100%' }} /> ); + } - case 'm3u': + case 'm3u': { + const selectedM3Us = filters.m3u_account + ? filters.m3u_account.split(',').filter(Boolean) + : []; return ( -