Compare commits

...

7 commits

Author SHA1 Message Date
SergeantPanda
8521df94ad
Merge pull request #868 from DawtCom:main
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Move caching to client to remove burden on dispatch server
2026-01-18 17:26:49 -06:00
DawtCom
c970cfcf9a Move caching to client to remove burden on dispatch server 2026-01-18 00:49:17 -06:00
SergeantPanda
fe60c4f3bc Enhancement: Update frontend tests workflow to ensure proper triggering on push and pull request events only when frontend code changes.
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Has been cancelled
2026-01-17 18:30:13 -06:00
SergeantPanda
7cf7aecdf2
Merge pull request #857 from Dispatcharr/dev
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Dev
2026-01-15 09:05:06 -06:00
SergeantPanda
54644df9a3 Test Fix: 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. 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.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
2026-01-15 08:55:38 -06:00
SergeantPanda
38fa0fe99d Bug Fix: Fixed NumPy baseline detection in Docker entrypoint. Now calls numpy.show_config() directly with case-insensitive grep instead of incorrectly wrapping the output.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-01-14 17:10:06 -06:00
SergeantPanda
a772f5c353 changelog: Update missed close on 0.17.0 changelog.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-01-13 17:03:30 -06:00
8 changed files with 302 additions and 426 deletions

View file

@ -2,9 +2,15 @@ name: Frontend Tests
on:
push:
branches: [ main ]
branches: [main, dev]
paths:
- 'frontend/**'
- '.github/workflows/frontend-tests.yml'
pull_request:
branches: [ main ]
branches: [main, dev]
paths:
- 'frontend/**'
- '.github/workflows/frontend-tests.yml'
jobs:
test:
@ -21,15 +27,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

View file

@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- 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.
### Fixed
- 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
@ -28,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Settings architecture refactored to use grouped JSON storage: Migrated from individual CharField settings to grouped JSONField settings for improved performance, maintainability, and type safety. Settings are now organized into logical groups (stream_settings, dvr_settings, backup_settings, system_settings, proxy_settings, network_access) with automatic migration handling. Backend provides helper methods (`get_stream_settings()`, `get_default_user_agent_id()`, etc.) for easy access. Frontend simplified by removing complex key mapping logic and standardizing on underscore-based field names throughout.
- Docker setup enhanced for legacy CPU support: Added `USE_LEGACY_NUMPY` environment variable to enable custom-built NumPy with no CPU baseline, allowing Dispatcharr to run on older CPUs (circa 2009) that lack support for newer baseline CPU features. When set to `true`, the entrypoint script will install the legacy NumPy build instead of the standard distribution.
- Docker setup enhanced for legacy CPU support: Added `USE_LEGACY_NUMPY` environment variable to enable custom-built NumPy with no CPU baseline, allowing Dispatcharr to run on older CPUs (circa 2009) that lack support for newer baseline CPU features. When set to `true`, the entrypoint script will install the legacy NumPy build instead of the standard distribution. (Fixes #805)
- VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase
- Form management refactored across application: Migrated Channel, Stream, M3U Profile, Stream Profile, Logo, and User Agent forms from Formik to React Hook Form (RHF) with Yup validation for improved form handling, better validation feedback, and enhanced code maintainability
- Stats and VOD pages refactored for clearer separation of concerns: extracted Stream/VOD connection cards (StreamConnectionCard, VodConnectionCard, VODCard, SeriesCard), moved page logic into dedicated utils, and lazy-loaded heavy components with ErrorBoundary fallbacks to improve readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)

View file

@ -9,7 +9,8 @@ from drf_yasg import openapi
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
from django.db.models import Q
import os, json, requests, logging
import os, json, requests, logging, mimetypes
from django.utils.http import http_date
from urllib.parse import unquote
from apps.accounts.permissions import (
Authenticated,
@ -1653,11 +1654,10 @@ class LogoViewSet(viewsets.ModelViewSet):
"""Streams the logo file, whether it's local or remote."""
logo = self.get_object()
logo_url = logo.url
if logo_url.startswith("/data"): # Local file
if not os.path.exists(logo_url):
raise Http404("Image not found")
stat = os.stat(logo_url)
# Get proper mime type (first item of the tuple)
content_type, _ = mimetypes.guess_type(logo_url)
if not content_type:
@ -1667,6 +1667,8 @@ class LogoViewSet(viewsets.ModelViewSet):
response = StreamingHttpResponse(
open(logo_url, "rb"), content_type=content_type
)
response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours
response["Last-Modified"] = http_date(stat.st_mtime)
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url)
)
@ -1706,6 +1708,10 @@ class LogoViewSet(viewsets.ModelViewSet):
remote_response.iter_content(chunk_size=8192),
content_type=content_type,
)
if(remote_response.headers.get("Cache-Control")):
response["Cache-Control"] = remote_response.headers.get("Cache-Control")
if(remote_response.headers.get("Last-Modified")):
response["Last-Modified"] = remote_response.headers.get("Last-Modified")
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url)
)

View file

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

View file

@ -293,6 +293,7 @@ describe('dateTimeUtils', () => {
const converted = result.current.toUserTime(null);
expect(converted).toBeDefined();
expect(converted.isValid()).toBe(false);
});

View file

@ -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();
});

View file

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

View file

@ -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');
});
});
});