mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
Merge pull request #848 from Dispatcharr/settings-refactor
Refactor CoreSettings to use JSONField for value storage and update r…
This commit is contained in:
commit
2f9b544519
32 changed files with 866 additions and 519 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
267
core/migrations/0020_change_coresettings_value_to_jsonfield.py
Normal file
267
core/migrations/0020_change_coresettings_value_to_jsonfield.py
Normal 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),
|
||||
]
|
||||
269
core/models.py
269
core/models.py
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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'),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
export const getSystemSettingsFormInitialValues = () => {
|
||||
return {
|
||||
'max-system-events': 100,
|
||||
max_system_events: 100,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue