mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge branch 'upstream/dev' into feature/667
Resolved conflicts in StreamsTable.jsx: - Kept API.queryStreamsTable() for automatic store updates - Preserved unassociated streams filter functionality - Maintained Group column overflow fix (no sorting icon) - Added upstream's improved M3U filter options and UX - Integrated upstream's filter validation logic - Fixed CSS to allow custom-multiselect headers to expand vertically"
This commit is contained in:
commit
c46e258d3d
26 changed files with 849 additions and 561 deletions
10
.github/workflows/frontend-tests.yml
vendored
10
.github/workflows/frontend-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
19
CHANGELOG.md
19
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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/`, {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
</Flex>
|
||||
);
|
||||
|
||||
case 'group':
|
||||
case 'group': {
|
||||
const selectedGroups = filters.channel_group
|
||||
? filters.channel_group.split(',').filter(Boolean)
|
||||
: [];
|
||||
return (
|
||||
<MultiSelect
|
||||
placeholder="Group"
|
||||
className="table-input-header"
|
||||
variant="unstyled"
|
||||
data={groupOptions}
|
||||
size="xs"
|
||||
searchable
|
||||
clearable
|
||||
size="xs"
|
||||
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={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -867,12 +917,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, {
|
||||
|
|
@ -886,6 +969,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -985,6 +1069,38 @@ const StreamsTable = ({ onReady }) => {
|
|||
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
|
||||
}, [pagination.pageIndex, pagination.pageSize, totalCount]);
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default function VODLogosTable() {
|
|||
deleteVODLogo,
|
||||
deleteVODLogos,
|
||||
cleanupUnusedVODLogos,
|
||||
getUnusedLogosCount,
|
||||
} = useVODLogosStore();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
|
@ -77,14 +78,9 @@ export default function VODLogosTable() {
|
|||
const [deleting, setDeleting] = useState(false);
|
||||
const [paginationString, setPaginationString] = useState('');
|
||||
const [isCleaningUp, setIsCleaningUp] = useState(false);
|
||||
const [unusedLogosCount, setUnusedLogosCount] = useState(0);
|
||||
const [loadingUnusedCount, setLoadingUnusedCount] = useState(false);
|
||||
const tableRef = React.useRef(null);
|
||||
|
||||
// Calculate unused logos count
|
||||
const unusedLogosCount = useMemo(() => {
|
||||
return logos.filter(
|
||||
(logo) => logo.movie_count === 0 && logo.series_count === 0
|
||||
).length;
|
||||
}, [logos]);
|
||||
useEffect(() => {
|
||||
fetchVODLogos({
|
||||
page: currentPage,
|
||||
|
|
@ -94,6 +90,23 @@ export default function VODLogosTable() {
|
|||
});
|
||||
}, [currentPage, pageSize, nameFilter, usageFilter, fetchVODLogos]);
|
||||
|
||||
// Fetch the total count of unused logos
|
||||
useEffect(() => {
|
||||
const fetchUnusedCount = async () => {
|
||||
setLoadingUnusedCount(true);
|
||||
try {
|
||||
const count = await getUnusedLogosCount();
|
||||
setUnusedLogosCount(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unused logos count:', error);
|
||||
} finally {
|
||||
setLoadingUnusedCount(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnusedCount();
|
||||
}, [getUnusedLogosCount]);
|
||||
|
||||
const handleSelectAll = useCallback(
|
||||
(checked) => {
|
||||
if (checked) {
|
||||
|
|
@ -185,6 +198,9 @@ export default function VODLogosTable() {
|
|||
message: `Cleaned up ${result.deleted_count} unused VOD logos`,
|
||||
color: 'green',
|
||||
});
|
||||
// Refresh the unused count after cleanup
|
||||
const newCount = await getUnusedLogosCount();
|
||||
setUnusedLogosCount(newCount);
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
|
|
|
|||
117
frontend/src/hooks/useTablePreferences.jsx
Normal file
117
frontend/src/hooks/useTablePreferences.jsx
Normal 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;
|
||||
|
|
@ -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 */
|
||||
max-height: fit-content;
|
||||
overflow: visible;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.custom-multiselect .mantine-MultiSelect-pillsList {
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
@ -116,6 +116,21 @@ const useVODLogosStore = create((set) => ({
|
|||
}
|
||||
},
|
||||
|
||||
getUnusedLogosCount: async () => {
|
||||
try {
|
||||
const response = await api.getVODLogos({
|
||||
used: 'false',
|
||||
page_size: 1, // Fetch only 1 item to minimize data transfer
|
||||
});
|
||||
|
||||
// Return the count from the paginated response
|
||||
return response.count || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unused logos count:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearVODLogos: () => {
|
||||
set({
|
||||
vodLogos: {},
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ describe('dateTimeUtils', () => {
|
|||
|
||||
const converted = result.current.toUserTime(null);
|
||||
|
||||
expect(converted).toBeDefined();
|
||||
expect(converted.isValid()).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,16 +14,15 @@ describe('StreamConnectionCardUtils', () => {
|
|||
describe('getBufferingSpeedThreshold', () => {
|
||||
it('should return parsed buffering_speed from proxy settings', () => {
|
||||
const proxySetting = {
|
||||
value: JSON.stringify({ buffering_speed: 2.5 })
|
||||
value: { buffering_speed: 2.5 }
|
||||
};
|
||||
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should return 1.0 for invalid JSON', () => {
|
||||
const proxySetting = { value: 'invalid json' };
|
||||
const proxySetting = { value: { buffering_speed: 'invalid' } };
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: '' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
export const IPV4_CIDR_REGEX = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/;
|
||||
// IPv4 CIDR regex - validates IP address and prefix length (0-32)
|
||||
export const IPV4_CIDR_REGEX = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/;
|
||||
|
||||
// IPv6 CIDR regex - validates IPv6 address and prefix length (0-128)
|
||||
export const IPV6_CIDR_REGEX =
|
||||
/(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/;
|
||||
/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/;
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
|
|
|||
|
|
@ -19,540 +19,393 @@ describe('SettingsUtils', () => {
|
|||
describe('checkSetting', () => {
|
||||
it('should call API checkSetting with values', async () => {
|
||||
const values = { key: 'test-setting', value: 'test-value' };
|
||||
|
||||
await SettingsUtils.checkSetting(values);
|
||||
|
||||
expect(API.checkSetting).toHaveBeenCalledWith(values);
|
||||
expect(API.checkSetting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return API response', async () => {
|
||||
const values = { key: 'test-setting', value: 'test-value' };
|
||||
const mockResponse = { valid: true };
|
||||
|
||||
API.checkSetting.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await SettingsUtils.checkSetting(values);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
const values = { key: 'test-setting', value: 'test-value' };
|
||||
const error = new Error('API error');
|
||||
|
||||
API.checkSetting.mockRejectedValue(error);
|
||||
|
||||
await expect(SettingsUtils.checkSetting(values)).rejects.toThrow('API error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSetting', () => {
|
||||
it('should call API updateSetting with values', async () => {
|
||||
const values = { id: 1, key: 'test-setting', value: 'new-value' };
|
||||
|
||||
await SettingsUtils.updateSetting(values);
|
||||
|
||||
expect(API.updateSetting).toHaveBeenCalledWith(values);
|
||||
expect(API.updateSetting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return API response', async () => {
|
||||
const values = { id: 1, key: 'test-setting', value: 'new-value' };
|
||||
const mockResponse = { id: 1, value: 'new-value' };
|
||||
|
||||
API.updateSetting.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await SettingsUtils.updateSetting(values);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
const values = { id: 1, key: 'test-setting', value: 'new-value' };
|
||||
const error = new Error('Update failed');
|
||||
|
||||
API.updateSetting.mockRejectedValue(error);
|
||||
|
||||
await expect(SettingsUtils.updateSetting(values)).rejects.toThrow('Update failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSetting', () => {
|
||||
it('should call API createSetting with values', async () => {
|
||||
const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
|
||||
|
||||
await SettingsUtils.createSetting(values);
|
||||
|
||||
expect(API.createSetting).toHaveBeenCalledWith(values);
|
||||
expect(API.createSetting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return API response', async () => {
|
||||
const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
|
||||
const mockResponse = { id: 1, ...values };
|
||||
|
||||
API.createSetting.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await SettingsUtils.createSetting(values);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
|
||||
const error = new Error('Create failed');
|
||||
|
||||
API.createSetting.mockRejectedValue(error);
|
||||
|
||||
await expect(SettingsUtils.createSetting(values)).rejects.toThrow('Create failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rehashStreams', () => {
|
||||
it('should call API rehashStreams', async () => {
|
||||
await SettingsUtils.rehashStreams();
|
||||
|
||||
expect(API.rehashStreams).toHaveBeenCalledWith();
|
||||
expect(API.rehashStreams).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return API response', async () => {
|
||||
const mockResponse = { success: true };
|
||||
|
||||
API.rehashStreams.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await SettingsUtils.rehashStreams();
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
const error = new Error('Rehash failed');
|
||||
|
||||
API.rehashStreams.mockRejectedValue(error);
|
||||
|
||||
await expect(SettingsUtils.rehashStreams()).rejects.toThrow('Rehash failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveChangedSettings', () => {
|
||||
it('should update existing settings', async () => {
|
||||
it('should group stream settings correctly and update', async () => {
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'old-value' }
|
||||
stream_settings: {
|
||||
id: 1,
|
||||
key: 'stream_settings',
|
||||
value: {
|
||||
default_user_agent: 5,
|
||||
m3u_hash_key: 'channel_name'
|
||||
}
|
||||
}
|
||||
};
|
||||
const changedSettings = {
|
||||
'setting-1': 'new-value'
|
||||
default_user_agent: 7,
|
||||
preferred_region: 'UK'
|
||||
};
|
||||
|
||||
API.updateSetting.mockResolvedValue({ id: 1, value: 'new-value' });
|
||||
API.updateSetting.mockResolvedValue({});
|
||||
|
||||
await SettingsUtils.saveChangedSettings(settings, changedSettings);
|
||||
|
||||
expect(API.updateSetting).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
key: 'setting-1',
|
||||
value: 'new-value'
|
||||
key: 'stream_settings',
|
||||
value: {
|
||||
default_user_agent: 7,
|
||||
m3u_hash_key: 'channel_name',
|
||||
preferred_region: 'UK'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should create new settings when not in settings object', async () => {
|
||||
const settings = {};
|
||||
it('should convert m3u_hash_key array to comma-separated string', async () => {
|
||||
const settings = {
|
||||
stream_settings: {
|
||||
id: 1,
|
||||
key: 'stream_settings',
|
||||
value: {}
|
||||
}
|
||||
};
|
||||
const changedSettings = {
|
||||
'new-setting': 'value'
|
||||
m3u_hash_key: ['channel_name', 'channel_number']
|
||||
};
|
||||
|
||||
API.createSetting.mockResolvedValue({ id: 1, key: 'new-setting', value: 'value' });
|
||||
API.updateSetting.mockResolvedValue({});
|
||||
|
||||
await SettingsUtils.saveChangedSettings(settings, changedSettings);
|
||||
|
||||
expect(API.createSetting).toHaveBeenCalledWith({
|
||||
key: 'new-setting',
|
||||
name: 'new setting',
|
||||
value: 'value'
|
||||
expect(API.updateSetting).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
key: 'stream_settings',
|
||||
value: {
|
||||
m3u_hash_key: 'channel_name,channel_number'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should create new settings when existing has no id', async () => {
|
||||
it('should convert ID fields to integers', async () => {
|
||||
const settings = {
|
||||
'setting-1': { key: 'setting-1', value: 'old-value' }
|
||||
stream_settings: {
|
||||
id: 1,
|
||||
key: 'stream_settings',
|
||||
value: {}
|
||||
}
|
||||
};
|
||||
const changedSettings = {
|
||||
'setting-1': 'new-value'
|
||||
default_user_agent: '5',
|
||||
default_stream_profile: '3'
|
||||
};
|
||||
|
||||
API.createSetting.mockResolvedValue({ id: 1, key: 'setting-1', value: 'new-value' });
|
||||
API.updateSetting.mockResolvedValue({});
|
||||
|
||||
await SettingsUtils.saveChangedSettings(settings, changedSettings);
|
||||
|
||||
expect(API.createSetting).toHaveBeenCalledWith({
|
||||
key: 'setting-1',
|
||||
name: 'setting 1',
|
||||
value: 'new-value'
|
||||
expect(API.updateSetting).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
key: 'stream_settings',
|
||||
value: {
|
||||
default_user_agent: 5,
|
||||
default_stream_profile: 3
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace hyphens with spaces in name', async () => {
|
||||
const settings = {};
|
||||
const changedSettings = {
|
||||
'multi-word-setting': 'value'
|
||||
};
|
||||
|
||||
API.createSetting.mockResolvedValue({ id: 1 });
|
||||
|
||||
await SettingsUtils.saveChangedSettings(settings, changedSettings);
|
||||
|
||||
expect(API.createSetting).toHaveBeenCalledWith({
|
||||
key: 'multi-word-setting',
|
||||
name: 'multi word setting',
|
||||
value: 'value'
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when update fails', async () => {
|
||||
it('should preserve boolean types', async () => {
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'old-value' }
|
||||
dvr_settings: {
|
||||
id: 2,
|
||||
key: 'dvr_settings',
|
||||
value: {}
|
||||
},
|
||||
stream_settings: {
|
||||
id: 1,
|
||||
key: 'stream_settings',
|
||||
value: {}
|
||||
}
|
||||
};
|
||||
const changedSettings = {
|
||||
'setting-1': 'new-value'
|
||||
comskip_enabled: true,
|
||||
auto_import_mapped_files: false
|
||||
};
|
||||
|
||||
API.updateSetting.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
SettingsUtils.saveChangedSettings(settings, changedSettings)
|
||||
).rejects.toThrow('Failed to update setting');
|
||||
});
|
||||
|
||||
it('should throw error when create fails', async () => {
|
||||
const settings = {};
|
||||
const changedSettings = {
|
||||
'new-setting': 'value'
|
||||
};
|
||||
|
||||
API.createSetting.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
SettingsUtils.saveChangedSettings(settings, changedSettings)
|
||||
).rejects.toThrow('Failed to create setting');
|
||||
});
|
||||
|
||||
it('should process multiple changed settings', async () => {
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'old-value-1' },
|
||||
'setting-2': { id: 2, key: 'setting-2', value: 'old-value-2' }
|
||||
};
|
||||
const changedSettings = {
|
||||
'setting-1': 'new-value-1',
|
||||
'setting-2': 'new-value-2',
|
||||
'setting-3': 'new-value-3'
|
||||
};
|
||||
|
||||
API.updateSetting.mockResolvedValue({ success: true });
|
||||
API.createSetting.mockResolvedValue({ success: true });
|
||||
API.updateSetting.mockResolvedValue({});
|
||||
|
||||
await SettingsUtils.saveChangedSettings(settings, changedSettings);
|
||||
|
||||
expect(API.updateSetting).toHaveBeenCalledTimes(2);
|
||||
expect(API.createSetting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle empty changedSettings', async () => {
|
||||
it('should handle proxy_settings specially', async () => {
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'value' }
|
||||
proxy_settings: {
|
||||
id: 5,
|
||||
key: 'proxy_settings',
|
||||
value: {
|
||||
buffering_speed: 1.0
|
||||
}
|
||||
}
|
||||
};
|
||||
const changedSettings = {};
|
||||
const changedSettings = {
|
||||
proxy_settings: {
|
||||
buffering_speed: 2.5,
|
||||
buffering_timeout: 15
|
||||
}
|
||||
};
|
||||
|
||||
API.updateSetting.mockResolvedValue({});
|
||||
|
||||
await SettingsUtils.saveChangedSettings(settings, changedSettings);
|
||||
|
||||
expect(API.updateSetting).not.toHaveBeenCalled();
|
||||
expect(API.createSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChangedSettings', () => {
|
||||
it('should detect changed values', () => {
|
||||
const values = {
|
||||
'setting-1': 'new-value'
|
||||
};
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'old-value' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.getChangedSettings(values, settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'setting-1': 'new-value'
|
||||
expect(API.updateSetting).toHaveBeenCalledWith({
|
||||
id: 5,
|
||||
key: 'proxy_settings',
|
||||
value: {
|
||||
buffering_speed: 2.5,
|
||||
buffering_timeout: 15
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should include new settings not in settings object', () => {
|
||||
const values = {
|
||||
'new-setting': 'value'
|
||||
};
|
||||
it('should create proxy_settings if it does not exist', async () => {
|
||||
const settings = {};
|
||||
const changedSettings = {
|
||||
proxy_settings: {
|
||||
buffering_speed: 2.5
|
||||
}
|
||||
};
|
||||
|
||||
const result = SettingsUtils.getChangedSettings(values, settings);
|
||||
API.createSetting.mockResolvedValue({});
|
||||
|
||||
expect(result).toEqual({
|
||||
'new-setting': 'value'
|
||||
await SettingsUtils.saveChangedSettings(settings, changedSettings);
|
||||
|
||||
expect(API.createSetting).toHaveBeenCalledWith({
|
||||
key: 'proxy_settings',
|
||||
name: 'Proxy Settings',
|
||||
value: {
|
||||
buffering_speed: 2.5
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip unchanged values', () => {
|
||||
const values = {
|
||||
'setting-1': 'same-value'
|
||||
};
|
||||
it('should handle network_access specially', async () => {
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'same-value' }
|
||||
network_access: {
|
||||
id: 6,
|
||||
key: 'network_access',
|
||||
value: []
|
||||
}
|
||||
};
|
||||
const changedSettings = {
|
||||
network_access: ['192.168.1.0/24', '10.0.0.0/8']
|
||||
};
|
||||
|
||||
const result = SettingsUtils.getChangedSettings(values, settings);
|
||||
API.updateSetting.mockResolvedValue({});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
await SettingsUtils.saveChangedSettings(settings, changedSettings);
|
||||
|
||||
it('should convert array values to comma-separated strings', () => {
|
||||
const values = {
|
||||
'm3u-hash-key': ['key1', 'key2', 'key3']
|
||||
};
|
||||
const settings = {
|
||||
'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'old-value' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.getChangedSettings(values, settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'm3u-hash-key': 'key1,key2,key3'
|
||||
expect(API.updateSetting).toHaveBeenCalledWith({
|
||||
id: 6,
|
||||
key: 'network_access',
|
||||
value: ['192.168.1.0/24', '10.0.0.0/8']
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip empty string values', () => {
|
||||
const values = {
|
||||
'setting-1': '',
|
||||
'setting-2': 'value'
|
||||
};
|
||||
const settings = {};
|
||||
|
||||
const result = SettingsUtils.getChangedSettings(values, settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'setting-2': 'value'
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip empty array values', () => {
|
||||
const values = {
|
||||
'setting-1': [],
|
||||
'setting-2': ['value']
|
||||
};
|
||||
const settings = {};
|
||||
|
||||
const result = SettingsUtils.getChangedSettings(values, settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'setting-2': 'value'
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert non-string values to strings', () => {
|
||||
const values = {
|
||||
'setting-1': 123,
|
||||
'setting-2': true,
|
||||
'setting-3': false
|
||||
};
|
||||
const settings = {};
|
||||
|
||||
const result = SettingsUtils.getChangedSettings(values, settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'setting-1': '123',
|
||||
'setting-2': 'true',
|
||||
'setting-3': 'false'
|
||||
});
|
||||
});
|
||||
|
||||
it('should compare string values correctly', () => {
|
||||
const values = {
|
||||
'setting-1': 'value',
|
||||
'setting-2': 123
|
||||
};
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'value' },
|
||||
'setting-2': { id: 2, key: 'setting-2', value: 123 }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.getChangedSettings(values, settings);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSettings', () => {
|
||||
it('should convert string "true" to boolean true', () => {
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'true' }
|
||||
it('should parse grouped settings correctly', () => {
|
||||
const mockSettings = {
|
||||
'stream_settings': {
|
||||
id: 1,
|
||||
key: 'stream_settings',
|
||||
value: {
|
||||
default_user_agent: 5,
|
||||
default_stream_profile: 3,
|
||||
m3u_hash_key: 'channel_name,channel_number',
|
||||
preferred_region: 'US',
|
||||
auto_import_mapped_files: true
|
||||
}
|
||||
},
|
||||
'dvr_settings': {
|
||||
id: 2,
|
||||
key: 'dvr_settings',
|
||||
value: {
|
||||
tv_template: '/media/tv/{show}/{season}/',
|
||||
comskip_enabled: false,
|
||||
pre_offset_minutes: 2,
|
||||
post_offset_minutes: 5
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
const result = SettingsUtils.parseSettings(mockSettings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'setting-1': true
|
||||
// Check stream settings
|
||||
expect(result.default_user_agent).toBe('5');
|
||||
expect(result.default_stream_profile).toBe('3');
|
||||
expect(result.m3u_hash_key).toEqual(['channel_name', 'channel_number']);
|
||||
expect(result.preferred_region).toBe('US');
|
||||
expect(result.auto_import_mapped_files).toBe(true);
|
||||
|
||||
// Check DVR settings
|
||||
expect(result.tv_template).toBe('/media/tv/{show}/{season}/');
|
||||
expect(result.comskip_enabled).toBe(false);
|
||||
expect(result.pre_offset_minutes).toBe(2);
|
||||
expect(result.post_offset_minutes).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle empty m3u_hash_key', () => {
|
||||
const mockSettings = {
|
||||
'stream_settings': {
|
||||
id: 1,
|
||||
key: 'stream_settings',
|
||||
value: {
|
||||
m3u_hash_key: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(mockSettings);
|
||||
expect(result.m3u_hash_key).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle proxy_settings', () => {
|
||||
const mockSettings = {
|
||||
'proxy_settings': {
|
||||
id: 5,
|
||||
key: 'proxy_settings',
|
||||
value: {
|
||||
buffering_speed: 2.5,
|
||||
buffering_timeout: 15
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(mockSettings);
|
||||
expect(result.proxy_settings).toEqual({
|
||||
buffering_speed: 2.5,
|
||||
buffering_timeout: 15
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert string "false" to boolean false', () => {
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'false' }
|
||||
it('should handle network_access', () => {
|
||||
const mockSettings = {
|
||||
'network_access': {
|
||||
id: 6,
|
||||
key: 'network_access',
|
||||
value: ['192.168.1.0/24', '10.0.0.0/8']
|
||||
}
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
const result = SettingsUtils.parseSettings(mockSettings);
|
||||
expect(result.network_access).toEqual(['192.168.1.0/24', '10.0.0.0/8']);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'setting-1': false
|
||||
describe('getChangedSettings', () => {
|
||||
it('should detect changes in primitive values', () => {
|
||||
const values = {
|
||||
time_zone: 'America/New_York',
|
||||
max_system_events: 2000,
|
||||
comskip_enabled: true
|
||||
};
|
||||
const settings = {
|
||||
time_zone: { value: 'UTC' },
|
||||
max_system_events: { value: 1000 },
|
||||
comskip_enabled: { value: false }
|
||||
};
|
||||
|
||||
const changes = SettingsUtils.getChangedSettings(values, settings);
|
||||
|
||||
expect(changes).toEqual({
|
||||
time_zone: 'America/New_York',
|
||||
max_system_events: 2000,
|
||||
comskip_enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse m3u-hash-key as array', () => {
|
||||
it('should not detect unchanged values', () => {
|
||||
const values = {
|
||||
time_zone: 'UTC',
|
||||
max_system_events: 1000
|
||||
};
|
||||
const settings = {
|
||||
'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'key1,key2,key3' }
|
||||
time_zone: { value: 'UTC' },
|
||||
max_system_events: { value: 1000 }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
const changes = SettingsUtils.getChangedSettings(values, settings);
|
||||
expect(changes).toEqual({});
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'm3u-hash-key': ['key1', 'key2', 'key3']
|
||||
it('should preserve type of numeric values', () => {
|
||||
const values = {
|
||||
max_system_events: 2000
|
||||
};
|
||||
const settings = {
|
||||
max_system_events: { value: 1000 }
|
||||
};
|
||||
|
||||
const changes = SettingsUtils.getChangedSettings(values, settings);
|
||||
expect(typeof changes.max_system_events).toBe('number');
|
||||
expect(changes.max_system_events).toBe(2000);
|
||||
});
|
||||
|
||||
it('should detect changes in array values', () => {
|
||||
const values = {
|
||||
m3u_hash_key: ['channel_name', 'channel_number']
|
||||
};
|
||||
const settings = {
|
||||
m3u_hash_key: { value: 'channel_name' }
|
||||
};
|
||||
|
||||
const changes = SettingsUtils.getChangedSettings(values, settings);
|
||||
// Arrays are converted to comma-separated strings internally
|
||||
expect(changes).toEqual({
|
||||
m3u_hash_key: 'channel_name,channel_number'
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter empty strings from m3u-hash-key array', () => {
|
||||
it('should skip proxy_settings and network_access', () => {
|
||||
const values = {
|
||||
time_zone: 'America/New_York',
|
||||
proxy_settings: {
|
||||
buffering_speed: 2.5
|
||||
},
|
||||
network_access: ['192.168.1.0/24']
|
||||
};
|
||||
const settings = {
|
||||
'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'key1,,key2,' }
|
||||
time_zone: { value: 'UTC' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'm3u-hash-key': ['key1', 'key2']
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for empty m3u-hash-key', () => {
|
||||
const settings = {
|
||||
'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: '' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'm3u-hash-key': []
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for null m3u-hash-key', () => {
|
||||
const settings = {
|
||||
'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: null }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'm3u-hash-key': []
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse dvr-pre-offset-minutes as integer', () => {
|
||||
const settings = {
|
||||
'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: '5' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'dvr-pre-offset-minutes': 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse dvr-post-offset-minutes as integer', () => {
|
||||
const settings = {
|
||||
'dvr-post-offset-minutes': { id: 1, key: 'dvr-post-offset-minutes', value: '10' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'dvr-post-offset-minutes': 10
|
||||
});
|
||||
});
|
||||
|
||||
it('should default offset minutes to 0 for empty string', () => {
|
||||
const settings = {
|
||||
'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: '' },
|
||||
'dvr-post-offset-minutes': { id: 2, key: 'dvr-post-offset-minutes', value: '' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'dvr-pre-offset-minutes': 0,
|
||||
'dvr-post-offset-minutes': 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should default offset minutes to 0 for NaN', () => {
|
||||
const settings = {
|
||||
'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: 'invalid' },
|
||||
'dvr-post-offset-minutes': { id: 2, key: 'dvr-post-offset-minutes', value: 'abc' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'dvr-pre-offset-minutes': 0,
|
||||
'dvr-post-offset-minutes': 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep other values unchanged', () => {
|
||||
const settings = {
|
||||
'setting-1': { id: 1, key: 'setting-1', value: 'test-value' },
|
||||
'setting-2': { id: 2, key: 'setting-2', value: 123 }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'setting-1': 'test-value',
|
||||
'setting-2': 123
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty settings object', () => {
|
||||
const result = SettingsUtils.parseSettings({});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should process multiple settings with mixed types', () => {
|
||||
const settings = {
|
||||
'enabled': { id: 1, key: 'enabled', value: 'true' },
|
||||
'disabled': { id: 2, key: 'disabled', value: 'false' },
|
||||
'm3u-hash-key': { id: 3, key: 'm3u-hash-key', value: 'key1,key2' },
|
||||
'dvr-pre-offset-minutes': { id: 4, key: 'dvr-pre-offset-minutes', value: '5' },
|
||||
'dvr-post-offset-minutes': { id: 5, key: 'dvr-post-offset-minutes', value: '10' },
|
||||
'other-setting': { id: 6, key: 'other-setting', value: 'value' }
|
||||
};
|
||||
|
||||
const result = SettingsUtils.parseSettings(settings);
|
||||
|
||||
expect(result).toEqual({
|
||||
'enabled': true,
|
||||
'disabled': false,
|
||||
'm3u-hash-key': ['key1', 'key2'],
|
||||
'dvr-pre-offset-minutes': 5,
|
||||
'dvr-post-offset-minutes': 10,
|
||||
'other-setting': 'value'
|
||||
});
|
||||
const changes = SettingsUtils.getChangedSettings(values, settings);
|
||||
expect(changes.proxy_settings).toBeUndefined();
|
||||
expect(changes.network_access).toBeUndefined();
|
||||
expect(changes.time_zone).toBe('America/New_York');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue