mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
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:
parent
4bfdd15b37
commit
36967c10ce
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