Merge pull request #848 from Dispatcharr/settings-refactor

Refactor CoreSettings to use JSONField for value storage and update r…
This commit is contained in:
SergeantPanda 2026-01-13 13:34:50 -06:00 committed by GitHub
commit 2f9b544519
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 866 additions and 519 deletions

View file

@ -9,60 +9,47 @@ logger = logging.getLogger(__name__)
BACKUP_SCHEDULE_TASK_NAME = "backup-scheduled-task"
SETTING_KEYS = {
"enabled": "backup_schedule_enabled",
"frequency": "backup_schedule_frequency",
"time": "backup_schedule_time",
"day_of_week": "backup_schedule_day_of_week",
"retention_count": "backup_retention_count",
"cron_expression": "backup_schedule_cron_expression",
}
DEFAULTS = {
"enabled": True,
"frequency": "daily",
"time": "03:00",
"day_of_week": 0, # Sunday
"schedule_enabled": True,
"schedule_frequency": "daily",
"schedule_time": "03:00",
"schedule_day_of_week": 0, # Sunday
"retention_count": 3,
"cron_expression": "",
"schedule_cron_expression": "",
}
def _get_setting(key: str, default=None):
"""Get a backup setting from CoreSettings."""
def _get_backup_settings():
"""Get all backup settings from CoreSettings grouped JSON."""
try:
setting = CoreSettings.objects.get(key=SETTING_KEYS[key])
value = setting.value
if key == "enabled":
return value.lower() == "true"
elif key in ("day_of_week", "retention_count"):
return int(value)
return value
settings_obj = CoreSettings.objects.get(key="backup_settings")
return settings_obj.value if isinstance(settings_obj.value, dict) else DEFAULTS.copy()
except CoreSettings.DoesNotExist:
return default if default is not None else DEFAULTS.get(key)
return DEFAULTS.copy()
def _set_setting(key: str, value) -> None:
"""Set a backup setting in CoreSettings."""
str_value = str(value).lower() if isinstance(value, bool) else str(value)
CoreSettings.objects.update_or_create(
key=SETTING_KEYS[key],
defaults={
"name": f"Backup {key.replace('_', ' ').title()}",
"value": str_value,
},
def _update_backup_settings(updates: dict) -> None:
"""Update backup settings in the grouped JSON."""
obj, created = CoreSettings.objects.get_or_create(
key="backup_settings",
defaults={"name": "Backup Settings", "value": DEFAULTS.copy()}
)
current = obj.value if isinstance(obj.value, dict) else {}
current.update(updates)
obj.value = current
obj.save()
def get_schedule_settings() -> dict:
"""Get all backup schedule settings."""
settings = _get_backup_settings()
return {
"enabled": _get_setting("enabled"),
"frequency": _get_setting("frequency"),
"time": _get_setting("time"),
"day_of_week": _get_setting("day_of_week"),
"retention_count": _get_setting("retention_count"),
"cron_expression": _get_setting("cron_expression"),
"enabled": bool(settings.get("schedule_enabled", DEFAULTS["schedule_enabled"])),
"frequency": str(settings.get("schedule_frequency", DEFAULTS["schedule_frequency"])),
"time": str(settings.get("schedule_time", DEFAULTS["schedule_time"])),
"day_of_week": int(settings.get("schedule_day_of_week", DEFAULTS["schedule_day_of_week"])),
"retention_count": int(settings.get("retention_count", DEFAULTS["retention_count"])),
"cron_expression": str(settings.get("schedule_cron_expression", DEFAULTS["schedule_cron_expression"])),
}
@ -90,10 +77,22 @@ def update_schedule_settings(data: dict) -> dict:
if count < 0:
raise ValueError("retention_count must be >= 0")
# Update settings
for key in ("enabled", "frequency", "time", "day_of_week", "retention_count", "cron_expression"):
if key in data:
_set_setting(key, data[key])
# Update settings with proper key names
updates = {}
if "enabled" in data:
updates["schedule_enabled"] = bool(data["enabled"])
if "frequency" in data:
updates["schedule_frequency"] = str(data["frequency"])
if "time" in data:
updates["schedule_time"] = str(data["time"])
if "day_of_week" in data:
updates["schedule_day_of_week"] = int(data["day_of_week"])
if "retention_count" in data:
updates["retention_count"] = int(data["retention_count"])
if "cron_expression" in data:
updates["schedule_cron_expression"] = str(data["cron_expression"])
_update_backup_settings(updates)
# Sync the periodic task
_sync_periodic_task()

View file

@ -286,11 +286,12 @@ def fetch_xmltv(source):
logger.info(f"Fetching XMLTV data from source: {source.name}")
try:
# Get default user agent from settings
default_user_agent_setting = CoreSettings.objects.filter(key='default-user-agent').first()
stream_settings = CoreSettings.get_stream_settings()
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default
if default_user_agent_setting and default_user_agent_setting.value:
default_user_agent_id = stream_settings.get('default_user_agent')
if default_user_agent_id:
try:
user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_setting.value)).first()
user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first()
if user_agent_obj and user_agent_obj.user_agent:
user_agent = user_agent_obj.user_agent
logger.debug(f"Using default user agent: {user_agent}")
@ -1714,12 +1715,13 @@ def fetch_schedules_direct(source):
logger.info(f"Fetching Schedules Direct data from source: {source.name}")
try:
# Get default user agent from settings
default_user_agent_setting = CoreSettings.objects.filter(key='default-user-agent').first()
stream_settings = CoreSettings.get_stream_settings()
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default
default_user_agent_id = stream_settings.get('default_user_agent')
if default_user_agent_setting and default_user_agent_setting.value:
if default_user_agent_id:
try:
user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_setting.value)).first()
user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first()
if user_agent_obj and user_agent_obj.user_agent:
user_agent = user_agent_obj.user_agent
logger.debug(f"Using default user agent: {user_agent}")

View file

@ -7,7 +7,6 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.epg.models import ProgramData
from apps.accounts.models import User
from core.models import CoreSettings, NETWORK_ACCESS
from dispatcharr.utils import network_access_allowed
from django.utils import timezone as django_timezone
from django.shortcuts import get_object_or_404

View file

@ -15,8 +15,9 @@ from .models import (
UserAgent,
StreamProfile,
CoreSettings,
STREAM_HASH_KEY,
NETWORK_ACCESS,
STREAM_SETTINGS_KEY,
DVR_SETTINGS_KEY,
NETWORK_ACCESS_KEY,
PROXY_SETTINGS_KEY,
)
from .serializers import (
@ -68,16 +69,28 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
def update(self, request, *args, **kwargs):
instance = self.get_object()
old_value = instance.value
response = super().update(request, *args, **kwargs)
if instance.key == STREAM_HASH_KEY:
if instance.value != request.data["value"]:
rehash_streams.delay(request.data["value"].split(","))
# If DVR pre/post offsets changed, reschedule upcoming recordings
try:
from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY
if instance.key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY):
if instance.value != request.data.get("value"):
# If stream settings changed and m3u_hash_key is different, rehash streams
if instance.key == STREAM_SETTINGS_KEY:
new_value = request.data.get("value", {})
if isinstance(new_value, dict) and isinstance(old_value, dict):
old_hash = old_value.get("m3u_hash_key", "")
new_hash = new_value.get("m3u_hash_key", "")
if old_hash != new_hash:
hash_keys = new_hash.split(",") if isinstance(new_hash, str) else new_hash
rehash_streams.delay(hash_keys)
# If DVR settings changed and pre/post offsets are different, reschedule upcoming recordings
if instance.key == DVR_SETTINGS_KEY:
new_value = request.data.get("value", {})
if isinstance(new_value, dict) and isinstance(old_value, dict):
old_pre = old_value.get("pre_offset_minutes")
new_pre = new_value.get("pre_offset_minutes")
old_post = old_value.get("post_offset_minutes")
new_post = new_value.get("post_offset_minutes")
if old_pre != new_pre or old_post != new_post:
try:
# Prefer async task if Celery is available
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
@ -86,24 +99,23 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
# Fallback to synchronous implementation
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
reschedule_upcoming_recordings_for_offset_change_impl()
except Exception:
pass
return response
def create(self, request, *args, **kwargs):
response = super().create(request, *args, **kwargs)
# If creating DVR pre/post offset settings, also reschedule upcoming recordings
# If creating DVR settings with offset values, reschedule upcoming recordings
try:
key = request.data.get("key")
from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY
if key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY):
try:
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
reschedule_upcoming_recordings_for_offset_change.delay()
except Exception:
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
reschedule_upcoming_recordings_for_offset_change_impl()
if key == DVR_SETTINGS_KEY:
value = request.data.get("value", {})
if isinstance(value, dict) and ("pre_offset_minutes" in value or "post_offset_minutes" in value):
try:
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
reschedule_upcoming_recordings_for_offset_change.delay()
except Exception:
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
reschedule_upcoming_recordings_for_offset_change_impl()
except Exception:
pass
return response
@ -111,13 +123,13 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
def check(self, request, *args, **kwargs):
data = request.data
if data.get("key") == NETWORK_ACCESS:
if data.get("key") == NETWORK_ACCESS_KEY:
client_ip = ipaddress.ip_address(get_client_ip(request))
in_network = {}
invalid = []
value = json.loads(data.get("value", "{}"))
value = data.get("value", {})
for key, val in value.items():
in_network[key] = []
cidrs = val.split(",")
@ -142,7 +154,7 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
},
status=status.HTTP_200_OK,
)
response_data = {
**in_network,
"client_ip": str(client_ip)
@ -161,8 +173,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
"""Get or create the proxy settings CoreSettings entry"""
try:
settings_obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY)
settings_data = json.loads(settings_obj.value)
except (CoreSettings.DoesNotExist, json.JSONDecodeError):
settings_data = settings_obj.value
except CoreSettings.DoesNotExist:
# Create default settings
settings_data = {
"buffering_timeout": 15,
@ -175,7 +187,7 @@ class ProxySettingsViewSet(viewsets.ViewSet):
key=PROXY_SETTINGS_KEY,
defaults={
"name": "Proxy Settings",
"value": json.dumps(settings_data)
"value": settings_data
}
)
return settings_obj, settings_data
@ -197,8 +209,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
serializer = ProxySettingsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Update the JSON data
settings_obj.value = json.dumps(serializer.validated_data)
# Update the JSON data - store as dict directly
settings_obj.value = serializer.validated_data
settings_obj.save()
return Response(serializer.validated_data)
@ -213,8 +225,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
serializer = ProxySettingsSerializer(data=updated_data)
serializer.is_valid(raise_exception=True)
# Update the JSON data
settings_obj.value = json.dumps(serializer.validated_data)
# Update the JSON data - store as dict directly
settings_obj.value = serializer.validated_data
settings_obj.save()
return Response(serializer.validated_data)
@ -332,8 +344,8 @@ def rehash_streams_endpoint(request):
"""Trigger the rehash streams task"""
try:
# Get the current hash keys from settings
hash_key_setting = CoreSettings.objects.get(key=STREAM_HASH_KEY)
hash_keys = hash_key_setting.value.split(",")
hash_key = CoreSettings.get_m3u_hash_key()
hash_keys = hash_key.split(",") if isinstance(hash_key, str) else hash_key
# Queue the rehash task
task = rehash_streams.delay(hash_keys)
@ -344,10 +356,10 @@ def rehash_streams_endpoint(request):
"task_id": task.id
}, status=status.HTTP_200_OK)
except CoreSettings.DoesNotExist:
except Exception as e:
return Response({
"success": False,
"message": "Hash key settings not found"
"message": f"Error triggering rehash: {str(e)}"
}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:

View file

@ -1,13 +1,13 @@
# your_app/management/commands/update_column.py
from django.core.management.base import BaseCommand
from core.models import CoreSettings, NETWORK_ACCESS
from core.models import CoreSettings, NETWORK_ACCESS_KEY
class Command(BaseCommand):
help = "Reset network access settings"
def handle(self, *args, **options):
setting = CoreSettings.objects.get(key=NETWORK_ACCESS)
setting.value = "{}"
setting = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY)
setting.value = {}
setting.save()

