Merge branch 'dev' into Media-Server

This commit is contained in:
Dispatcharr 2026-01-16 14:07:24 -06:00
commit 339d4e9058
18 changed files with 517 additions and 94 deletions

View file

@ -7,12 +7,21 @@ 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 refresh failures caused by orphaned logo references: Added validation to detect and clear logo foreign key references when the logo no longer exists in the database, preventing "VODLogo matching query does not exist" errors. Also improved logo assignment logic to properly handle cases where logo URLs exist but logo creation fails, ensuring VOD content updates successfully even when logos are deleted or unavailable.
- 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.

View file

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

View file

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

View file

@ -555,10 +555,17 @@ 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 != new_logo:
movie._logo_to_update = new_logo
logo_updated = True
elif movie.logo:
# 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:
# Clear logo if no logo URL provided or URL is too long
@ -662,6 +669,8 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
for movie in movies_to_update:
if hasattr(movie, '_logo_to_update'):
movie.logo = movie._logo_to_update
# Validate logo reference exists before saving
validate_logo_reference(movie, "Movie")
movie.save(update_fields=['logo'])
# Update relations to reference the correct movie objects (with PKs)
@ -905,10 +914,17 @@ 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 != new_logo:
series._logo_to_update = new_logo
logo_updated = True
elif series.logo:
# 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:
# Clear logo if no logo URL provided or URL is too long
@ -1012,6 +1028,8 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
for series in series_to_update:
if hasattr(series, '_logo_to_update'):
series.logo = series._logo_to_update
# Validate logo reference exists before saving
validate_logo_reference(series, "Series")
series.save(update_fields=['logo'])
# Update relations to reference the correct series objects (with PKs)

View file

