Refactor CoreSettings to use JSONField for value storage and update related logic for proper type handling. Adjusted serializers and forms to accommodate new data structure, ensuring seamless integration across the application.

This commit is contained in:
SergeantPanda 2026-01-13 12:18:34 -06:00
parent 4bfdd15b37
commit 36967c10ce
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;
};