View file

@ -0,0 +1,267 @@
# Generated migration to change CoreSettings value field to JSONField and consolidate settings
import json
from django.db import migrations, models
def convert_string_to_json(apps, schema_editor):
"""Convert existing string values to appropriate JSON types before changing column type"""
CoreSettings = apps.get_model("core", "CoreSettings")
for setting in CoreSettings.objects.all():
value = setting.value
if not value:
# Empty strings become empty string in JSON
setting.value = json.dumps("")
setting.save(update_fields=['value'])
continue
# Try to parse as JSON if it looks like JSON (objects/arrays)
if value.startswith('{') or value.startswith('['):
try:
parsed = json.loads(value)
# Store as JSON string temporarily (column is still CharField)
setting.value = json.dumps(parsed)
setting.save(update_fields=['value'])
continue
except (json.JSONDecodeError, ValueError):
pass
# Try to parse as number
try:
# Check if it's an integer
if '.' not in value and value.lstrip('-').isdigit():
setting.value = json.dumps(int(value))
setting.save(update_fields=['value'])
continue
# Check if it's a float
float_val = float(value)
setting.value = json.dumps(float_val)
setting.save(update_fields=['value'])
continue
except (ValueError, AttributeError):
pass
# Check for booleans
if value.lower() in ('true', 'false', '1', '0', 'yes', 'no', 'on', 'off'):
bool_val = value.lower() in ('true', '1', 'yes', 'on')
setting.value = json.dumps(bool_val)
setting.save(update_fields=['value'])
continue
# Default: store as JSON string
setting.value = json.dumps(value)
setting.save(update_fields=['value'])
def consolidate_settings(apps, schema_editor):
"""Consolidate individual setting rows into grouped JSON objects."""
CoreSettings = apps.get_model("core", "CoreSettings")
# Helper to get setting value
def get_value(key, default=None):
try:
obj = CoreSettings.objects.get(key=key)
return obj.value if obj.value is not None else default
except CoreSettings.DoesNotExist:
return default
# STREAM SETTINGS
stream_settings = {
"default_user_agent": get_value("default-user-agent"),
"default_stream_profile": get_value("default-stream-profile"),
"m3u_hash_key": get_value("m3u-hash-key", ""),
"preferred_region": get_value("preferred-region"),
"auto_import_mapped_files": get_value("auto-import-mapped-files"),
}
CoreSettings.objects.update_or_create(
key="stream_settings",
defaults={"name": "Stream Settings", "value": stream_settings}
)
# DVR SETTINGS
dvr_settings = {
"tv_template": get_value("dvr-tv-template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"),
"movie_template": get_value("dvr-movie-template", "Movies/{title} ({year}).mkv"),
"tv_fallback_dir": get_value("dvr-tv-fallback-dir", "TV_Shows"),
"tv_fallback_template": get_value("dvr-tv-fallback-template", "TV_Shows/{show}/{start}.mkv"),
"movie_fallback_template": get_value("dvr-movie-fallback-template", "Movies/{start}.mkv"),
"comskip_enabled": bool(get_value("dvr-comskip-enabled", False)),
"comskip_custom_path": get_value("dvr-comskip-custom-path", ""),
"pre_offset_minutes": int(get_value("dvr-pre-offset-minutes", 0) or 0),
"post_offset_minutes": int(get_value("dvr-post-offset-minutes", 0) or 0),
"series_rules": get_value("dvr-series-rules", []),
}
CoreSettings.objects.update_or_create(
key="dvr_settings",
defaults={"name": "DVR Settings", "value": dvr_settings}
)
# BACKUP SETTINGS - using underscore keys (not dashes)
backup_settings = {
"schedule_enabled": get_value("backup_schedule_enabled") if get_value("backup_schedule_enabled") is not None else True,
"schedule_frequency": get_value("backup_schedule_frequency") or "daily",
"schedule_time": get_value("backup_schedule_time") or "03:00",
"schedule_day_of_week": get_value("backup_schedule_day_of_week") if get_value("backup_schedule_day_of_week") is not None else 0,
"retention_count": get_value("backup_retention_count") if get_value("backup_retention_count") is not None else 3,
"schedule_cron_expression": get_value("backup_schedule_cron_expression") or "",
}
CoreSettings.objects.update_or_create(
key="backup_settings",
defaults={"name": "Backup Settings", "value": backup_settings}
)
# SYSTEM SETTINGS
system_settings = {
"time_zone": get_value("system-time-zone", "UTC"),
"max_system_events": int(get_value("max-system-events", 100) or 100),
}
CoreSettings.objects.update_or_create(
key="system_settings",
defaults={"name": "System Settings", "value": system_settings}
)
# Rename proxy-settings to proxy_settings (if it exists with old name)
try:
old_proxy = CoreSettings.objects.get(key="proxy-settings")
old_proxy.key = "proxy_settings"
old_proxy.save()
except CoreSettings.DoesNotExist:
pass
# Ensure proxy_settings exists with defaults if not present
proxy_obj, proxy_created = CoreSettings.objects.get_or_create(
key="proxy_settings",
defaults={
"name": "Proxy Settings",
"value": {
"buffering_timeout": 15,
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
}
}
)
# Rename network-access to network_access (if it exists with old name)
try:
old_network = CoreSettings.objects.get(key="network-access")
old_network.key = "network_access"
old_network.save()
except CoreSettings.DoesNotExist:
pass
# Ensure network_access exists with defaults if not present
network_obj, network_created = CoreSettings.objects.get_or_create(
key="network_access",
defaults={
"name": "Network Access",
"value": {}
}
)
# Delete old individual setting rows (keep only the new grouped settings)
grouped_keys = ["stream_settings", "dvr_settings", "backup_settings", "system_settings", "proxy_settings", "network_access"]
CoreSettings.objects.exclude(key__in=grouped_keys).delete()
def reverse_migration(apps, schema_editor):
"""Reverse migration: split grouped settings and convert JSON back to strings"""
CoreSettings = apps.get_model("core", "CoreSettings")
# Helper to create individual setting
def create_setting(key, name, value):
# Convert value back to string representation for CharField
if isinstance(value, str):
str_value = value
elif isinstance(value, bool):
str_value = "true" if value else "false"
elif isinstance(value, (int, float)):
str_value = str(value)
elif isinstance(value, (dict, list)):
str_value = json.dumps(value)
elif value is None:
str_value = ""
else:
str_value = str(value)
CoreSettings.objects.update_or_create(
key=key,
defaults={"name": name, "value": str_value}
)
# Split stream_settings
try:
stream = CoreSettings.objects.get(key="stream_settings")
if isinstance(stream.value, dict):
create_setting("default_user_agent", "Default User Agent", stream.value.get("default_user_agent"))
create_setting("default_stream_profile", "Default Stream Profile", stream.value.get("default_stream_profile"))
create_setting("stream_hash_key", "Stream Hash Key", stream.value.get("m3u_hash_key", ""))
create_setting("preferred_region", "Preferred Region", stream.value.get("preferred_region"))
create_setting("auto_import_mapped_files", "Auto Import Mapped Files", stream.value.get("auto_import_mapped_files"))
stream.delete()
except CoreSettings.DoesNotExist:
pass
# Split dvr_settings
try:
dvr = CoreSettings.objects.get(key="dvr_settings")
if isinstance(dvr.value, dict):
create_setting("dvr_tv_template", "DVR TV Template", dvr.value.get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"))
create_setting("dvr_movie_template", "DVR Movie Template", dvr.value.get("movie_template", "Movies/{title} ({year}).mkv"))
create_setting("dvr_tv_fallback_dir", "DVR TV Fallback Dir", dvr.value.get("tv_fallback_dir", "TV_Shows"))
create_setting("dvr_tv_fallback_template", "DVR TV Fallback Template", dvr.value.get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv"))
create_setting("dvr_movie_fallback_template", "DVR Movie Fallback Template", dvr.value.get("movie_fallback_template", "Movies/{start}.mkv"))
create_setting("dvr_comskip_enabled", "DVR Comskip Enabled", dvr.value.get("comskip_enabled", False))
create_setting("dvr_comskip_custom_path", "DVR Comskip Custom Path", dvr.value.get("comskip_custom_path", ""))
create_setting("dvr_pre_offset_minutes", "DVR Pre Offset Minutes", dvr.value.get("pre_offset_minutes", 0))
create_setting("dvr_post_offset_minutes", "DVR Post Offset Minutes", dvr.value.get("post_offset_minutes", 0))
create_setting("dvr_series_rules", "DVR Series Rules", dvr.value.get("series_rules", []))
dvr.delete()
except CoreSettings.DoesNotExist:
pass
# Split backup_settings
try:
backup = CoreSettings.objects.get(key="backup_settings")
if isinstance(backup.value, dict):
create_setting("backup_schedule_enabled", "Backup Schedule Enabled", backup.value.get("schedule_enabled", False))
create_setting("backup_schedule_frequency", "Backup Schedule Frequency", backup.value.get("schedule_frequency", "weekly"))
create_setting("backup_schedule_time", "Backup Schedule Time", backup.value.get("schedule_time", "02:00"))
create_setting("backup_schedule_day_of_week", "Backup Schedule Day of Week", backup.value.get("schedule_day_of_week", 0))
create_setting("backup_retention_count", "Backup Retention Count", backup.value.get("retention_count", 7))
create_setting("backup_schedule_cron_expression", "Backup Schedule Cron Expression", backup.value.get("schedule_cron_expression", ""))
backup.delete()
except CoreSettings.DoesNotExist:
pass
# Split system_settings
try:
system = CoreSettings.objects.get(key="system_settings")
if isinstance(system.value, dict):
create_setting("system_time_zone", "System Time Zone", system.value.get("time_zone", "UTC"))
create_setting("max_system_events", "Max System Events", system.value.get("max_system_events", 100))
system.delete()
except CoreSettings.DoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
('core', '0019_add_vlc_stream_profile'),
]
operations = [
# First, convert all data to valid JSON strings while column is still CharField
migrations.RunPython(convert_string_to_json, migrations.RunPython.noop),
# Then change the field type to JSONField
migrations.AlterField(
model_name='coresettings',
name='value',
field=models.JSONField(blank=True, default=dict),
),
# Finally, consolidate individual settings into grouped JSON objects
migrations.RunPython(consolidate_settings, reverse_migration),
]

View file

@ -148,24 +148,13 @@ class StreamProfile(models.Model):
return part
DEFAULT_USER_AGENT_KEY = slugify("Default User-Agent")
DEFAULT_STREAM_PROFILE_KEY = slugify("Default Stream Profile")
STREAM_HASH_KEY = slugify("M3U Hash Key")
PREFERRED_REGION_KEY = slugify("Preferred Region")
AUTO_IMPORT_MAPPED_FILES = slugify("Auto-Import Mapped Files")
NETWORK_ACCESS = slugify("Network Access")
PROXY_SETTINGS_KEY = slugify("Proxy Settings")
DVR_TV_TEMPLATE_KEY = slugify("DVR TV Template")
DVR_MOVIE_TEMPLATE_KEY = slugify("DVR Movie Template")
DVR_SERIES_RULES_KEY = slugify("DVR Series Rules")
DVR_TV_FALLBACK_DIR_KEY = slugify("DVR TV Fallback Dir")
DVR_TV_FALLBACK_TEMPLATE_KEY = slugify("DVR TV Fallback Template")
DVR_MOVIE_FALLBACK_TEMPLATE_KEY = slugify("DVR Movie Fallback Template")
DVR_COMSKIP_ENABLED_KEY = slugify("DVR Comskip Enabled")
DVR_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path")
DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes")
DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes")
SYSTEM_TIME_ZONE_KEY = slugify("System Time Zone")
# Setting group keys
STREAM_SETTINGS_KEY = "stream_settings"
DVR_SETTINGS_KEY = "dvr_settings"
BACKUP_SETTINGS_KEY = "backup_settings"
PROXY_SETTINGS_KEY = "proxy_settings"
NETWORK_ACCESS_KEY = "network_access"
SYSTEM_SETTINGS_KEY = "system_settings"
class CoreSettings(models.Model):
@ -176,208 +165,166 @@ class CoreSettings(models.Model):
name = models.CharField(
max_length=255,
)
value = models.CharField(
max_length=255,
value = models.JSONField(
default=dict,
blank=True,
)
def __str__(self):
return "Core Settings"
# Helper methods to get/set grouped settings
@classmethod
def _get_group(cls, key, defaults=None):
"""Get a settings group, returning defaults if not found."""
try:
return cls.objects.get(key=key).value or (defaults or {})
except cls.DoesNotExist:
return defaults or {}
@classmethod
def _update_group(cls, key, name, updates):
"""Update specific fields in a settings group."""
obj, created = cls.objects.get_or_create(
key=key,
defaults={"name": name, "value": {}}
)
current = obj.value if isinstance(obj.value, dict) else {}
current.update(updates)
obj.value = current
obj.save()
return current
# Stream Settings
@classmethod
def get_stream_settings(cls):
"""Get all stream-related settings."""
return cls._get_group(STREAM_SETTINGS_KEY, {
"default_user_agent": None,
"default_stream_profile": None,
"m3u_hash_key": "",
"preferred_region": None,
"auto_import_mapped_files": None,
})
@classmethod
def get_default_user_agent_id(cls):
"""Retrieve a system profile by name (or return None if not found)."""
return cls.objects.get(key=DEFAULT_USER_AGENT_KEY).value
return cls.get_stream_settings().get("default_user_agent")
@classmethod
def get_default_stream_profile_id(cls):
return cls.objects.get(key=DEFAULT_STREAM_PROFILE_KEY).value
return cls.get_stream_settings().get("default_stream_profile")
@classmethod
def get_m3u_hash_key(cls):
return cls.objects.get(key=STREAM_HASH_KEY).value
return cls.get_stream_settings().get("m3u_hash_key", "")
@classmethod
def get_preferred_region(cls):
"""Retrieve the preferred region setting (or return None if not found)."""
try:
return cls.objects.get(key=PREFERRED_REGION_KEY).value
except cls.DoesNotExist:
return None
return cls.get_stream_settings().get("preferred_region")
@classmethod
def get_auto_import_mapped_files(cls):
"""Retrieve the preferred region setting (or return None if not found)."""
try:
return cls.objects.get(key=AUTO_IMPORT_MAPPED_FILES).value
except cls.DoesNotExist:
return None
return cls.get_stream_settings().get("auto_import_mapped_files")
# DVR Settings
@classmethod
def get_proxy_settings(cls):
"""Retrieve proxy settings as dict (or return defaults if not found)."""
try:
import json
settings_json = cls.objects.get(key=PROXY_SETTINGS_KEY).value
return json.loads(settings_json)
except (cls.DoesNotExist, json.JSONDecodeError):
# Return defaults if not found or invalid JSON
return {
"buffering_timeout": 15,
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
}
def get_dvr_settings(cls):
"""Get all DVR-related settings."""
return cls._get_group(DVR_SETTINGS_KEY, {
"tv_template": "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv",
"movie_template": "Movies/{title} ({year}).mkv",
"tv_fallback_dir": "TV_Shows",
"tv_fallback_template": "TV_Shows/{show}/{start}.mkv",
"movie_fallback_template": "Movies/{start}.mkv",
"comskip_enabled": False,
"comskip_custom_path": "",
"pre_offset_minutes": 0,
"post_offset_minutes": 0,
"series_rules": [],
})
@classmethod
def get_dvr_tv_template(cls):
try:
return cls.objects.get(key=DVR_TV_TEMPLATE_KEY).value
except cls.DoesNotExist:
# Default: relative to recordings root (/data/recordings)
return "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
return cls.get_dvr_settings().get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv")
@classmethod
def get_dvr_movie_template(cls):
try:
return cls.objects.get(key=DVR_MOVIE_TEMPLATE_KEY).value
except cls.DoesNotExist:
return "Movies/{title} ({year}).mkv"
return cls.get_dvr_settings().get("movie_template", "Movies/{title} ({year}).mkv")
@classmethod
def get_dvr_tv_fallback_dir(cls):
"""Folder name to use when a TV episode has no season/episode information.
Defaults to 'TV_Show' to match existing behavior but can be overridden in settings.
"""
try:
return cls.objects.get(key=DVR_TV_FALLBACK_DIR_KEY).value or "TV_Shows"
except cls.DoesNotExist:
return "TV_Shows"
return cls.get_dvr_settings().get("tv_fallback_dir", "TV_Shows")
@classmethod
def get_dvr_tv_fallback_template(cls):
"""Full path template used when season/episode are missing for a TV airing."""
try:
return cls.objects.get(key=DVR_TV_FALLBACK_TEMPLATE_KEY).value
except cls.DoesNotExist:
# default requested by user
return "TV_Shows/{show}/{start}.mkv"
return cls.get_dvr_settings().get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv")
@classmethod
def get_dvr_movie_fallback_template(cls):
"""Full path template used when movie metadata is incomplete."""
try:
return cls.objects.get(key=DVR_MOVIE_FALLBACK_TEMPLATE_KEY).value
except cls.DoesNotExist:
return "Movies/{start}.mkv"
return cls.get_dvr_settings().get("movie_fallback_template", "Movies/{start}.mkv")
@classmethod
def get_dvr_comskip_enabled(cls):
"""Return boolean-like string value ('true'/'false') for comskip enablement."""
try:
val = cls.objects.get(key=DVR_COMSKIP_ENABLED_KEY).value
return str(val).lower() in ("1", "true", "yes", "on")
except cls.DoesNotExist:
return False
return bool(cls.get_dvr_settings().get("comskip_enabled", False))
@classmethod
def get_dvr_comskip_custom_path(cls):
"""Return configured comskip.ini path or empty string if unset."""
try:
return cls.objects.get(key=DVR_COMSKIP_CUSTOM_PATH_KEY).value
except cls.DoesNotExist:
return ""
return cls.get_dvr_settings().get("comskip_custom_path", "")
@classmethod
def set_dvr_comskip_custom_path(cls, path: str | None):
"""Persist the comskip.ini path setting, normalizing nulls to empty string."""
value = (path or "").strip()
obj, _ = cls.objects.get_or_create(
key=DVR_COMSKIP_CUSTOM_PATH_KEY,
defaults={"name": "DVR Comskip Custom Path", "value": value},
)
if obj.value != value:
obj.value = value
obj.save(update_fields=["value"])
cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"comskip_custom_path": value})
return value
@classmethod
def get_dvr_pre_offset_minutes(cls):
"""Minutes to start recording before scheduled start (default 0)."""
try:
val = cls.objects.get(key=DVR_PRE_OFFSET_MINUTES_KEY).value
return int(val)
except cls.DoesNotExist:
return 0
except Exception:
try:
return int(float(val))
except Exception:
return 0
return int(cls.get_dvr_settings().get("pre_offset_minutes", 0) or 0)
@classmethod
def get_dvr_post_offset_minutes(cls):
"""Minutes to stop recording after scheduled end (default 0)."""
try:
val = cls.objects.get(key=DVR_POST_OFFSET_MINUTES_KEY).value
return int(val)
except cls.DoesNotExist:
return 0
except Exception:
try:
return int(float(val))
except Exception:
return 0
@classmethod
def get_system_time_zone(cls):
"""Return configured system time zone or fall back to Django settings."""
try:
value = cls.objects.get(key=SYSTEM_TIME_ZONE_KEY).value
if value:
return value
except cls.DoesNotExist:
pass
return getattr(settings, "TIME_ZONE", "UTC") or "UTC"
@classmethod
def set_system_time_zone(cls, tz_name: str | None):
"""Persist the desired system time zone identifier."""
value = (tz_name or "").strip() or getattr(settings, "TIME_ZONE", "UTC") or "UTC"
obj, _ = cls.objects.get_or_create(
key=SYSTEM_TIME_ZONE_KEY,
defaults={"name": "System Time Zone", "value": value},
)
if obj.value != value:
obj.value = value
obj.save(update_fields=["value"])
return value
return int(cls.get_dvr_settings().get("post_offset_minutes", 0) or 0)
@classmethod
def get_dvr_series_rules(cls):
"""Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}"""
import json
try:
raw = cls.objects.get(key=DVR_SERIES_RULES_KEY).value
rules = json.loads(raw) if raw else []
if isinstance(rules, list):
return rules
return []
except cls.DoesNotExist:
# Initialize empty if missing
cls.objects.create(key=DVR_SERIES_RULES_KEY, name="DVR Series Rules", value="[]")
return []
return cls.get_dvr_settings().get("series_rules", [])
@classmethod
def set_dvr_series_rules(cls, rules):
import json
try:
obj, _ = cls.objects.get_or_create(key=DVR_SERIES_RULES_KEY, defaults={"name": "DVR Series Rules", "value": "[]"})
obj.value = json.dumps(rules)
obj.save(update_fields=["value"])
return rules
except Exception:
return rules
cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"series_rules": rules})
return rules
# Proxy Settings
@classmethod
def get_proxy_settings(cls):
"""Get proxy settings."""
return cls._get_group(PROXY_SETTINGS_KEY, {
"buffering_timeout": 15,
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
})
# System Settings
@classmethod
def get_system_settings(cls):
"""Get all system-related settings."""
return cls._get_group(SYSTEM_SETTINGS_KEY, {
"time_zone": getattr(settings, "TIME_ZONE", "UTC") or "UTC",
"max_system_events": 100,
})
@classmethod
def get_system_time_zone(cls):
return cls.get_system_settings().get("time_zone") or getattr(settings, "TIME_ZONE", "UTC") or "UTC"
@classmethod
def set_system_time_zone(cls, tz_name: str | None):
value = (tz_name or "").strip() or getattr(settings, "TIME_ZONE", "UTC") or "UTC"
cls._update_group(SYSTEM_SETTINGS_KEY, "System Settings", {"time_zone": value})
return value
class SystemEvent(models.Model):