@ -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/`, {

View file

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

View file

@ -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,
},
],
[]

View file

@ -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 (
<>
<Stack gap="md">
<Select
label="Table Size"
value={tableSize}
@ -98,6 +105,14 @@ const UiSettingsForm = React.memo(() => {
},
]}
/>
<Switch
label="Pin Table Headers"
description="Keep table headers visible when scrolling"
checked={headerPinned}
onChange={(event) =>
onUISettingsChange('header-pinned', event.currentTarget.checked)
}
/>
<Select
label="Time format"
value={timeFormat}
@ -136,7 +151,7 @@ const UiSettingsForm = React.memo(() => {
onChange={(val) => onUISettingsChange('time-zone', val)}
data={timeZoneOptions}
/>
</>
</Stack>
);
});

View file

@ -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 }) => (
<ChannelRowActions
theme={theme}
row={row}
table={table}
editChannel={editChannel}
deleteChannel={deleteChannel}
handleWatchStream={handleWatchStream}

View file

@ -28,6 +28,8 @@ import {
Filter,
Square,
SquareCheck,
Pin,
PinOff,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
@ -105,6 +107,7 @@ const ChannelTableHeader = ({
editChannel,
deleteChannels,
selectedTableIds,
table,
showDisabled,
setShowDisabled,
showOnlyStreamlessChannels,
@ -131,6 +134,9 @@ const ChannelTableHeader = ({
const authUser = useAuthStore((s) => 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 (
<Group justify="space-between">
<Group gap={5} style={{ paddingLeft: 10 }}>
@ -346,6 +356,19 @@ const ChannelTableHeader = ({
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
headerPinned ? <Pin size={18} /> : <PinOff size={18} />
}
onClick={toggleHeaderPinned}
>
<Text size="xs">
{headerPinned ? 'Unpin Headers' : 'Pin Headers'}
</Text>
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<ArrowDown01 size={18} />}
disabled={

View file

@ -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',
}}
>
<CustomTableHeader
@ -47,6 +45,7 @@ const CustomTable = ({ table }) => {
}
selectedTableIds={table.selectedTableIds}
tableCellProps={table.tableCellProps}
headerPinned={table.headerPinned}
/>
<CustomTableBody
getRowModel={table.getRowModel}

View file

@ -9,6 +9,7 @@ const CustomTableHeader = ({
headerCellRenderFns,
onSelectAllChange,
tableCellProps,
headerPinned = true,
}) => {
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 (
<Box
className="thead"
style={{
position: 'sticky',
top: 0,
backgroundColor: '#3E3E45',
zIndex: 10,
}}
style={headerStyle}
data-header-pinned={headerPinned ? 'true' : 'false'}
>
{getHeaderGroups().map((headerGroup) => (
<Box

View file

@ -1,6 +1,7 @@
import { Center, Checkbox } from '@mantine/core';
import CustomTable from './CustomTable';
import CustomTableHeader from './CustomTableHeader';
import useTablePreferences from '../../../hooks/useTablePreferences';
import {
useReactTable,
@ -27,6 +28,10 @@ const useTable = ({
const [lastClickedId, setLastClickedId] = useState(null);
const [isShiftKeyDown, setIsShiftKeyDown] = useState(false);
// Use shared table preferences hook
const { headerPinned, setHeaderPinned, tableSize, setTableSize } =
useTablePreferences();
// Event handlers for shift key detection with improved handling
const handleKeyDown = useCallback((e) => {
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 {

View file

@ -68,8 +68,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
@ -185,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
@ -268,7 +270,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');
// Warnings store for "remember choice" functionality
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@ -286,7 +287,7 @@ const StreamsTable = ({ onReady }) => {
() => [
{
id: 'actions',
size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80),
size: columnSizing.actions || 75,
},
{
id: 'select',
@ -354,7 +355,7 @@ const StreamsTable = ({ onReady }) => {
),
},
],
[channelGroups, playlists, columnSizing, tableSize]
[channelGroups, playlists, columnSizing]
);
/**
@ -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 }) => {
</Flex>
);
case 'group':
case 'group': {
const selectedGroups = filters.channel_group
? filters.channel_group.split(',').filter(Boolean)
: [];
return (
<Flex align="center" style={{ width: '100%', flex: 1 }}>
<MultiSelect
@ -854,10 +866,43 @@ const StreamsTable = ({ onReady }) => {
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 (
<Flex gap={4} align="center">
<Text
size="xs"
style={{
padding: '2px 6px',
backgroundColor: 'var(--mantine-color-dark-4)',
borderRadius: '4px',
}}
>
{value}
</Text>
{selectedGroups.length > 1 && (
<Text
size="xs"
style={{
padding: '2px 6px',
backgroundColor: 'var(--mantine-color-dark-4)',
borderRadius: '4px',
}}
>
+{selectedGroups.length - 1}
</Text>
)}
</Flex>
);
}
return null;
}}
style={{ flex: 1, minWidth: 0 }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
@ -871,11 +916,15 @@ const StreamsTable = ({ onReady }) => {
/>
</Flex>
);
}
case 'm3u':
case 'm3u': {
const selectedM3Us = filters.m3u_account
? filters.m3u_account.split(',').filter(Boolean)
: [];
return (
<Flex align="center" style={{ width: '100%', flex: 1 }}>
<Select
<MultiSelect
placeholder="M3U"
searchable
clearable
@ -883,12 +932,45 @@ const StreamsTable = ({ onReady }) => {
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleM3UChange}
data={playlists.map((playlist) => ({
label: playlist.name,
value: `${playlist.id}`,
}))}
value={selectedM3Us}
data={m3uOptions}
variant="unstyled"
className="table-input-header"
className="table-input-header custom-multiselect"
valueComponent={({ value }) => {
const index = selectedM3Us.indexOf(value);
if (index === 0) {
const label =
m3uOptions.find((opt) => opt.value === value)?.label ||
value;
return (
<Flex gap={4} align="center">
<Text
size="xs"
style={{
padding: '2px 6px',
backgroundColor: 'var(--mantine-color-dark-4)',
borderRadius: '4px',
}}
>
{label}
</Text>
{selectedM3Us.length > 1 && (
<Text
size="xs"
style={{
padding: '2px 6px',
backgroundColor: 'var(--mantine-color-dark-4)',
borderRadius: '4px',
}}
>
+{selectedM3Us.length - 1}
</Text>
)}
</Flex>
);
}
return null;
}}
style={{ flex: 1, minWidth: 0 }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
@ -902,6 +984,7 @@ const StreamsTable = ({ onReady }) => {
/>
</Flex>
);
}
}
};
@ -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 (
<>
<Flex

View file

@ -0,0 +1,117 @@
import { useState, useEffect, useCallback } from 'react';
const useTablePreferences = () => {
// Initialize all preferences from localStorage
const [headerPinned, setHeaderPinnedState] = useState(() => {
try {
const prefs = localStorage.getItem('table-preferences');
if (prefs) {
const parsed = JSON.parse(prefs);
return parsed.headerPinned ?? false;
}
} catch (e) {
console.error('Error reading headerPinned from localStorage:', e);
}
return false;
});
const [tableSize, setTableSizeState] = useState(() => {
try {
// Check new location first
const prefs = localStorage.getItem('table-preferences');
if (prefs) {
const parsed = JSON.parse(prefs);
if (parsed.tableSize) {
return parsed.tableSize;
}
}
// Fallback to old location for migration
const oldSize = localStorage.getItem('table-size');
if (oldSize) {
return JSON.parse(oldSize);
}
} catch (e) {
console.error('Error reading tableSize from localStorage:', e);
}
return 'default';
});
// Listen for changes from other components
useEffect(() => {
const handleCustomEvent = (e) => {
if (
e.detail.headerPinned !== undefined &&
e.detail.headerPinned !== headerPinned
) {
setHeaderPinnedState(e.detail.headerPinned);
}
if (
e.detail.tableSize !== undefined &&
e.detail.tableSize !== tableSize
) {
setTableSizeState(e.detail.tableSize);
}
};
window.addEventListener('table-preferences-changed', handleCustomEvent);
return () =>
window.removeEventListener(
'table-preferences-changed',
handleCustomEvent
);
}, [headerPinned, tableSize]);
// Function to update headerPinned and persist to localStorage
const setHeaderPinned = useCallback((value) => {
setHeaderPinnedState(value);
try {
// Read current prefs, update headerPinned, and save back
let prefs = {};
const stored = localStorage.getItem('table-preferences');
if (stored) {
prefs = JSON.parse(stored);
}
prefs.headerPinned = value;
localStorage.setItem('table-preferences', JSON.stringify(prefs));
// Dispatch custom event for same-page sync
window.dispatchEvent(
new CustomEvent('table-preferences-changed', {
detail: { headerPinned: value },
})
);
} catch (e) {
console.error('Error saving headerPinned to localStorage:', e);
}
}, []);
// Function to update tableSize and persist to localStorage
const setTableSize = useCallback((value) => {
setTableSizeState(value);
try {
// Read current prefs, update tableSize, and save back
let prefs = {};
const stored = localStorage.getItem('table-preferences');
if (stored) {
prefs = JSON.parse(stored);
}
prefs.tableSize = value;
localStorage.setItem('table-preferences', JSON.stringify(prefs));
// Dispatch custom event for same-page sync
window.dispatchEvent(
new CustomEvent('table-preferences-changed', {
detail: { tableSize: value },
})
);
} catch (e) {
console.error('Error saving tableSize to localStorage:', e);
}
}, []);
return { headerPinned, setHeaderPinned, tableSize, setTableSize };
};
export default useTablePreferences;

View file

@ -118,7 +118,23 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
.custom-multiselect .mantine-MultiSelect-input {
min-height: 30px;
/* Set a minimum height */
max-height: 30px;
/* Set max height */
overflow: hidden;
flex-wrap: nowrap;
}
.custom-multiselect .mantine-MultiSelect-pillsList {
flex-wrap: nowrap;
overflow: hidden;
}
.custom-multiselect .mantine-MultiSelect-pill {
max-width: 100px;
flex-shrink: 0;
}
.custom-multiselect .mantine-MultiSelect-pill span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -80,7 +80,7 @@ export const useTimeHelpers = () => {
const toUserTime = useCallback(
(value) => {
if (!value) return dayjs.invalid();
if (!value) return dayjs(null);
try {
return initializeTime(value).tz(timeZone);
} catch (error) {

View file

@ -16,11 +16,11 @@ describe('UiSettingsFormUtils', () => {
it('should update existing setting when id is present', async () => {
const tzValue = 'America/New_York';
const settings = {
'system-time-zone': {
'system_settings': {
id: 123,
key: 'system-time-zone',
name: 'System Time Zone',
value: 'UTC'
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'UTC' }
}
};
@ -29,9 +29,9 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.updateSetting).toHaveBeenCalledTimes(1);
expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
id: 123,
key: 'system-time-zone',
name: 'System Time Zone',
value: 'America/New_York'
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'America/New_York' }
});
expect(SettingsUtils.createSetting).not.toHaveBeenCalled();
});
@ -39,10 +39,10 @@ describe('UiSettingsFormUtils', () => {
it('should create new setting when existing setting has no id', async () => {
const tzValue = 'Europe/London';
const settings = {
'system-time-zone': {
key: 'system-time-zone',
name: 'System Time Zone',
value: 'UTC'
'system_settings': {
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'UTC' }
}
};
@ -50,14 +50,14 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system-time-zone',
name: 'System Time Zone',
value: 'Europe/London'
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'Europe/London' }
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
it('should create new setting when system-time-zone does not exist', async () => {
it('should create new setting when system_settings does not exist', async () => {
const tzValue = 'Asia/Tokyo';
const settings = {};
@ -65,26 +65,26 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system-time-zone',
name: 'System Time Zone',
value: 'Asia/Tokyo'
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'Asia/Tokyo' }
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
it('should create new setting when system-time-zone is null', async () => {
it('should create new setting when system_settings is null', async () => {
const tzValue = 'Pacific/Auckland';
const settings = {
'system-time-zone': null
'system_settings': null
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system-time-zone',
name: 'System Time Zone',
value: 'Pacific/Auckland'
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'Pacific/Auckland' }
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
@ -92,10 +92,10 @@ describe('UiSettingsFormUtils', () => {
it('should create new setting when id is undefined', async () => {
const tzValue = 'America/Los_Angeles';
const settings = {
'system-time-zone': {
'system_settings': {
id: undefined,
key: 'system-time-zone',
value: 'UTC'
key: 'system_settings',
value: { time_zone: 'UTC' }
}
};
@ -108,11 +108,11 @@ describe('UiSettingsFormUtils', () => {
it('should preserve existing properties when updating', async () => {
const tzValue = 'UTC';
const settings = {
'system-time-zone': {
'system_settings': {
id: 456,
key: 'system-time-zone',
name: 'System Time Zone',
value: 'America/New_York',
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'America/New_York', some_other_setting: 'value' },
extraProp: 'should be preserved'
}
};
@ -121,9 +121,9 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
id: 456,
key: 'system-time-zone',
name: 'System Time Zone',
value: 'UTC',
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'UTC', some_other_setting: 'value' },
extraProp: 'should be preserved'
});
});
@ -131,8 +131,11 @@ describe('UiSettingsFormUtils', () => {
it('should handle empty string timezone value', async () => {
const tzValue = '';
const settings = {
'system-time-zone': {
id: 789
'system_settings': {
id: 789,
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'America/New_York' }
}
};
@ -140,7 +143,9 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
id: 789,
value: ''
key: 'system_settings',
name: 'System Settings',
value: { time_zone: '' }
});
});
});