View file

@ -3,7 +3,7 @@ import json
import ipaddress
from rest_framework import serializers
from .models import CoreSettings, UserAgent, StreamProfile, NETWORK_ACCESS
from .models import CoreSettings, UserAgent, StreamProfile, NETWORK_ACCESS_KEY
class UserAgentSerializer(serializers.ModelSerializer):
@ -40,10 +40,10 @@ class CoreSettingsSerializer(serializers.ModelSerializer):
fields = "__all__"
def update(self, instance, validated_data):
if instance.key == NETWORK_ACCESS:
if instance.key == NETWORK_ACCESS_KEY:
errors = False
invalid = {}
value = json.loads(validated_data.get("value"))
value = validated_data.get("value")
for key, val in value.items():
cidrs = val.split(",")
for cidr in cidrs:

View file

@ -417,8 +417,12 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
# Get max events from settings (default 100)
try:
max_events_setting = CoreSettings.objects.filter(key='max-system-events').first()
max_events = int(max_events_setting.value) if max_events_setting else 100
from .models import CoreSettings
system_settings = CoreSettings.objects.filter(key='system_settings').first()
if system_settings and isinstance(system_settings.value, dict):
max_events = int(system_settings.value.get('max_system_events', 100))
else:
max_events = 100
except Exception:
max_events = 100

View file

@ -132,7 +132,7 @@ def stream_view(request, channel_uuid):
stream_profile = channel.stream_profile
if not stream_profile:
logger.error("No stream profile set for channel ID=%s, using default", channel.id)
stream_profile = StreamProfile.objects.get(id=CoreSettings.objects.get(key="default-stream-profile").value)
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
logger.debug("Stream profile used: %s", stream_profile.name)

View file

@ -3,7 +3,7 @@ import json
import ipaddress
from django.http import JsonResponse
from django.core.exceptions import ValidationError
from core.models import CoreSettings, NETWORK_ACCESS
from core.models import CoreSettings, NETWORK_ACCESS_KEY
def json_error_response(message, status=400):
@ -39,7 +39,10 @@ def get_client_ip(request):
def network_access_allowed(request, settings_key):
network_access = json.loads(CoreSettings.objects.get(key=NETWORK_ACCESS).value)
try:
network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value
except CoreSettings.DoesNotExist:
network_access = {}
cidrs = (
network_access[settings_key].split(",")

View file

@ -3,8 +3,28 @@ import React, { useEffect, useMemo, useState } from 'react';
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
import usePlaylistsStore from '../../store/playlists.jsx';
import useSettingsStore from '../../store/settings.jsx';
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Select, Stack, Text, Tooltip } from '@mantine/core';
import { Gauge, HardDriveDownload, HardDriveUpload, SquareX, Timer, Users, Video } from 'lucide-react';
import {
ActionIcon,
Badge,
Box,
Card,
Center,
Flex,
Group,
Select,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import {
Gauge,
HardDriveDownload,
HardDriveUpload,
SquareX,
Timer,
Users,
Video,
} from 'lucide-react';
import { toFriendlyDuration } from '../../utils/dateTimeUtils.js';
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
import { TableHelper } from '../../helpers/index.jsx';
@ -87,7 +107,10 @@ const StreamConnectionCard = ({
// If we have a channel URL, try to find the matching stream
if (channel.url && streamData.length > 0) {
// Try to find matching stream based on URL
const matchingStream = getMatchingStreamByUrl(streamData, channel.url);
const matchingStream = getMatchingStreamByUrl(
streamData,
channel.url
);
if (matchingStream) {
setActiveStreamId(matchingStream.id.toString());
@ -178,9 +201,9 @@ const StreamConnectionCard = ({
console.error('Error checking streams after switch:', error);
}
};
}
};
// Handle stream switching
// Handle stream switching
const handleStreamChange = async (streamId) => {
try {
console.log('Switching to stream ID:', streamId);
@ -333,7 +356,7 @@ const StreamConnectionCard = ({
});
// Get logo URL from the logos object if available
const logoUrl = getLogoUrl(channel.logo_id , logos, previewedStream);
const logoUrl = getLogoUrl(channel.logo_id, logos, previewedStream);
useEffect(() => {
let isMounted = true;
@ -388,11 +411,11 @@ const StreamConnectionCard = ({
style={{
backgroundColor: '#27272A',
}}
color='#fff'
color="#fff"
maw={700}
w={'100%'}
>
<Stack pos='relative' >
<Stack pos="relative">
<Group justify="space-between">
<Box
style={{
@ -401,7 +424,7 @@ const StreamConnectionCard = ({
}}
w={100}
h={50}
display='flex'
display="flex"
>
<img
src={logoUrl || logo}
@ -531,7 +554,7 @@ const StreamConnectionCard = ({
variant="light"
color={
parseFloat(channel.ffmpeg_speed) >=
getBufferingSpeedThreshold(settings['proxy-settings'])
getBufferingSpeedThreshold(settings['proxy_settings'])
? 'green'
: 'red'
}
@ -587,4 +610,4 @@ const StreamConnectionCard = ({
);
};
export default StreamConnectionCard;
export default StreamConnectionCard;

View file

@ -50,9 +50,9 @@ const DvrSettingsForm = React.memo(({ active }) => {
form.setValues(formValues);
if (formValues['dvr-comskip-custom-path']) {
if (formValues['comskip_custom_path']) {
setComskipConfig((prev) => ({
path: formValues['dvr-comskip-custom-path'],
path: formValues['comskip_custom_path'],
exists: prev.exists,
}));
}
@ -69,7 +69,7 @@ const DvrSettingsForm = React.memo(({ active }) => {
exists: Boolean(response.exists),
});
if (response.path) {
form.setFieldValue('dvr-comskip-custom-path', response.path);
form.setFieldValue('comskip_custom_path', response.path);
}
}
} catch (error) {
@ -94,10 +94,10 @@ const DvrSettingsForm = React.memo(({ active }) => {
autoClose: 3000,
color: 'green',
});
form.setFieldValue('dvr-comskip-custom-path', response.path);
form.setFieldValue('comskip_custom_path', response.path);
useSettingsStore.getState().updateSetting({
...(settings['dvr-comskip-custom-path'] || {
key: 'dvr-comskip-custom-path',
...(settings['comskip_custom_path'] || {
key: 'comskip_custom_path',
name: 'DVR Comskip Custom Path',
}),
value: response.path,
@ -137,24 +137,19 @@ const DvrSettingsForm = React.memo(({ active }) => {
)}
<Switch
label="Enable Comskip (remove commercials after recording)"
{...form.getInputProps('dvr-comskip-enabled', {
{...form.getInputProps('comskip_enabled', {
type: 'checkbox',
})}
id={settings['dvr-comskip-enabled']?.id || 'dvr-comskip-enabled'}
name={settings['dvr-comskip-enabled']?.key || 'dvr-comskip-enabled'}
id="comskip_enabled"
name="comskip_enabled"
/>
<TextInput
label="Custom comskip.ini path"
description="Leave blank to use the built-in defaults."
placeholder="/app/docker/comskip.ini"
{...form.getInputProps('dvr-comskip-custom-path')}
id={
settings['dvr-comskip-custom-path']?.id || 'dvr-comskip-custom-path'
}
name={
settings['dvr-comskip-custom-path']?.key ||
'dvr-comskip-custom-path'
}
{...form.getInputProps('comskip_custom_path')}
id="comskip_custom_path"
name="comskip_custom_path"
/>
<Group align="flex-end" gap="sm">
<FileInput
@ -184,71 +179,50 @@ const DvrSettingsForm = React.memo(({ active }) => {
description="Begin recording this many minutes before the scheduled start."
min={0}
step={1}
{...form.getInputProps('dvr-pre-offset-minutes')}
id={
settings['dvr-pre-offset-minutes']?.id || 'dvr-pre-offset-minutes'
}
name={
settings['dvr-pre-offset-minutes']?.key || 'dvr-pre-offset-minutes'
}
{...form.getInputProps('pre_offset_minutes')}
id="pre_offset_minutes"
name="pre_offset_minutes"
/>
<NumberInput
label="End late (minutes)"
description="Continue recording this many minutes after the scheduled end."
min={0}
step={1}
{...form.getInputProps('dvr-post-offset-minutes')}
id={
settings['dvr-post-offset-minutes']?.id || 'dvr-post-offset-minutes'
}
name={
settings['dvr-post-offset-minutes']?.key ||
'dvr-post-offset-minutes'
}
{...form.getInputProps('post_offset_minutes')}
id="post_offset_minutes"
name="post_offset_minutes"
/>
<TextInput
label="TV Path Template"
description="Supports {show}, {season}, {episode}, {sub_title}, {channel}, {year}, {start}, {end}. Use format specifiers like {season:02d}. Relative paths are under your library dir."
placeholder="TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
{...form.getInputProps('dvr-tv-template')}
id={settings['dvr-tv-template']?.id || 'dvr-tv-template'}
name={settings['dvr-tv-template']?.key || 'dvr-tv-template'}
{...form.getInputProps('tv_template')}
id="tv_template"
name="tv_template"
/>
<TextInput
label="TV Fallback Template"
description="Template used when an episode has no season/episode. Supports {show}, {start}, {end}, {channel}, {year}."
placeholder="TV_Shows/{show}/{start}.mkv"
{...form.getInputProps('dvr-tv-fallback-template')}
id={
settings['dvr-tv-fallback-template']?.id ||
'dvr-tv-fallback-template'
}
name={
settings['dvr-tv-fallback-template']?.key ||
'dvr-tv-fallback-template'
}
{...form.getInputProps('tv_fallback_template')}
id="tv_fallback_template"
name="tv_fallback_template"
/>
<TextInput
label="Movie Path Template"
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
placeholder="Movies/{title} ({year}).mkv"
{...form.getInputProps('dvr-movie-template')}
id={settings['dvr-movie-template']?.id || 'dvr-movie-template'}
name={settings['dvr-movie-template']?.key || 'dvr-movie-template'}
{...form.getInputProps('movie_template')}
id="movie_template"
name="movie_template"
/>
<TextInput
label="Movie Fallback Template"
description="Template used when movie metadata is incomplete. Supports {start}, {end}, {channel}."
placeholder="Movies/{start}.mkv"
{...form.getInputProps('dvr-movie-fallback-template')}
id={
settings['dvr-movie-fallback-template']?.id ||
'dvr-movie-fallback-template'
}
name={
settings['dvr-movie-fallback-template']?.key ||
'dvr-movie-fallback-template'
}
{...form.getInputProps('movie_fallback_template')}
id="movie_fallback_template"
name="movie_fallback_template"
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" variant="default">
@ -260,4 +234,4 @@ const DvrSettingsForm = React.memo(({ active }) => {
);
});
export default DvrSettingsForm;
export default DvrSettingsForm;

View file

@ -36,9 +36,7 @@ const NetworkAccessForm = React.memo(({ active }) => {
}, [active]);
useEffect(() => {
const networkAccessSettings = JSON.parse(
settings['network-access'].value || '{}'
);
const networkAccessSettings = settings['network_access']?.value || {};
networkAccessForm.setValues(
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
@ -51,8 +49,8 @@ const NetworkAccessForm = React.memo(({ active }) => {
setSaved(false);
setNetworkAccessError(null);
const check = await checkSetting({
...settings['network-access'],
value: JSON.stringify(networkAccessForm.getValues()),
...settings['network_access'],
value: networkAccessForm.getValues(), // Send as object
});
if (check.error && check.message) {
@ -78,8 +76,8 @@ const NetworkAccessForm = React.memo(({ active }) => {
setSaving(true);
try {
await updateSetting({
...settings['network-access'],
value: JSON.stringify(networkAccessForm.getValues()),
...settings['network_access'],
value: networkAccessForm.getValues(), // Send as object
});
setSaved(true);
} catch (e) {

View file

@ -91,18 +91,13 @@ const ProxySettingsForm = React.memo(({ active }) => {
});
useEffect(() => {
if(!active) setSaved(false);
if (!active) setSaved(false);
}, [active]);
useEffect(() => {
if (settings) {
if (settings['proxy-settings']?.value) {
try {
const proxySettings = JSON.parse(settings['proxy-settings'].value);
proxySettingsForm.setValues(proxySettings);
} catch (error) {
console.error('Error parsing proxy settings:', error);
}
if (settings['proxy_settings']?.value) {
proxySettingsForm.setValues(settings['proxy_settings'].value);
}
}
}, [settings]);
@ -116,8 +111,8 @@ const ProxySettingsForm = React.memo(({ active }) => {
try {
const result = await updateSetting({
...settings['proxy-settings'],
value: JSON.stringify(proxySettingsForm.getValues()),
...settings['proxy_settings'],
value: proxySettingsForm.getValues(), // Send as object
});
// API functions return undefined on error
if (result) {
@ -163,4 +158,4 @@ const ProxySettingsForm = React.memo(({ active }) => {
);
});
export default ProxySettingsForm;
export default ProxySettingsForm;

View file

@ -129,8 +129,11 @@ const StreamSettingsForm = React.memo(({ active }) => {
const values = form.getValues();
const changedSettings = getChangedSettings(values, settings);
const m3uHashKeyChanged =
settings['m3u-hash-key']?.value !== values['m3u-hash-key'].join(',');
// Check if m3u_hash_key changed from the grouped stream_settings
const currentHashKey =
settings['stream_settings']?.value?.m3u_hash_key || '';
const newHashKey = values['m3u_hash_key']?.join(',') || '';
const m3uHashKeyChanged = currentHashKey !== newHashKey;
// If M3U hash key changed, show warning (unless suppressed)
if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
@ -161,10 +164,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
)}
<Select
searchable
{...form.getInputProps('default-user-agent')}
id={settings['default-user-agent']?.id || 'default-user-agent'}
name={settings['default-user-agent']?.key || 'default-user-agent'}
label={settings['default-user-agent']?.name || 'Default User Agent'}
{...form.getInputProps('default_user_agent')}
id="default_user_agent"
name="default_user_agent"
label="Default User Agent"
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
@ -172,16 +175,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
/>
<Select
searchable
{...form.getInputProps('default-stream-profile')}
id={
settings['default-stream-profile']?.id || 'default-stream-profile'
}
name={
settings['default-stream-profile']?.key || 'default-stream-profile'
}
label={
settings['default-stream-profile']?.name || 'Default Stream Profile'
}
{...form.getInputProps('default_stream_profile')}
id="default_stream_profile"
name="default_stream_profile"
label="Default Stream Profile"
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
@ -189,10 +186,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
/>
<Select
searchable
{...form.getInputProps('preferred-region')}
id={settings['preferred-region']?.id || 'preferred-region'}
name={settings['preferred-region']?.key || 'preferred-region'}
label={settings['preferred-region']?.name || 'Preferred Region'}
{...form.getInputProps('preferred_region')}
id="preferred_region"
name="preferred_region"
label="Preferred Region"
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
@ -204,19 +201,16 @@ const StreamSettingsForm = React.memo(({ active }) => {
Auto-Import Mapped Files
</Text>
<Switch
{...form.getInputProps('auto-import-mapped-files', {
{...form.getInputProps('auto_import_mapped_files', {
type: 'checkbox',
})}
id={
settings['auto-import-mapped-files']?.id ||
'auto-import-mapped-files'
}
id="auto_import_mapped_files"
/>
</Group>
<MultiSelect
id="m3u-hash-key"
name="m3u-hash-key"
id="m3u_hash_key"
name="m3u_hash_key"
label="M3U Hash Key"
data={[
{
@ -240,7 +234,7 @@ const StreamSettingsForm = React.memo(({ active }) => {
label: 'Group',
},
]}
{...form.getInputProps('m3u-hash-key')}
{...form.getInputProps('m3u_hash_key')}
/>
{rehashSuccess && (
@ -303,4 +297,4 @@ Please ensure you have time to let this complete before proceeding.`}
);
});
export default StreamSettingsForm;
export default StreamSettingsForm;

View file

@ -60,9 +60,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
<NumberInput
label="Maximum System Events"
description="Number of events to retain (minimum: 10, maximum: 1000)"
value={form.values['max-system-events'] || 100}
value={form.values['max_system_events'] || 100}
onChange={(value) => {
form.setFieldValue('max-system-events', value);
form.setFieldValue('max_system_events', value);
}}
min={10}
max={1000}
@ -81,4 +81,4 @@ const SystemSettingsForm = React.memo(({ active }) => {
);
});
export default SystemSettingsForm;
export default SystemSettingsForm;

View file

@ -45,12 +45,11 @@ const UiSettingsForm = React.memo(() => {
useEffect(() => {
if (settings) {
const tzSetting = settings['system-time-zone'];
if (tzSetting?.value) {
const systemSettings = settings['system_settings'];
const tzValue = systemSettings?.value?.time_zone;
if (tzValue) {
timeZoneSyncedRef.current = true;
setTimeZone((prev) =>
prev === tzSetting.value ? prev : tzSetting.value
);
setTimeZone((prev) => (prev === tzValue ? prev : tzValue));
} else if (!timeZoneSyncedRef.current && timeZone) {
timeZoneSyncedRef.current = true;
persistTimeZoneSetting(timeZone);
@ -141,4 +140,4 @@ const UiSettingsForm = React.memo(() => {
);
});
export default UiSettingsForm;
export default UiSettingsForm;

View file

@ -155,7 +155,7 @@ const StreamProfiles = () => {
};
const deleteStreamProfile = async (id) => {
if (id == settings['default-stream-profile'].value) {
if (id == settings.default_stream_profile) {
notifications.show({
title: 'Cannot delete default stream-profile',
color: 'red.5',

View file

@ -127,7 +127,7 @@ const UserAgentsTable = () => {
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
if (ids.includes(settings['default-user-agent'].value)) {
if (ids.includes(settings.default_user_agent)) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
@ -137,7 +137,7 @@ const UserAgentsTable = () => {
await API.deleteUserAgents(ids);
} else {
if (ids == settings['default-user-agent'].value) {
if (ids == settings.default_user_agent) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',

View file

@ -12,7 +12,11 @@ import {
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import { categorizeRecordings } from '../../utils/pages/DVRUtils.js';
import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../../utils/cards/RecordingCardUtils.js';
import {
getPosterUrl,
getRecordingUrl,
getShowVideoUrl,
} from '../../utils/cards/RecordingCardUtils.js';
vi.mock('../../store/channels');
vi.mock('../../store/settings');
@ -53,17 +57,28 @@ vi.mock('../../components/cards/RecordingCard', () => ({
<span>{recording.custom_properties?.Title || 'Recording'}</span>
<button onClick={() => onOpenDetails(recording)}>Open Details</button>
{recording.custom_properties?.rule && (
<button onClick={() => onOpenRecurring(recording)}>Open Recurring</button>
<button onClick={() => onOpenRecurring(recording)}>
Open Recurring
</button>
)}
</div>
),
}));
vi.mock('../../components/forms/RecordingDetailsModal', () => ({
default: ({ opened, onClose, recording, onEdit, onWatchLive, onWatchRecording }) =>
default: ({
opened,
onClose,
recording,
onEdit,
onWatchLive,
onWatchRecording,
}) =>
opened ? (
<div data-testid="details-modal">
<div data-testid="modal-title">{recording?.custom_properties?.Title}</div>
<div data-testid="modal-title">
{recording?.custom_properties?.Title}
</div>
<button onClick={onClose}>Close Modal</button>
<button onClick={onEdit}>Edit</button>
<button onClick={onWatchLive}>Watch Live</button>
@ -137,7 +152,7 @@ describe('DVRPage', () => {
const defaultSettingsState = {
settings: {
'system-time-zone': { value: 'America/New_York' },
system_settings: { value: { time_zone: 'America/New_York' } },
},
environment: {
env_mode: 'production',
@ -178,12 +193,10 @@ describe('DVRPage', () => {
getPosterUrl.mockImplementation((recording) =>
recording?.id ? `http://poster.url/${recording.id}` : null
);
getRecordingUrl.mockImplementation((custom_properties) =>
custom_properties?.recording_url
);
getShowVideoUrl.mockImplementation((channel) =>
channel?.stream_url
getRecordingUrl.mockImplementation(
(custom_properties) => custom_properties?.recording_url
);
getShowVideoUrl.mockImplementation((channel) => channel?.stream_url);
useChannelsStore.mockImplementation((selector) => {
return selector ? selector(defaultChannelsState) : defaultChannelsState;
@ -295,7 +308,9 @@ describe('DVRPage', () => {
const state = {
...defaultChannelsState,
recordings: [recording],
channels: { 1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' } },
channels: {
1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
},
};
return selector ? selector(state) : state;
});
@ -362,7 +377,7 @@ describe('DVRPage', () => {
end_time: now.add(1, 'hour').toISOString(),
custom_properties: {
Title: 'Recurring Show',
rule: { id: 100 }
rule: { id: 100 },
},
};
@ -538,4 +553,4 @@ describe('DVRPage', () => {
expect(mockShowVideo).not.toHaveBeenCalled();
});
});
});
});

View file

@ -256,7 +256,7 @@ describe('dateTimeUtils', () => {
const setTimeZone = vi.fn();
useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
useSettingsStore.mockReturnValue({
'system-time-zone': { value: 'America/Los_Angeles' }
'system_settings': { value: { time_zone: 'America/Los_Angeles' } }
});
renderHook(() => dateTimeUtils.useUserTimeZone());

View file

@ -7,15 +7,14 @@ import {
toFriendlyDuration,
} from '../dateTimeUtils.js';
// Parse proxy settings to get buffering_speed
// Get buffering_speed from proxy settings
export const getBufferingSpeedThreshold = (proxySetting) => {
try {
if (proxySetting?.value) {
const proxySettings = JSON.parse(proxySetting.value);
return parseFloat(proxySettings.buffering_speed) || 1.0;
return parseFloat(proxySetting.value.buffering_speed) || 1.0;
}
} catch (error) {
console.error('Error parsing proxy settings:', error);
console.error('Error getting buffering speed:', error);
}
return 1.0; // Default fallback
};

View file

@ -66,7 +66,7 @@ export const useUserTimeZone = () => {
);
useEffect(() => {
const tz = settings?.['system-time-zone']?.value;
const tz = settings?.['system_settings']?.value?.time_zone;
if (tz && tz !== timeZone) {
setTimeZone(tz);
}

View file

@ -10,13 +10,13 @@ export const uploadComskipIni = async (file) => {
export const getDvrSettingsFormInitialValues = () => {
return {
'dvr-tv-template': '',
'dvr-movie-template': '',
'dvr-tv-fallback-template': '',
'dvr-movie-fallback-template': '',
'dvr-comskip-enabled': false,
'dvr-comskip-custom-path': '',
'dvr-pre-offset-minutes': 0,
'dvr-post-offset-minutes': 0,
'tv_template': '',
'movie_template': '',
'tv_fallback_template': '',
'movie_fallback_template': '',
'comskip_enabled': false,
'comskip_custom_path': '',
'pre_offset_minutes': 0,
'post_offset_minutes': 0,
};
};

View file

@ -2,18 +2,18 @@ import { isNotEmpty } from '@mantine/form';
export const getStreamSettingsFormInitialValues = () => {
return {
'default-user-agent': '',
'default-stream-profile': '',
'preferred-region': '',
'auto-import-mapped-files': true,
'm3u-hash-key': [],
default_user_agent: '',
default_stream_profile: '',
preferred_region: '',
auto_import_mapped_files: true,
m3u_hash_key: [],
};
};
export const getStreamSettingsFormValidation = () => {
return {
'default-user-agent': isNotEmpty('Select a user agent'),
'default-stream-profile': isNotEmpty('Select a stream profile'),
'preferred-region': isNotEmpty('Select a region'),
default_user_agent: isNotEmpty('Select a user agent'),
default_stream_profile: isNotEmpty('Select a stream profile'),
preferred_region: isNotEmpty('Select a region'),
};
};

View file

@ -1,5 +1,5 @@
export const getSystemSettingsFormInitialValues = () => {
return {
'max-system-events': 100,
max_system_events: 100,
};
};

View file

@ -1,14 +1,17 @@
import { createSetting, updateSetting } from '../../pages/SettingsUtils.js';
export const saveTimeZoneSetting = async (tzValue, settings) => {
const existing = settings['system-time-zone'];
const existing = settings['system_settings'];
const currentValue = existing?.value || {};
const newValue = { ...currentValue, time_zone: tzValue };
if (existing?.id) {
await updateSetting({ ...existing, value: tzValue });
await updateSetting({ ...existing, value: newValue });
} else {
await createSetting({
key: 'system-time-zone',
name: 'System Time Zone',
value: tzValue,
key: 'system_settings',
name: 'System Settings',
value: newValue,
});
}
};

View file

@ -57,14 +57,14 @@ describe('DvrSettingsFormUtils', () => {
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
expect(result).toEqual({
'dvr-tv-template': '',
'dvr-movie-template': '',
'dvr-tv-fallback-template': '',
'dvr-movie-fallback-template': '',
'dvr-comskip-enabled': false,
'dvr-comskip-custom-path': '',
'dvr-pre-offset-minutes': 0,
'dvr-post-offset-minutes': 0,
'tv_template': '',
'movie_template': '',
'tv_fallback_template': '',
'movie_fallback_template': '',
'comskip_enabled': false,
'comskip_custom_path': '',
'pre_offset_minutes': 0,
'post_offset_minutes': 0,
});
});
@ -79,14 +79,14 @@ describe('DvrSettingsFormUtils', () => {
it('should have correct default types', () => {
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
expect(typeof result['dvr-tv-template']).toBe('string');
expect(typeof result['dvr-movie-template']).toBe('string');
expect(typeof result['dvr-tv-fallback-template']).toBe('string');
expect(typeof result['dvr-movie-fallback-template']).toBe('string');
expect(typeof result['dvr-comskip-enabled']).toBe('boolean');
expect(typeof result['dvr-comskip-custom-path']).toBe('string');
expect(typeof result['dvr-pre-offset-minutes']).toBe('number');
expect(typeof result['dvr-post-offset-minutes']).toBe('number');
expect(typeof result['tv_template']).toBe('string');
expect(typeof result['movie_template']).toBe('string');
expect(typeof result['tv_fallback_template']).toBe('string');
expect(typeof result['movie_fallback_template']).toBe('string');
expect(typeof result['comskip_enabled']).toBe('boolean');
expect(typeof result['comskip_custom_path']).toBe('string');
expect(typeof result['pre_offset_minutes']).toBe('number');
expect(typeof result['post_offset_minutes']).toBe('number');
});
});
});

View file

@ -16,26 +16,26 @@ describe('StreamSettingsFormUtils', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result).toEqual({
'default-user-agent': '',
'default-stream-profile': '',
'preferred-region': '',
'auto-import-mapped-files': true,
'm3u-hash-key': []
'default_user_agent': '',
'default_stream_profile': '',
'preferred_region': '',
'auto_import_mapped_files': true,
'm3u_hash_key': []
});
});
it('should return boolean true for auto-import-mapped-files', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result['auto-import-mapped-files']).toBe(true);
expect(typeof result['auto-import-mapped-files']).toBe('boolean');
expect(result['auto_import_mapped_files']).toBe(true);
expect(typeof result['auto_import_mapped_files']).toBe('boolean');
});
it('should return empty array for m3u-hash-key', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result['m3u-hash-key']).toEqual([]);
expect(Array.isArray(result['m3u-hash-key'])).toBe(true);
expect(result['m3u_hash_key']).toEqual([]);
expect(Array.isArray(result['m3u_hash_key'])).toBe(true);
});
it('should return a new object each time', () => {
@ -50,7 +50,7 @@ describe('StreamSettingsFormUtils', () => {
const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result1['m3u-hash-key']).not.toBe(result2['m3u-hash-key']);
expect(result1['m3u_hash_key']).not.toBe(result2['m3u_hash_key']);
});
});
@ -59,25 +59,25 @@ describe('StreamSettingsFormUtils', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(Object.keys(result)).toEqual([
'default-user-agent',
'default-stream-profile',
'preferred-region'
'default_user_agent',
'default_stream_profile',
'preferred_region'
]);
});
it('should use isNotEmpty validator for default-user-agent', () => {
it('should use isNotEmpty validator for default_user_agent', () => {
StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(isNotEmpty).toHaveBeenCalledWith('Select a user agent');
});
it('should use isNotEmpty validator for default-stream-profile', () => {
it('should use isNotEmpty validator for default_stream_profile', () => {
StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(isNotEmpty).toHaveBeenCalledWith('Select a stream profile');
});
it('should use isNotEmpty validator for preferred-region', () => {
it('should use isNotEmpty validator for preferred_region', () => {
StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(isNotEmpty).toHaveBeenCalledWith('Select a region');
@ -86,21 +86,21 @@ describe('StreamSettingsFormUtils', () => {
it('should not include validation for auto-import-mapped-files', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(result).not.toHaveProperty('auto-import-mapped-files');
expect(result).not.toHaveProperty('auto_import_mapped_files');
});
it('should not include validation for m3u-hash-key', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(result).not.toHaveProperty('m3u-hash-key');
expect(result).not.toHaveProperty('m3u_hash_key');
});
it('should return correct validation error messages', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(result['default-user-agent']).toBe('Select a user agent');
expect(result['default-stream-profile']).toBe('Select a stream profile');
expect(result['preferred-region']).toBe('Select a region');
expect(result['default_user_agent']).toBe('Select a user agent');
expect(result['default_stream_profile']).toBe('Select a stream profile');
expect(result['preferred_region']).toBe('Select a region');
});
});
});

View file

@ -7,15 +7,15 @@ describe('SystemSettingsFormUtils', () => {
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toEqual({
'max-system-events': 100
'max_system_events': 100
});
});
it('should return number value for max-system-events', () => {
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result['max-system-events']).toBe(100);
expect(typeof result['max-system-events']).toBe('number');
expect(result['max_system_events']).toBe(100);
expect(typeof result['max_system_events']).toBe('number');
});
it('should return a new object each time', () => {
@ -29,7 +29,7 @@ describe('SystemSettingsFormUtils', () => {
it('should have max-system-events property', () => {
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toHaveProperty('max-system-events');
expect(result).toHaveProperty('max_system_events');
});
});
});

View file

@ -17,26 +17,95 @@ export const rehashStreams = async () => {
};
export const saveChangedSettings = async (settings, changedSettings) => {
for (const updatedKey in changedSettings) {
const existing = settings[updatedKey];
// Group changes by their setting group based on field name prefixes
const groupedChanges = {
stream_settings: {},
dvr_settings: {},
backup_settings: {},
system_settings: {},
};
// Map of field prefixes to their groups
const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files'];
const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template',
'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules'];
const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week',
'retention_count', 'schedule_cron_expression'];
const systemFields = ['time_zone', 'max_system_events'];
for (const formKey in changedSettings) {
let value = changedSettings[formKey];
// Handle special grouped settings (proxy_settings and network_access)
if (formKey === 'proxy_settings') {
const existing = settings['proxy_settings'];
if (existing?.id) {
await updateSetting({ ...existing, value });
} else {
await createSetting({ key: 'proxy_settings', name: 'Proxy Settings', value });
}
continue;
}
if (formKey === 'network_access') {
const existing = settings['network_access'];
if (existing?.id) {
await updateSetting({ ...existing, value });
} else {
await createSetting({ key: 'network_access', name: 'Network Access', value });
}
continue;
}
// Type conversions for proper storage
if (formKey === 'm3u_hash_key' && Array.isArray(value)) {
value = value.join(',');
}
if (['default_user_agent', 'default_stream_profile'].includes(formKey) && value != null) {
value = parseInt(value, 10);
}
const numericFields = ['pre_offset_minutes', 'post_offset_minutes', 'retention_count', 'schedule_day_of_week', 'max_system_events'];
if (numericFields.includes(formKey) && value != null) {
value = typeof value === 'number' ? value : parseInt(value, 10);
}
const booleanFields = ['comskip_enabled', 'schedule_enabled', 'auto_import_mapped_files'];
if (booleanFields.includes(formKey) && value != null) {
value = typeof value === 'boolean' ? value : Boolean(value);
}
// Route to appropriate group
if (streamFields.includes(formKey)) {
groupedChanges.stream_settings[formKey] = value;
} else if (dvrFields.includes(formKey)) {
groupedChanges.dvr_settings[formKey] = value;
} else if (backupFields.includes(formKey)) {
groupedChanges.backup_settings[formKey] = value;
} else if (systemFields.includes(formKey)) {
groupedChanges.system_settings[formKey] = value;
}
}
// Update each group that has changes
for (const [groupKey, changes] of Object.entries(groupedChanges)) {
if (Object.keys(changes).length === 0) continue;
const existing = settings[groupKey];
const currentValue = existing?.value || {};
const newValue = { ...currentValue, ...changes };
if (existing?.id) {
const result = await updateSetting({
...existing,
value: changedSettings[updatedKey],
});
// API functions return undefined on error
const result = await updateSetting({ ...existing, value: newValue });
if (!result) {
throw new Error('Failed to update setting');
throw new Error(`Failed to update ${groupKey}`);
}
} else {
const result = await createSetting({
key: updatedKey,
name: updatedKey.replace(/-/g, ' '),
value: changedSettings[updatedKey],
});
// API functions return undefined on error
const name = groupKey.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
const result = await createSetting({ key: groupKey, name: name, value: newValue });
if (!result) {
throw new Error('Failed to create setting');
throw new Error(`Failed to create ${groupKey}`);
}
}
}
@ -46,59 +115,104 @@ export const getChangedSettings = (values, settings) => {
const changedSettings = {};
for (const settingKey in values) {
// Skip grouped settings that are handled by their own dedicated forms
if (settingKey === 'proxy_settings' || settingKey === 'network_access') {
continue;
}
// Only compare against existing value if the setting exists
const existing = settings[settingKey];
// Convert array values (like m3u-hash-key) to comma-separated strings
const stringValue = Array.isArray(values[settingKey])
? values[settingKey].join(',')
: `${values[settingKey]}`;
// Convert array values (like m3u_hash_key) to comma-separated strings for comparison
let compareValue;
let actualValue = values[settingKey];
if (Array.isArray(actualValue)) {
actualValue = actualValue.join(',');
compareValue = actualValue;
} else {
compareValue = String(actualValue);
}
// Skip empty values to avoid validation errors
if (!stringValue) {
if (!compareValue) {
continue;
}
if (!existing) {
// Create new setting on save
changedSettings[settingKey] = stringValue;
} else if (stringValue !== String(existing.value)) {
// If the user changed the setting's value from what's in the DB:
changedSettings[settingKey] = stringValue;
// Create new setting on save - preserve original type
changedSettings[settingKey] = actualValue;
} else if (compareValue !== String(existing.value)) {
// If the user changed the setting's value from what's in the DB - preserve original type
changedSettings[settingKey] = actualValue;
}
}
return changedSettings;
};
export const parseSettings = (settings) => {
return Object.entries(settings).reduce((acc, [key, value]) => {
// Modify each value based on its own properties
switch (value.value) {
case 'true':
value.value = true;
break;
case 'false':
value.value = false;
break;
}
const parsed = {};
let val = null;
switch (key) {
case 'm3u-hash-key':
// Split comma-separated string, filter out empty strings
val = value.value ? value.value.split(',').filter((v) => v) : [];
break;
case 'dvr-pre-offset-minutes':
case 'dvr-post-offset-minutes':
val = Number.parseInt(value.value || '0', 10);
if (Number.isNaN(val)) val = 0;
break;
default:
val = value.value;
break;
}
// Stream settings - direct mapping with underscore keys
const streamSettings = settings['stream_settings']?.value;
if (streamSettings && typeof streamSettings === 'object') {
// IDs must be strings for Select components
parsed.default_user_agent = streamSettings.default_user_agent != null ? String(streamSettings.default_user_agent) : null;
parsed.default_stream_profile = streamSettings.default_stream_profile != null ? String(streamSettings.default_stream_profile) : null;
parsed.preferred_region = streamSettings.preferred_region;
parsed.auto_import_mapped_files = streamSettings.auto_import_mapped_files;
acc[key] = val;
return acc;
}, {});
// m3u_hash_key should be array
const hashKey = streamSettings.m3u_hash_key;
if (typeof hashKey === 'string') {
parsed.m3u_hash_key = hashKey ? hashKey.split(',').filter((v) => v) : [];
} else if (Array.isArray(hashKey)) {
parsed.m3u_hash_key = hashKey;
} else {
parsed.m3u_hash_key = [];
}
}
// DVR settings - direct mapping with underscore keys
const dvrSettings = settings['dvr_settings']?.value;
if (dvrSettings && typeof dvrSettings === 'object') {
parsed.tv_template = dvrSettings.tv_template;
parsed.movie_template = dvrSettings.movie_template;
parsed.tv_fallback_dir = dvrSettings.tv_fallback_dir;
parsed.tv_fallback_template = dvrSettings.tv_fallback_template;
parsed.movie_fallback_template = dvrSettings.movie_fallback_template;
parsed.comskip_enabled = typeof dvrSettings.comskip_enabled === 'boolean' ? dvrSettings.comskip_enabled : Boolean(dvrSettings.comskip_enabled);
parsed.comskip_custom_path = dvrSettings.comskip_custom_path;
parsed.pre_offset_minutes = typeof dvrSettings.pre_offset_minutes === 'number' ? dvrSettings.pre_offset_minutes : parseInt(dvrSettings.pre_offset_minutes, 10) || 0;
parsed.post_offset_minutes = typeof dvrSettings.post_offset_minutes === 'number' ? dvrSettings.post_offset_minutes : parseInt(dvrSettings.post_offset_minutes, 10) || 0;
parsed.series_rules = dvrSettings.series_rules;
}
// Backup settings - direct mapping with underscore keys
const backupSettings = settings['backup_settings']?.value;
if (backupSettings && typeof backupSettings === 'object') {
parsed.schedule_enabled = typeof backupSettings.schedule_enabled === 'boolean' ? backupSettings.schedule_enabled : Boolean(backupSettings.schedule_enabled);
parsed.schedule_frequency = String(backupSettings.schedule_frequency || '');
parsed.schedule_time = String(backupSettings.schedule_time || '');
parsed.schedule_day_of_week = typeof backupSettings.schedule_day_of_week === 'number' ? backupSettings.schedule_day_of_week : parseInt(backupSettings.schedule_day_of_week, 10) || 0;
parsed.retention_count = typeof backupSettings.retention_count === 'number' ? backupSettings.retention_count : parseInt(backupSettings.retention_count, 10) || 0;
parsed.schedule_cron_expression = String(backupSettings.schedule_cron_expression || '');
}
// System settings - direct mapping with underscore keys
const systemSettings = settings['system_settings']?.value;
if (systemSettings && typeof systemSettings === 'object') {
parsed.time_zone = String(systemSettings.time_zone || '');
parsed.max_system_events = typeof systemSettings.max_system_events === 'number' ? systemSettings.max_system_events : parseInt(systemSettings.max_system_events, 10) || 100;
}
// Proxy and network access are already grouped objects
if (settings['proxy_settings']?.value) {
parsed.proxy_settings = settings['proxy_settings'].value;
}
if (settings['network_access']?.value) {
parsed.network_access = settings['network_access'].value;
}
return parsed;
};