mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +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"
|
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 = {
|
DEFAULTS = {
|
||||||
"enabled": True,
|
"schedule_enabled": True,
|
||||||
"frequency": "daily",
|
"schedule_frequency": "daily",
|
||||||
"time": "03:00",
|
"schedule_time": "03:00",
|
||||||
"day_of_week": 0, # Sunday
|
"schedule_day_of_week": 0, # Sunday
|
||||||
"retention_count": 3,
|
"retention_count": 3,
|
||||||
"cron_expression": "",
|
"schedule_cron_expression": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_setting(key: str, default=None):
|
def _get_backup_settings():
|
||||||
"""Get a backup setting from CoreSettings."""
|
"""Get all backup settings from CoreSettings grouped JSON."""
|
||||||
try:
|
try:
|
||||||
setting = CoreSettings.objects.get(key=SETTING_KEYS[key])
|
settings_obj = CoreSettings.objects.get(key="backup_settings")
|
||||||
value = setting.value
|
return settings_obj.value if isinstance(settings_obj.value, dict) else DEFAULTS.copy()
|
||||||
if key == "enabled":
|
|
||||||
return value.lower() == "true"
|
|
||||||
elif key in ("day_of_week", "retention_count"):
|
|
||||||
return int(value)
|
|
||||||
return value
|
|
||||||
except CoreSettings.DoesNotExist:
|
except CoreSettings.DoesNotExist:
|
||||||
return default if default is not None else DEFAULTS.get(key)
|
return DEFAULTS.copy()
|
||||||
|
|
||||||
|
|
||||||
def _set_setting(key: str, value) -> None:
|
def _update_backup_settings(updates: dict) -> None:
|
||||||
"""Set a backup setting in CoreSettings."""
|
"""Update backup settings in the grouped JSON."""
|
||||||
str_value = str(value).lower() if isinstance(value, bool) else str(value)
|
obj, created = CoreSettings.objects.get_or_create(
|
||||||
CoreSettings.objects.update_or_create(
|
key="backup_settings",
|
||||||
key=SETTING_KEYS[key],
|
defaults={"name": "Backup Settings", "value": DEFAULTS.copy()}
|
||||||
defaults={
|
|
||||||
"name": f"Backup {key.replace('_', ' ').title()}",
|
|
||||||
"value": str_value,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
current = obj.value if isinstance(obj.value, dict) else {}
|
||||||
|
current.update(updates)
|
||||||
|
obj.value = current
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
|
||||||
def get_schedule_settings() -> dict:
|
def get_schedule_settings() -> dict:
|
||||||
"""Get all backup schedule settings."""
|
"""Get all backup schedule settings."""
|
||||||
|
settings = _get_backup_settings()
|
||||||
return {
|
return {
|
||||||
"enabled": _get_setting("enabled"),
|
"enabled": bool(settings.get("schedule_enabled", DEFAULTS["schedule_enabled"])),
|
||||||
"frequency": _get_setting("frequency"),
|
"frequency": str(settings.get("schedule_frequency", DEFAULTS["schedule_frequency"])),
|
||||||
"time": _get_setting("time"),
|
"time": str(settings.get("schedule_time", DEFAULTS["schedule_time"])),
|
||||||
"day_of_week": _get_setting("day_of_week"),
|
"day_of_week": int(settings.get("schedule_day_of_week", DEFAULTS["schedule_day_of_week"])),
|
||||||
"retention_count": _get_setting("retention_count"),
|
"retention_count": int(settings.get("retention_count", DEFAULTS["retention_count"])),
|
||||||
"cron_expression": _get_setting("cron_expression"),
|
"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:
|
if count < 0:
|
||||||
raise ValueError("retention_count must be >= 0")
|
raise ValueError("retention_count must be >= 0")
|
||||||
|
|
||||||
# Update settings
|
# Update settings with proper key names
|
||||||
for key in ("enabled", "frequency", "time", "day_of_week", "retention_count", "cron_expression"):
|
updates = {}
|
||||||
if key in data:
|
if "enabled" in data:
|
||||||
_set_setting(key, data[key])
|
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 the periodic task
|
||||||
_sync_periodic_task()
|
_sync_periodic_task()
|
||||||
|
|
|
||||||
|
|
@ -286,11 +286,12 @@ def fetch_xmltv(source):
|
||||||
logger.info(f"Fetching XMLTV data from source: {source.name}")
|
logger.info(f"Fetching XMLTV data from source: {source.name}")
|
||||||
try:
|
try:
|
||||||
# Get default user agent from settings
|
# 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
|
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:
|
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:
|
if user_agent_obj and user_agent_obj.user_agent:
|
||||||
user_agent = user_agent_obj.user_agent
|
user_agent = user_agent_obj.user_agent
|
||||||
logger.debug(f"Using default user agent: {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}")
|
logger.info(f"Fetching Schedules Direct data from source: {source.name}")
|
||||||
try:
|
try:
|
||||||
# Get default user agent from settings
|
# 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
|
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:
|
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:
|
if user_agent_obj and user_agent_obj.user_agent:
|
||||||
user_agent = user_agent_obj.user_agent
|
user_agent = user_agent_obj.user_agent
|
||||||
logger.debug(f"Using default user agent: {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 django.views.decorators.http import require_http_methods
|
||||||
from apps.epg.models import ProgramData
|
from apps.epg.models import ProgramData
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
from core.models import CoreSettings, NETWORK_ACCESS
|
|
||||||
from dispatcharr.utils import network_access_allowed
|
from dispatcharr.utils import network_access_allowed
|
||||||
from django.utils import timezone as django_timezone
|
from django.utils import timezone as django_timezone
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,9 @@ from .models import (
|
||||||
UserAgent,
|
UserAgent,
|
||||||
StreamProfile,
|
StreamProfile,
|
||||||
CoreSettings,
|
CoreSettings,
|
||||||
STREAM_HASH_KEY,
|
STREAM_SETTINGS_KEY,
|
||||||
NETWORK_ACCESS,
|
DVR_SETTINGS_KEY,
|
||||||
|
NETWORK_ACCESS_KEY,
|
||||||
PROXY_SETTINGS_KEY,
|
PROXY_SETTINGS_KEY,
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
|
@ -68,16 +69,28 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
|
old_value = instance.value
|
||||||
response = super().update(request, *args, **kwargs)
|
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
|
# If stream settings changed and m3u_hash_key is different, rehash streams
|
||||||
try:
|
if instance.key == STREAM_SETTINGS_KEY:
|
||||||
from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY
|
new_value = request.data.get("value", {})
|
||||||
if instance.key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY):
|
if isinstance(new_value, dict) and isinstance(old_value, dict):
|
||||||
if instance.value != request.data.get("value"):
|
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:
|
try:
|
||||||
# Prefer async task if Celery is available
|
# Prefer async task if Celery is available
|
||||||
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
|
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
|
||||||
|
|
@ -86,24 +99,23 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
|
||||||
# Fallback to synchronous implementation
|
# Fallback to synchronous implementation
|
||||||
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
|
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
|
||||||
reschedule_upcoming_recordings_for_offset_change_impl()
|
reschedule_upcoming_recordings_for_offset_change_impl()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
response = super().create(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:
|
try:
|
||||||
key = request.data.get("key")
|
key = request.data.get("key")
|
||||||
from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY
|
if key == DVR_SETTINGS_KEY:
|
||||||
if key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY):
|
value = request.data.get("value", {})
|
||||||
try:
|
if isinstance(value, dict) and ("pre_offset_minutes" in value or "post_offset_minutes" in value):
|
||||||
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
|
try:
|
||||||
reschedule_upcoming_recordings_for_offset_change.delay()
|
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change
|
||||||
except Exception:
|
reschedule_upcoming_recordings_for_offset_change.delay()
|
||||||
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
|
except Exception:
|
||||||
reschedule_upcoming_recordings_for_offset_change_impl()
|
from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl
|
||||||
|
reschedule_upcoming_recordings_for_offset_change_impl()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return response
|
return response
|
||||||
|
|
@ -111,13 +123,13 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
|
||||||
def check(self, request, *args, **kwargs):
|
def check(self, request, *args, **kwargs):
|
||||||
data = request.data
|
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))
|
client_ip = ipaddress.ip_address(get_client_ip(request))
|
||||||
|
|
||||||
in_network = {}
|
in_network = {}
|
||||||
invalid = []
|
invalid = []
|
||||||
|
|
||||||
value = json.loads(data.get("value", "{}"))
|
value = data.get("value", {})
|
||||||
for key, val in value.items():
|
for key, val in value.items():
|
||||||
in_network[key] = []
|
in_network[key] = []
|
||||||
cidrs = val.split(",")
|
cidrs = val.split(",")
|
||||||
|
|
@ -161,8 +173,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
|
||||||
"""Get or create the proxy settings CoreSettings entry"""
|
"""Get or create the proxy settings CoreSettings entry"""
|
||||||
try:
|
try:
|
||||||
settings_obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY)
|
settings_obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY)
|
||||||
settings_data = json.loads(settings_obj.value)
|
settings_data = settings_obj.value
|
||||||
except (CoreSettings.DoesNotExist, json.JSONDecodeError):
|
except CoreSettings.DoesNotExist:
|
||||||
# Create default settings
|
# Create default settings
|
||||||
settings_data = {
|
settings_data = {
|
||||||
"buffering_timeout": 15,
|
"buffering_timeout": 15,
|
||||||
|
|
@ -175,7 +187,7 @@ class ProxySettingsViewSet(viewsets.ViewSet):
|
||||||
key=PROXY_SETTINGS_KEY,
|
key=PROXY_SETTINGS_KEY,
|
||||||
defaults={
|
defaults={
|
||||||
"name": "Proxy Settings",
|
"name": "Proxy Settings",
|
||||||
"value": json.dumps(settings_data)
|
"value": settings_data
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return settings_obj, settings_data
|
return settings_obj, settings_data
|
||||||
|
|
@ -197,8 +209,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
|
||||||
serializer = ProxySettingsSerializer(data=request.data)
|
serializer = ProxySettingsSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
# Update the JSON data
|
# Update the JSON data - store as dict directly
|
||||||
settings_obj.value = json.dumps(serializer.validated_data)
|
settings_obj.value = serializer.validated_data
|
||||||
settings_obj.save()
|
settings_obj.save()
|
||||||
|
|
||||||
return Response(serializer.validated_data)
|
return Response(serializer.validated_data)
|
||||||
|
|
@ -213,8 +225,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
|
||||||
serializer = ProxySettingsSerializer(data=updated_data)
|
serializer = ProxySettingsSerializer(data=updated_data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
# Update the JSON data
|
# Update the JSON data - store as dict directly
|
||||||
settings_obj.value = json.dumps(serializer.validated_data)
|
settings_obj.value = serializer.validated_data
|
||||||
settings_obj.save()
|
settings_obj.save()
|
||||||
|
|
||||||
return Response(serializer.validated_data)
|
return Response(serializer.validated_data)
|
||||||
|
|
@ -332,8 +344,8 @@ def rehash_streams_endpoint(request):
|
||||||
"""Trigger the rehash streams task"""
|
"""Trigger the rehash streams task"""
|
||||||
try:
|
try:
|
||||||
# Get the current hash keys from settings
|
# Get the current hash keys from settings
|
||||||
hash_key_setting = CoreSettings.objects.get(key=STREAM_HASH_KEY)
|
hash_key = CoreSettings.get_m3u_hash_key()
|
||||||
hash_keys = hash_key_setting.value.split(",")
|
hash_keys = hash_key.split(",") if isinstance(hash_key, str) else hash_key
|
||||||
|
|
||||||
# Queue the rehash task
|
# Queue the rehash task
|
||||||
task = rehash_streams.delay(hash_keys)
|
task = rehash_streams.delay(hash_keys)
|
||||||
|
|
@ -344,10 +356,10 @@ def rehash_streams_endpoint(request):
|
||||||
"task_id": task.id
|
"task_id": task.id
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
except CoreSettings.DoesNotExist:
|
except Exception as e:
|
||||||
return Response({
|
return Response({
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Hash key settings not found"
|
"message": f"Error triggering rehash: {str(e)}"
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# your_app/management/commands/update_column.py
|
# your_app/management/commands/update_column.py
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
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):
|
class Command(BaseCommand):
|
||||||
help = "Reset network access settings"
|
help = "Reset network access settings"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
setting = CoreSettings.objects.get(key=NETWORK_ACCESS)
|
setting = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY)
|
||||||
setting.value = "{}"
|
setting.value = {}
|
||||||
setting.save()
|
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
|
return part
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_USER_AGENT_KEY = slugify("Default User-Agent")
|
# Setting group keys
|
||||||
DEFAULT_STREAM_PROFILE_KEY = slugify("Default Stream Profile")
|
STREAM_SETTINGS_KEY = "stream_settings"
|
||||||
STREAM_HASH_KEY = slugify("M3U Hash Key")
|
DVR_SETTINGS_KEY = "dvr_settings"
|
||||||
PREFERRED_REGION_KEY = slugify("Preferred Region")
|
BACKUP_SETTINGS_KEY = "backup_settings"
|
||||||
AUTO_IMPORT_MAPPED_FILES = slugify("Auto-Import Mapped Files")
|
PROXY_SETTINGS_KEY = "proxy_settings"
|
||||||
NETWORK_ACCESS = slugify("Network Access")
|
NETWORK_ACCESS_KEY = "network_access"
|
||||||
PROXY_SETTINGS_KEY = slugify("Proxy Settings")
|
SYSTEM_SETTINGS_KEY = "system_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")
|
|
||||||
|
|
||||||
|
|
||||||
class CoreSettings(models.Model):
|
class CoreSettings(models.Model):
|
||||||
|
|
@ -176,208 +165,166 @@ class CoreSettings(models.Model):
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
)
|
)
|
||||||
value = models.CharField(
|
value = models.JSONField(
|
||||||
max_length=255,
|
default=dict,
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Core Settings"
|
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
|
@classmethod
|
||||||
def get_default_user_agent_id(cls):
|
def get_default_user_agent_id(cls):
|
||||||
"""Retrieve a system profile by name (or return None if not found)."""
|
return cls.get_stream_settings().get("default_user_agent")
|
||||||
return cls.objects.get(key=DEFAULT_USER_AGENT_KEY).value
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_default_stream_profile_id(cls):
|
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
|
@classmethod
|
||||||
def get_m3u_hash_key(cls):
|
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
|
@classmethod
|
||||||
def get_preferred_region(cls):
|
def get_preferred_region(cls):
|
||||||
"""Retrieve the preferred region setting (or return None if not found)."""
|
return cls.get_stream_settings().get("preferred_region")
|
||||||
try:
|
|
||||||
return cls.objects.get(key=PREFERRED_REGION_KEY).value
|
|
||||||
except cls.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_auto_import_mapped_files(cls):
|
def get_auto_import_mapped_files(cls):
|
||||||
"""Retrieve the preferred region setting (or return None if not found)."""
|
return cls.get_stream_settings().get("auto_import_mapped_files")
|
||||||
try:
|
|
||||||
return cls.objects.get(key=AUTO_IMPORT_MAPPED_FILES).value
|
|
||||||
except cls.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
# DVR Settings
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_proxy_settings(cls):
|
def get_dvr_settings(cls):
|
||||||
"""Retrieve proxy settings as dict (or return defaults if not found)."""
|
"""Get all DVR-related settings."""
|
||||||
try:
|
return cls._get_group(DVR_SETTINGS_KEY, {
|
||||||
import json
|
"tv_template": "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv",
|
||||||
settings_json = cls.objects.get(key=PROXY_SETTINGS_KEY).value
|
"movie_template": "Movies/{title} ({year}).mkv",
|
||||||
return json.loads(settings_json)
|
"tv_fallback_dir": "TV_Shows",
|
||||||
except (cls.DoesNotExist, json.JSONDecodeError):
|
"tv_fallback_template": "TV_Shows/{show}/{start}.mkv",
|
||||||
# Return defaults if not found or invalid JSON
|
"movie_fallback_template": "Movies/{start}.mkv",
|
||||||
return {
|
"comskip_enabled": False,
|
||||||
"buffering_timeout": 15,
|
"comskip_custom_path": "",
|
||||||
"buffering_speed": 1.0,
|
"pre_offset_minutes": 0,
|
||||||
"redis_chunk_ttl": 60,
|
"post_offset_minutes": 0,
|
||||||
"channel_shutdown_delay": 0,
|
"series_rules": [],
|
||||||
"channel_init_grace_period": 5,
|
})
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_tv_template(cls):
|
def get_dvr_tv_template(cls):
|
||||||
try:
|
return cls.get_dvr_settings().get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv")
|
||||||
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"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_movie_template(cls):
|
def get_dvr_movie_template(cls):
|
||||||
try:
|
return cls.get_dvr_settings().get("movie_template", "Movies/{title} ({year}).mkv")
|
||||||
return cls.objects.get(key=DVR_MOVIE_TEMPLATE_KEY).value
|
|
||||||
except cls.DoesNotExist:
|
|
||||||
return "Movies/{title} ({year}).mkv"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_tv_fallback_dir(cls):
|
def get_dvr_tv_fallback_dir(cls):
|
||||||
"""Folder name to use when a TV episode has no season/episode information.
|
return cls.get_dvr_settings().get("tv_fallback_dir", "TV_Shows")
|
||||||
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"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_tv_fallback_template(cls):
|
def get_dvr_tv_fallback_template(cls):
|
||||||
"""Full path template used when season/episode are missing for a TV airing."""
|
return cls.get_dvr_settings().get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv")
|
||||||
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"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_movie_fallback_template(cls):
|
def get_dvr_movie_fallback_template(cls):
|
||||||
"""Full path template used when movie metadata is incomplete."""
|
return cls.get_dvr_settings().get("movie_fallback_template", "Movies/{start}.mkv")
|
||||||
try:
|
|
||||||
return cls.objects.get(key=DVR_MOVIE_FALLBACK_TEMPLATE_KEY).value
|
|
||||||
except cls.DoesNotExist:
|
|
||||||
return "Movies/{start}.mkv"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_comskip_enabled(cls):
|
def get_dvr_comskip_enabled(cls):
|
||||||
"""Return boolean-like string value ('true'/'false') for comskip enablement."""
|
return bool(cls.get_dvr_settings().get("comskip_enabled", False))
|
||||||
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
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_comskip_custom_path(cls):
|
def get_dvr_comskip_custom_path(cls):
|
||||||
"""Return configured comskip.ini path or empty string if unset."""
|
return cls.get_dvr_settings().get("comskip_custom_path", "")
|
||||||
try:
|
|
||||||
return cls.objects.get(key=DVR_COMSKIP_CUSTOM_PATH_KEY).value
|
|
||||||
except cls.DoesNotExist:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_dvr_comskip_custom_path(cls, path: str | None):
|
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()
|
value = (path or "").strip()
|
||||||
obj, _ = cls.objects.get_or_create(
|
cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"comskip_custom_path": value})
|
||||||
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"])
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_pre_offset_minutes(cls):
|
def get_dvr_pre_offset_minutes(cls):
|
||||||
"""Minutes to start recording before scheduled start (default 0)."""
|
return int(cls.get_dvr_settings().get("pre_offset_minutes", 0) or 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
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_post_offset_minutes(cls):
|
def get_dvr_post_offset_minutes(cls):
|
||||||
"""Minutes to stop recording after scheduled end (default 0)."""
|
return int(cls.get_dvr_settings().get("post_offset_minutes", 0) or 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
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dvr_series_rules(cls):
|
def get_dvr_series_rules(cls):
|
||||||
"""Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}"""
|
return cls.get_dvr_settings().get("series_rules", [])
|
||||||
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 []
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_dvr_series_rules(cls, rules):
|
def set_dvr_series_rules(cls, rules):
|
||||||
import json
|
cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"series_rules": rules})
|
||||||
try:
|
return rules
|
||||||
obj, _ = cls.objects.get_or_create(key=DVR_SERIES_RULES_KEY, defaults={"name": "DVR Series Rules", "value": "[]"})
|
|
||||||
obj.value = json.dumps(rules)
|
# Proxy Settings
|
||||||
obj.save(update_fields=["value"])
|
@classmethod
|
||||||
return rules
|
def get_proxy_settings(cls):
|
||||||
except Exception:
|
"""Get proxy settings."""
|
||||||
return rules
|
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):
|
class SystemEvent(models.Model):
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import json
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
|
||||||
from rest_framework import serializers
|
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):
|
class UserAgentSerializer(serializers.ModelSerializer):
|
||||||
|
|
@ -40,10 +40,10 @@ class CoreSettingsSerializer(serializers.ModelSerializer):
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
if instance.key == NETWORK_ACCESS:
|
if instance.key == NETWORK_ACCESS_KEY:
|
||||||
errors = False
|
errors = False
|
||||||
invalid = {}
|
invalid = {}
|
||||||
value = json.loads(validated_data.get("value"))
|
value = validated_data.get("value")
|
||||||
for key, val in value.items():
|
for key, val in value.items():
|
||||||
cidrs = val.split(",")
|
cidrs = val.split(",")
|
||||||
for cidr in cidrs:
|
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)
|
# Get max events from settings (default 100)
|
||||||
try:
|
try:
|
||||||
max_events_setting = CoreSettings.objects.filter(key='max-system-events').first()
|
from .models import CoreSettings
|
||||||
max_events = int(max_events_setting.value) if max_events_setting else 100
|
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:
|
except Exception:
|
||||||
max_events = 100
|
max_events = 100
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ def stream_view(request, channel_uuid):
|
||||||
stream_profile = channel.stream_profile
|
stream_profile = channel.stream_profile
|
||||||
if not stream_profile:
|
if not stream_profile:
|
||||||
logger.error("No stream profile set for channel ID=%s, using default", channel.id)
|
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)
|
logger.debug("Stream profile used: %s", stream_profile.name)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import json
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.core.exceptions import ValidationError
|
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):
|
def json_error_response(message, status=400):
|
||||||
|
|
@ -39,7 +39,10 @@ def get_client_ip(request):
|
||||||
|
|
||||||
|
|
||||||
def network_access_allowed(request, settings_key):
|
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 = (
|
cidrs = (
|
||||||
network_access[settings_key].split(",")
|
network_access[settings_key].split(",")
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,28 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
|
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
|
||||||
import usePlaylistsStore from '../../store/playlists.jsx';
|
import usePlaylistsStore from '../../store/playlists.jsx';
|
||||||
import useSettingsStore from '../../store/settings.jsx';
|
import useSettingsStore from '../../store/settings.jsx';
|
||||||
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Select, Stack, Text, Tooltip } from '@mantine/core';
|
import {
|
||||||
import { Gauge, HardDriveDownload, HardDriveUpload, SquareX, Timer, Users, Video } from 'lucide-react';
|
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 { toFriendlyDuration } from '../../utils/dateTimeUtils.js';
|
||||||
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
|
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
|
||||||
import { TableHelper } from '../../helpers/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 we have a channel URL, try to find the matching stream
|
||||||
if (channel.url && streamData.length > 0) {
|
if (channel.url && streamData.length > 0) {
|
||||||
// Try to find matching stream based on URL
|
// Try to find matching stream based on URL
|
||||||
const matchingStream = getMatchingStreamByUrl(streamData, channel.url);
|
const matchingStream = getMatchingStreamByUrl(
|
||||||
|
streamData,
|
||||||
|
channel.url
|
||||||
|
);
|
||||||
|
|
||||||
if (matchingStream) {
|
if (matchingStream) {
|
||||||
setActiveStreamId(matchingStream.id.toString());
|
setActiveStreamId(matchingStream.id.toString());
|
||||||
|
|
@ -178,9 +201,9 @@ const StreamConnectionCard = ({
|
||||||
console.error('Error checking streams after switch:', error);
|
console.error('Error checking streams after switch:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Handle stream switching
|
// Handle stream switching
|
||||||
const handleStreamChange = async (streamId) => {
|
const handleStreamChange = async (streamId) => {
|
||||||
try {
|
try {
|
||||||
console.log('Switching to stream ID:', streamId);
|
console.log('Switching to stream ID:', streamId);
|
||||||
|
|
@ -333,7 +356,7 @@ const StreamConnectionCard = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get logo URL from the logos object if available
|
// 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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
@ -388,11 +411,11 @@ const StreamConnectionCard = ({
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#27272A',
|
backgroundColor: '#27272A',
|
||||||
}}
|
}}
|
||||||
color='#fff'
|
color="#fff"
|
||||||
maw={700}
|
maw={700}
|
||||||
w={'100%'}
|
w={'100%'}
|
||||||
>
|
>
|
||||||
<Stack pos='relative' >
|
<Stack pos="relative">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -401,7 +424,7 @@ const StreamConnectionCard = ({
|
||||||
}}
|
}}
|
||||||
w={100}
|
w={100}
|
||||||
h={50}
|
h={50}
|
||||||
display='flex'
|
display="flex"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={logoUrl || logo}
|
src={logoUrl || logo}
|
||||||
|
|
@ -531,7 +554,7 @@ const StreamConnectionCard = ({
|
||||||
variant="light"
|
variant="light"
|
||||||
color={
|
color={
|
||||||
parseFloat(channel.ffmpeg_speed) >=
|
parseFloat(channel.ffmpeg_speed) >=
|
||||||
getBufferingSpeedThreshold(settings['proxy-settings'])
|
getBufferingSpeedThreshold(settings['proxy_settings'])
|
||||||
? 'green'
|
? 'green'
|
||||||
: 'red'
|
: 'red'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,9 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
||||||
|
|
||||||
form.setValues(formValues);
|
form.setValues(formValues);
|
||||||
|
|
||||||
if (formValues['dvr-comskip-custom-path']) {
|
if (formValues['comskip_custom_path']) {
|
||||||
setComskipConfig((prev) => ({
|
setComskipConfig((prev) => ({
|
||||||
path: formValues['dvr-comskip-custom-path'],
|
path: formValues['comskip_custom_path'],
|
||||||
exists: prev.exists,
|
exists: prev.exists,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +69,7 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
||||||
exists: Boolean(response.exists),
|
exists: Boolean(response.exists),
|
||||||
});
|
});
|
||||||
if (response.path) {
|
if (response.path) {
|
||||||
form.setFieldValue('dvr-comskip-custom-path', response.path);
|
form.setFieldValue('comskip_custom_path', response.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -94,10 +94,10 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
||||||
autoClose: 3000,
|
autoClose: 3000,
|
||||||
color: 'green',
|
color: 'green',
|
||||||
});
|
});
|
||||||
form.setFieldValue('dvr-comskip-custom-path', response.path);
|
form.setFieldValue('comskip_custom_path', response.path);
|
||||||
useSettingsStore.getState().updateSetting({
|
useSettingsStore.getState().updateSetting({
|
||||||
...(settings['dvr-comskip-custom-path'] || {
|
...(settings['comskip_custom_path'] || {
|
||||||
key: 'dvr-comskip-custom-path',
|
key: 'comskip_custom_path',
|
||||||
name: 'DVR Comskip Custom Path',
|
name: 'DVR Comskip Custom Path',
|
||||||
}),
|
}),
|
||||||
value: response.path,
|
value: response.path,
|
||||||
|
|
@ -137,24 +137,19 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
||||||
)}
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
label="Enable Comskip (remove commercials after recording)"
|
label="Enable Comskip (remove commercials after recording)"
|
||||||
{...form.getInputProps('dvr-comskip-enabled', {
|
{...form.getInputProps('comskip_enabled', {
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
})}
|
})}
|
||||||
id={settings['dvr-comskip-enabled']?.id || 'dvr-comskip-enabled'}
|
id="comskip_enabled"
|
||||||
name={settings['dvr-comskip-enabled']?.key || 'dvr-comskip-enabled'}
|
name="comskip_enabled"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Custom comskip.ini path"
|
label="Custom comskip.ini path"
|
||||||
description="Leave blank to use the built-in defaults."
|
description="Leave blank to use the built-in defaults."
|
||||||
placeholder="/app/docker/comskip.ini"
|
placeholder="/app/docker/comskip.ini"
|
||||||
{...form.getInputProps('dvr-comskip-custom-path')}
|
{...form.getInputProps('comskip_custom_path')}
|
||||||
id={
|
id="comskip_custom_path"
|
||||||
settings['dvr-comskip-custom-path']?.id || 'dvr-comskip-custom-path'
|
name="comskip_custom_path"
|
||||||
}
|
|
||||||
name={
|
|
||||||
settings['dvr-comskip-custom-path']?.key ||
|
|
||||||
'dvr-comskip-custom-path'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Group align="flex-end" gap="sm">
|
<Group align="flex-end" gap="sm">
|
||||||
<FileInput
|
<FileInput
|
||||||
|
|
@ -184,71 +179,50 @@ const DvrSettingsForm = React.memo(({ active }) => {
|
||||||
description="Begin recording this many minutes before the scheduled start."
|
description="Begin recording this many minutes before the scheduled start."
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
{...form.getInputProps('dvr-pre-offset-minutes')}
|
{...form.getInputProps('pre_offset_minutes')}
|
||||||
id={
|
id="pre_offset_minutes"
|
||||||
settings['dvr-pre-offset-minutes']?.id || 'dvr-pre-offset-minutes'
|
name="pre_offset_minutes"
|
||||||
}
|
|
||||||
name={
|
|
||||||
settings['dvr-pre-offset-minutes']?.key || 'dvr-pre-offset-minutes'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="End late (minutes)"
|
label="End late (minutes)"
|
||||||
description="Continue recording this many minutes after the scheduled end."
|
description="Continue recording this many minutes after the scheduled end."
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
{...form.getInputProps('dvr-post-offset-minutes')}
|
{...form.getInputProps('post_offset_minutes')}
|
||||||
id={
|
id="post_offset_minutes"
|
||||||
settings['dvr-post-offset-minutes']?.id || 'dvr-post-offset-minutes'
|
name="post_offset_minutes"
|
||||||
}
|
|
||||||
name={
|
|
||||||
settings['dvr-post-offset-minutes']?.key ||
|
|
||||||
'dvr-post-offset-minutes'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="TV Path Template"
|
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."
|
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"
|
placeholder="TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
|
||||||
{...form.getInputProps('dvr-tv-template')}
|
{...form.getInputProps('tv_template')}
|
||||||
id={settings['dvr-tv-template']?.id || 'dvr-tv-template'}
|
id="tv_template"
|
||||||
name={settings['dvr-tv-template']?.key || 'dvr-tv-template'}
|
name="tv_template"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="TV Fallback Template"
|
label="TV Fallback Template"
|
||||||
description="Template used when an episode has no season/episode. Supports {show}, {start}, {end}, {channel}, {year}."
|
description="Template used when an episode has no season/episode. Supports {show}, {start}, {end}, {channel}, {year}."
|
||||||
placeholder="TV_Shows/{show}/{start}.mkv"
|
placeholder="TV_Shows/{show}/{start}.mkv"
|
||||||
{...form.getInputProps('dvr-tv-fallback-template')}
|
{...form.getInputProps('tv_fallback_template')}
|
||||||
id={
|
id="tv_fallback_template"
|
||||||
settings['dvr-tv-fallback-template']?.id ||
|
name="tv_fallback_template"
|
||||||
'dvr-tv-fallback-template'
|
|
||||||
}
|
|
||||||
name={
|
|
||||||
settings['dvr-tv-fallback-template']?.key ||
|
|
||||||
'dvr-tv-fallback-template'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Movie Path Template"
|
label="Movie Path Template"
|
||||||
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
|
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
|
||||||
placeholder="Movies/{title} ({year}).mkv"
|
placeholder="Movies/{title} ({year}).mkv"
|
||||||
{...form.getInputProps('dvr-movie-template')}
|
{...form.getInputProps('movie_template')}
|
||||||
id={settings['dvr-movie-template']?.id || 'dvr-movie-template'}
|
id="movie_template"
|
||||||
name={settings['dvr-movie-template']?.key || 'dvr-movie-template'}
|
name="movie_template"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Movie Fallback Template"
|
label="Movie Fallback Template"
|
||||||
description="Template used when movie metadata is incomplete. Supports {start}, {end}, {channel}."
|
description="Template used when movie metadata is incomplete. Supports {start}, {end}, {channel}."
|
||||||
placeholder="Movies/{start}.mkv"
|
placeholder="Movies/{start}.mkv"
|
||||||
{...form.getInputProps('dvr-movie-fallback-template')}
|
{...form.getInputProps('movie_fallback_template')}
|
||||||
id={
|
id="movie_fallback_template"
|
||||||
settings['dvr-movie-fallback-template']?.id ||
|
name="movie_fallback_template"
|
||||||
'dvr-movie-fallback-template'
|
|
||||||
}
|
|
||||||
name={
|
|
||||||
settings['dvr-movie-fallback-template']?.key ||
|
|
||||||
'dvr-movie-fallback-template'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||||
<Button type="submit" variant="default">
|
<Button type="submit" variant="default">
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,7 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const networkAccessSettings = JSON.parse(
|
const networkAccessSettings = settings['network_access']?.value || {};
|
||||||
settings['network-access'].value || '{}'
|
|
||||||
);
|
|
||||||
networkAccessForm.setValues(
|
networkAccessForm.setValues(
|
||||||
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
||||||
acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
|
acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
|
||||||
|
|
@ -51,8 +49,8 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
setNetworkAccessError(null);
|
setNetworkAccessError(null);
|
||||||
const check = await checkSetting({
|
const check = await checkSetting({
|
||||||
...settings['network-access'],
|
...settings['network_access'],
|
||||||
value: JSON.stringify(networkAccessForm.getValues()),
|
value: networkAccessForm.getValues(), // Send as object
|
||||||
});
|
});
|
||||||
|
|
||||||
if (check.error && check.message) {
|
if (check.error && check.message) {
|
||||||
|
|
@ -78,8 +76,8 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await updateSetting({
|
await updateSetting({
|
||||||
...settings['network-access'],
|
...settings['network_access'],
|
||||||
value: JSON.stringify(networkAccessForm.getValues()),
|
value: networkAccessForm.getValues(), // Send as object
|
||||||
});
|
});
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -91,18 +91,13 @@ const ProxySettingsForm = React.memo(({ active }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(!active) setSaved(false);
|
if (!active) setSaved(false);
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
if (settings['proxy-settings']?.value) {
|
if (settings['proxy_settings']?.value) {
|
||||||
try {
|
proxySettingsForm.setValues(settings['proxy_settings'].value);
|
||||||
const proxySettings = JSON.parse(settings['proxy-settings'].value);
|
|
||||||
proxySettingsForm.setValues(proxySettings);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing proxy settings:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
@ -116,8 +111,8 @@ const ProxySettingsForm = React.memo(({ active }) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await updateSetting({
|
const result = await updateSetting({
|
||||||
...settings['proxy-settings'],
|
...settings['proxy_settings'],
|
||||||
value: JSON.stringify(proxySettingsForm.getValues()),
|
value: proxySettingsForm.getValues(), // Send as object
|
||||||
});
|
});
|
||||||
// API functions return undefined on error
|
// API functions return undefined on error
|
||||||
if (result) {
|
if (result) {
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,11 @@ const StreamSettingsForm = React.memo(({ active }) => {
|
||||||
const values = form.getValues();
|
const values = form.getValues();
|
||||||
const changedSettings = getChangedSettings(values, settings);
|
const changedSettings = getChangedSettings(values, settings);
|
||||||
|
|
||||||
const m3uHashKeyChanged =
|
// Check if m3u_hash_key changed from the grouped stream_settings
|
||||||
settings['m3u-hash-key']?.value !== values['m3u-hash-key'].join(',');
|
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 M3U hash key changed, show warning (unless suppressed)
|
||||||
if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
|
if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
|
||||||
|
|
@ -161,10 +164,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
|
||||||
)}
|
)}
|
||||||
<Select
|
<Select
|
||||||
searchable
|
searchable
|
||||||
{...form.getInputProps('default-user-agent')}
|
{...form.getInputProps('default_user_agent')}
|
||||||
id={settings['default-user-agent']?.id || 'default-user-agent'}
|
id="default_user_agent"
|
||||||
name={settings['default-user-agent']?.key || 'default-user-agent'}
|
name="default_user_agent"
|
||||||
label={settings['default-user-agent']?.name || 'Default User Agent'}
|
label="Default User Agent"
|
||||||
data={userAgents.map((option) => ({
|
data={userAgents.map((option) => ({
|
||||||
value: `${option.id}`,
|
value: `${option.id}`,
|
||||||
label: option.name,
|
label: option.name,
|
||||||
|
|
@ -172,16 +175,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
searchable
|
searchable
|
||||||
{...form.getInputProps('default-stream-profile')}
|
{...form.getInputProps('default_stream_profile')}
|
||||||
id={
|
id="default_stream_profile"
|
||||||
settings['default-stream-profile']?.id || 'default-stream-profile'
|
name="default_stream_profile"
|
||||||
}
|
label="Default Stream Profile"
|
||||||
name={
|
|
||||||
settings['default-stream-profile']?.key || 'default-stream-profile'
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
settings['default-stream-profile']?.name || 'Default Stream Profile'
|
|
||||||
}
|
|
||||||
data={streamProfiles.map((option) => ({
|
data={streamProfiles.map((option) => ({
|
||||||
value: `${option.id}`,
|
value: `${option.id}`,
|
||||||
label: option.name,
|
label: option.name,
|
||||||
|
|
@ -189,10 +186,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
searchable
|
searchable
|
||||||
{...form.getInputProps('preferred-region')}
|
{...form.getInputProps('preferred_region')}
|
||||||
id={settings['preferred-region']?.id || 'preferred-region'}
|
id="preferred_region"
|
||||||
name={settings['preferred-region']?.key || 'preferred-region'}
|
name="preferred_region"
|
||||||
label={settings['preferred-region']?.name || 'Preferred Region'}
|
label="Preferred Region"
|
||||||
data={regionChoices.map((r) => ({
|
data={regionChoices.map((r) => ({
|
||||||
label: r.label,
|
label: r.label,
|
||||||
value: `${r.value}`,
|
value: `${r.value}`,
|
||||||
|
|
@ -204,19 +201,16 @@ const StreamSettingsForm = React.memo(({ active }) => {
|
||||||
Auto-Import Mapped Files
|
Auto-Import Mapped Files
|
||||||
</Text>
|
</Text>
|
||||||
<Switch
|
<Switch
|
||||||
{...form.getInputProps('auto-import-mapped-files', {
|
{...form.getInputProps('auto_import_mapped_files', {
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
})}
|
})}
|
||||||
id={
|
id="auto_import_mapped_files"
|
||||||
settings['auto-import-mapped-files']?.id ||
|
|
||||||
'auto-import-mapped-files'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
id="m3u-hash-key"
|
id="m3u_hash_key"
|
||||||
name="m3u-hash-key"
|
name="m3u_hash_key"
|
||||||
label="M3U Hash Key"
|
label="M3U Hash Key"
|
||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
|
|
@ -240,7 +234,7 @@ const StreamSettingsForm = React.memo(({ active }) => {
|
||||||
label: 'Group',
|
label: 'Group',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
{...form.getInputProps('m3u-hash-key')}
|
{...form.getInputProps('m3u_hash_key')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{rehashSuccess && (
|
{rehashSuccess && (
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Maximum System Events"
|
label="Maximum System Events"
|
||||||
description="Number of events to retain (minimum: 10, maximum: 1000)"
|
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) => {
|
onChange={(value) => {
|
||||||
form.setFieldValue('max-system-events', value);
|
form.setFieldValue('max_system_events', value);
|
||||||
}}
|
}}
|
||||||
min={10}
|
min={10}
|
||||||
max={1000}
|
max={1000}
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,11 @@ const UiSettingsForm = React.memo(() => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
const tzSetting = settings['system-time-zone'];
|
const systemSettings = settings['system_settings'];
|
||||||
if (tzSetting?.value) {
|
const tzValue = systemSettings?.value?.time_zone;
|
||||||
|
if (tzValue) {
|
||||||
timeZoneSyncedRef.current = true;
|
timeZoneSyncedRef.current = true;
|
||||||
setTimeZone((prev) =>
|
setTimeZone((prev) => (prev === tzValue ? prev : tzValue));
|
||||||
prev === tzSetting.value ? prev : tzSetting.value
|
|
||||||
);
|
|
||||||
} else if (!timeZoneSyncedRef.current && timeZone) {
|
} else if (!timeZoneSyncedRef.current && timeZone) {
|
||||||
timeZoneSyncedRef.current = true;
|
timeZoneSyncedRef.current = true;
|
||||||
persistTimeZoneSetting(timeZone);
|
persistTimeZoneSetting(timeZone);
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ const StreamProfiles = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteStreamProfile = async (id) => {
|
const deleteStreamProfile = async (id) => {
|
||||||
if (id == settings['default-stream-profile'].value) {
|
if (id == settings.default_stream_profile) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Cannot delete default stream-profile',
|
title: 'Cannot delete default stream-profile',
|
||||||
color: 'red.5',
|
color: 'red.5',
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ const UserAgentsTable = () => {
|
||||||
|
|
||||||
const deleteUserAgent = async (ids) => {
|
const deleteUserAgent = async (ids) => {
|
||||||
if (Array.isArray(ids)) {
|
if (Array.isArray(ids)) {
|
||||||
if (ids.includes(settings['default-user-agent'].value)) {
|
if (ids.includes(settings.default_user_agent)) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Cannot delete default user-agent',
|
title: 'Cannot delete default user-agent',
|
||||||
color: 'red.5',
|
color: 'red.5',
|
||||||
|
|
@ -137,7 +137,7 @@ const UserAgentsTable = () => {
|
||||||
|
|
||||||
await API.deleteUserAgents(ids);
|
await API.deleteUserAgents(ids);
|
||||||
} else {
|
} else {
|
||||||
if (ids == settings['default-user-agent'].value) {
|
if (ids == settings.default_user_agent) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Cannot delete default user-agent',
|
title: 'Cannot delete default user-agent',
|
||||||
color: 'red.5',
|
color: 'red.5',
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@ import {
|
||||||
useTimeHelpers,
|
useTimeHelpers,
|
||||||
} from '../../utils/dateTimeUtils.js';
|
} from '../../utils/dateTimeUtils.js';
|
||||||
import { categorizeRecordings } from '../../utils/pages/DVRUtils.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/channels');
|
||||||
vi.mock('../../store/settings');
|
vi.mock('../../store/settings');
|
||||||
|
|
@ -53,17 +57,28 @@ vi.mock('../../components/cards/RecordingCard', () => ({
|
||||||
<span>{recording.custom_properties?.Title || 'Recording'}</span>
|
<span>{recording.custom_properties?.Title || 'Recording'}</span>
|
||||||
<button onClick={() => onOpenDetails(recording)}>Open Details</button>
|
<button onClick={() => onOpenDetails(recording)}>Open Details</button>
|
||||||
{recording.custom_properties?.rule && (
|
{recording.custom_properties?.rule && (
|
||||||
<button onClick={() => onOpenRecurring(recording)}>Open Recurring</button>
|
<button onClick={() => onOpenRecurring(recording)}>
|
||||||
|
Open Recurring
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../components/forms/RecordingDetailsModal', () => ({
|
vi.mock('../../components/forms/RecordingDetailsModal', () => ({
|
||||||
default: ({ opened, onClose, recording, onEdit, onWatchLive, onWatchRecording }) =>
|
default: ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
recording,
|
||||||
|
onEdit,
|
||||||
|
onWatchLive,
|
||||||
|
onWatchRecording,
|
||||||
|
}) =>
|
||||||
opened ? (
|
opened ? (
|
||||||
<div data-testid="details-modal">
|
<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={onClose}>Close Modal</button>
|
||||||
<button onClick={onEdit}>Edit</button>
|
<button onClick={onEdit}>Edit</button>
|
||||||
<button onClick={onWatchLive}>Watch Live</button>
|
<button onClick={onWatchLive}>Watch Live</button>
|
||||||
|
|
@ -137,7 +152,7 @@ describe('DVRPage', () => {
|
||||||
|
|
||||||
const defaultSettingsState = {
|
const defaultSettingsState = {
|
||||||
settings: {
|
settings: {
|
||||||
'system-time-zone': { value: 'America/New_York' },
|
system_settings: { value: { time_zone: 'America/New_York' } },
|
||||||
},
|
},
|
||||||
environment: {
|
environment: {
|
||||||
env_mode: 'production',
|
env_mode: 'production',
|
||||||
|
|
@ -178,12 +193,10 @@ describe('DVRPage', () => {
|
||||||
getPosterUrl.mockImplementation((recording) =>
|
getPosterUrl.mockImplementation((recording) =>
|
||||||
recording?.id ? `http://poster.url/${recording.id}` : null
|
recording?.id ? `http://poster.url/${recording.id}` : null
|
||||||
);
|
);
|
||||||
getRecordingUrl.mockImplementation((custom_properties) =>
|
getRecordingUrl.mockImplementation(
|
||||||
custom_properties?.recording_url
|
(custom_properties) => custom_properties?.recording_url
|
||||||
);
|
|
||||||
getShowVideoUrl.mockImplementation((channel) =>
|
|
||||||
channel?.stream_url
|
|
||||||
);
|
);
|
||||||
|
getShowVideoUrl.mockImplementation((channel) => channel?.stream_url);
|
||||||
|
|
||||||
useChannelsStore.mockImplementation((selector) => {
|
useChannelsStore.mockImplementation((selector) => {
|
||||||
return selector ? selector(defaultChannelsState) : defaultChannelsState;
|
return selector ? selector(defaultChannelsState) : defaultChannelsState;
|
||||||
|
|
@ -295,7 +308,9 @@ describe('DVRPage', () => {
|
||||||
const state = {
|
const state = {
|
||||||
...defaultChannelsState,
|
...defaultChannelsState,
|
||||||
recordings: [recording],
|
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;
|
return selector ? selector(state) : state;
|
||||||
});
|
});
|
||||||
|
|
@ -362,7 +377,7 @@ describe('DVRPage', () => {
|
||||||
end_time: now.add(1, 'hour').toISOString(),
|
end_time: now.add(1, 'hour').toISOString(),
|
||||||
custom_properties: {
|
custom_properties: {
|
||||||
Title: 'Recurring Show',
|
Title: 'Recurring Show',
|
||||||
rule: { id: 100 }
|
rule: { id: 100 },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,7 @@ describe('dateTimeUtils', () => {
|
||||||
const setTimeZone = vi.fn();
|
const setTimeZone = vi.fn();
|
||||||
useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
|
useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
|
||||||
useSettingsStore.mockReturnValue({
|
useSettingsStore.mockReturnValue({
|
||||||
'system-time-zone': { value: 'America/Los_Angeles' }
|
'system_settings': { value: { time_zone: 'America/Los_Angeles' } }
|
||||||
});
|
});
|
||||||
|
|
||||||
renderHook(() => dateTimeUtils.useUserTimeZone());
|
renderHook(() => dateTimeUtils.useUserTimeZone());
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,14 @@ import {
|
||||||
toFriendlyDuration,
|
toFriendlyDuration,
|
||||||
} from '../dateTimeUtils.js';
|
} from '../dateTimeUtils.js';
|
||||||
|
|
||||||
// Parse proxy settings to get buffering_speed
|
// Get buffering_speed from proxy settings
|
||||||
export const getBufferingSpeedThreshold = (proxySetting) => {
|
export const getBufferingSpeedThreshold = (proxySetting) => {
|
||||||
try {
|
try {
|
||||||
if (proxySetting?.value) {
|
if (proxySetting?.value) {
|
||||||
const proxySettings = JSON.parse(proxySetting.value);
|
return parseFloat(proxySetting.value.buffering_speed) || 1.0;
|
||||||
return parseFloat(proxySettings.buffering_speed) || 1.0;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing proxy settings:', error);
|
console.error('Error getting buffering speed:', error);
|
||||||
}
|
}
|
||||||
return 1.0; // Default fallback
|
return 1.0; // Default fallback
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export const useUserTimeZone = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tz = settings?.['system-time-zone']?.value;
|
const tz = settings?.['system_settings']?.value?.time_zone;
|
||||||
if (tz && tz !== timeZone) {
|
if (tz && tz !== timeZone) {
|
||||||
setTimeZone(tz);
|
setTimeZone(tz);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ export const uploadComskipIni = async (file) => {
|
||||||
|
|
||||||
export const getDvrSettingsFormInitialValues = () => {
|
export const getDvrSettingsFormInitialValues = () => {
|
||||||
return {
|
return {
|
||||||
'dvr-tv-template': '',
|
'tv_template': '',
|
||||||
'dvr-movie-template': '',
|
'movie_template': '',
|
||||||
'dvr-tv-fallback-template': '',
|
'tv_fallback_template': '',
|
||||||
'dvr-movie-fallback-template': '',
|
'movie_fallback_template': '',
|
||||||
'dvr-comskip-enabled': false,
|
'comskip_enabled': false,
|
||||||
'dvr-comskip-custom-path': '',
|
'comskip_custom_path': '',
|
||||||
'dvr-pre-offset-minutes': 0,
|
'pre_offset_minutes': 0,
|
||||||
'dvr-post-offset-minutes': 0,
|
'post_offset_minutes': 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -2,18 +2,18 @@ import { isNotEmpty } from '@mantine/form';
|
||||||
|
|
||||||
export const getStreamSettingsFormInitialValues = () => {
|
export const getStreamSettingsFormInitialValues = () => {
|
||||||
return {
|
return {
|
||||||
'default-user-agent': '',
|
default_user_agent: '',
|
||||||
'default-stream-profile': '',
|
default_stream_profile: '',
|
||||||
'preferred-region': '',
|
preferred_region: '',
|
||||||
'auto-import-mapped-files': true,
|
auto_import_mapped_files: true,
|
||||||
'm3u-hash-key': [],
|
m3u_hash_key: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStreamSettingsFormValidation = () => {
|
export const getStreamSettingsFormValidation = () => {
|
||||||
return {
|
return {
|
||||||
'default-user-agent': isNotEmpty('Select a user agent'),
|
default_user_agent: isNotEmpty('Select a user agent'),
|
||||||
'default-stream-profile': isNotEmpty('Select a stream profile'),
|
default_stream_profile: isNotEmpty('Select a stream profile'),
|
||||||
'preferred-region': isNotEmpty('Select a region'),
|
preferred_region: isNotEmpty('Select a region'),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export const getSystemSettingsFormInitialValues = () => {
|
export const getSystemSettingsFormInitialValues = () => {
|
||||||
return {
|
return {
|
||||||
'max-system-events': 100,
|
max_system_events: 100,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { createSetting, updateSetting } from '../../pages/SettingsUtils.js';
|
import { createSetting, updateSetting } from '../../pages/SettingsUtils.js';
|
||||||
|
|
||||||
export const saveTimeZoneSetting = async (tzValue, settings) => {
|
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) {
|
if (existing?.id) {
|
||||||
await updateSetting({ ...existing, value: tzValue });
|
await updateSetting({ ...existing, value: newValue });
|
||||||
} else {
|
} else {
|
||||||
await createSetting({
|
await createSetting({
|
||||||
key: 'system-time-zone',
|
key: 'system_settings',
|
||||||
name: 'System Time Zone',
|
name: 'System Settings',
|
||||||
value: tzValue,
|
value: newValue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -57,14 +57,14 @@ describe('DvrSettingsFormUtils', () => {
|
||||||
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
|
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'dvr-tv-template': '',
|
'tv_template': '',
|
||||||
'dvr-movie-template': '',
|
'movie_template': '',
|
||||||
'dvr-tv-fallback-template': '',
|
'tv_fallback_template': '',
|
||||||
'dvr-movie-fallback-template': '',
|
'movie_fallback_template': '',
|
||||||
'dvr-comskip-enabled': false,
|
'comskip_enabled': false,
|
||||||
'dvr-comskip-custom-path': '',
|
'comskip_custom_path': '',
|
||||||
'dvr-pre-offset-minutes': 0,
|
'pre_offset_minutes': 0,
|
||||||
'dvr-post-offset-minutes': 0,
|
'post_offset_minutes': 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -79,14 +79,14 @@ describe('DvrSettingsFormUtils', () => {
|
||||||
it('should have correct default types', () => {
|
it('should have correct default types', () => {
|
||||||
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
|
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
|
||||||
|
|
||||||
expect(typeof result['dvr-tv-template']).toBe('string');
|
expect(typeof result['tv_template']).toBe('string');
|
||||||
expect(typeof result['dvr-movie-template']).toBe('string');
|
expect(typeof result['movie_template']).toBe('string');
|
||||||
expect(typeof result['dvr-tv-fallback-template']).toBe('string');
|
expect(typeof result['tv_fallback_template']).toBe('string');
|
||||||
expect(typeof result['dvr-movie-fallback-template']).toBe('string');
|
expect(typeof result['movie_fallback_template']).toBe('string');
|
||||||
expect(typeof result['dvr-comskip-enabled']).toBe('boolean');
|
expect(typeof result['comskip_enabled']).toBe('boolean');
|
||||||
expect(typeof result['dvr-comskip-custom-path']).toBe('string');
|
expect(typeof result['comskip_custom_path']).toBe('string');
|
||||||
expect(typeof result['dvr-pre-offset-minutes']).toBe('number');
|
expect(typeof result['pre_offset_minutes']).toBe('number');
|
||||||
expect(typeof result['dvr-post-offset-minutes']).toBe('number');
|
expect(typeof result['post_offset_minutes']).toBe('number');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,26 +16,26 @@ describe('StreamSettingsFormUtils', () => {
|
||||||
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
|
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'default-user-agent': '',
|
'default_user_agent': '',
|
||||||
'default-stream-profile': '',
|
'default_stream_profile': '',
|
||||||
'preferred-region': '',
|
'preferred_region': '',
|
||||||
'auto-import-mapped-files': true,
|
'auto_import_mapped_files': true,
|
||||||
'm3u-hash-key': []
|
'm3u_hash_key': []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return boolean true for auto-import-mapped-files', () => {
|
it('should return boolean true for auto-import-mapped-files', () => {
|
||||||
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
|
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
|
||||||
|
|
||||||
expect(result['auto-import-mapped-files']).toBe(true);
|
expect(result['auto_import_mapped_files']).toBe(true);
|
||||||
expect(typeof result['auto-import-mapped-files']).toBe('boolean');
|
expect(typeof result['auto_import_mapped_files']).toBe('boolean');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty array for m3u-hash-key', () => {
|
it('should return empty array for m3u-hash-key', () => {
|
||||||
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
|
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
|
||||||
|
|
||||||
expect(result['m3u-hash-key']).toEqual([]);
|
expect(result['m3u_hash_key']).toEqual([]);
|
||||||
expect(Array.isArray(result['m3u-hash-key'])).toBe(true);
|
expect(Array.isArray(result['m3u_hash_key'])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a new object each time', () => {
|
it('should return a new object each time', () => {
|
||||||
|
|
@ -50,7 +50,7 @@ describe('StreamSettingsFormUtils', () => {
|
||||||
const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
|
const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
|
||||||
const result2 = 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();
|
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
||||||
|
|
||||||
expect(Object.keys(result)).toEqual([
|
expect(Object.keys(result)).toEqual([
|
||||||
'default-user-agent',
|
'default_user_agent',
|
||||||
'default-stream-profile',
|
'default_stream_profile',
|
||||||
'preferred-region'
|
'preferred_region'
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use isNotEmpty validator for default-user-agent', () => {
|
it('should use isNotEmpty validator for default_user_agent', () => {
|
||||||
StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
||||||
|
|
||||||
expect(isNotEmpty).toHaveBeenCalledWith('Select a user agent');
|
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();
|
StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
||||||
|
|
||||||
expect(isNotEmpty).toHaveBeenCalledWith('Select a stream profile');
|
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();
|
StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
||||||
|
|
||||||
expect(isNotEmpty).toHaveBeenCalledWith('Select a region');
|
expect(isNotEmpty).toHaveBeenCalledWith('Select a region');
|
||||||
|
|
@ -86,21 +86,21 @@ describe('StreamSettingsFormUtils', () => {
|
||||||
it('should not include validation for auto-import-mapped-files', () => {
|
it('should not include validation for auto-import-mapped-files', () => {
|
||||||
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
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', () => {
|
it('should not include validation for m3u-hash-key', () => {
|
||||||
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
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', () => {
|
it('should return correct validation error messages', () => {
|
||||||
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
|
||||||
|
|
||||||
expect(result['default-user-agent']).toBe('Select a user agent');
|
expect(result['default_user_agent']).toBe('Select a user agent');
|
||||||
expect(result['default-stream-profile']).toBe('Select a stream profile');
|
expect(result['default_stream_profile']).toBe('Select a stream profile');
|
||||||
expect(result['preferred-region']).toBe('Select a region');
|
expect(result['preferred_region']).toBe('Select a region');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@ describe('SystemSettingsFormUtils', () => {
|
||||||
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
|
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
'max-system-events': 100
|
'max_system_events': 100
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return number value for max-system-events', () => {
|
it('should return number value for max-system-events', () => {
|
||||||
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
|
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
|
||||||
|
|
||||||
expect(result['max-system-events']).toBe(100);
|
expect(result['max_system_events']).toBe(100);
|
||||||
expect(typeof result['max-system-events']).toBe('number');
|
expect(typeof result['max_system_events']).toBe('number');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a new object each time', () => {
|
it('should return a new object each time', () => {
|
||||||
|
|
@ -29,7 +29,7 @@ describe('SystemSettingsFormUtils', () => {
|
||||||
it('should have max-system-events property', () => {
|
it('should have max-system-events property', () => {
|
||||||
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
|
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) => {
|
export const saveChangedSettings = async (settings, changedSettings) => {
|
||||||
for (const updatedKey in changedSettings) {
|
// Group changes by their setting group based on field name prefixes
|
||||||
const existing = settings[updatedKey];
|
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) {
|
if (existing?.id) {
|
||||||
const result = await updateSetting({
|
const result = await updateSetting({ ...existing, value: newValue });
|
||||||
...existing,
|
|
||||||
value: changedSettings[updatedKey],
|
|
||||||
});
|
|
||||||
// API functions return undefined on error
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error('Failed to update setting');
|
throw new Error(`Failed to update ${groupKey}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await createSetting({
|
const name = groupKey.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||||
key: updatedKey,
|
const result = await createSetting({ key: groupKey, name: name, value: newValue });
|
||||||
name: updatedKey.replace(/-/g, ' '),
|
|
||||||
value: changedSettings[updatedKey],
|
|
||||||
});
|
|
||||||
// API functions return undefined on error
|
|
||||||
if (!result) {
|
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 = {};
|
const changedSettings = {};
|
||||||
|
|
||||||
for (const settingKey in values) {
|
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
|
// Only compare against existing value if the setting exists
|
||||||
const existing = settings[settingKey];
|
const existing = settings[settingKey];
|
||||||
|
|
||||||
// Convert array values (like m3u-hash-key) to comma-separated strings
|
// Convert array values (like m3u_hash_key) to comma-separated strings for comparison
|
||||||
const stringValue = Array.isArray(values[settingKey])
|
let compareValue;
|
||||||
? values[settingKey].join(',')
|
let actualValue = values[settingKey];
|
||||||
: `${values[settingKey]}`;
|
|
||||||
|
if (Array.isArray(actualValue)) {
|
||||||
|
actualValue = actualValue.join(',');
|
||||||
|
compareValue = actualValue;
|
||||||
|
} else {
|
||||||
|
compareValue = String(actualValue);
|
||||||
|
}
|
||||||
|
|
||||||
// Skip empty values to avoid validation errors
|
// Skip empty values to avoid validation errors
|
||||||
if (!stringValue) {
|
if (!compareValue) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
// Create new setting on save
|
// Create new setting on save - preserve original type
|
||||||
changedSettings[settingKey] = stringValue;
|
changedSettings[settingKey] = actualValue;
|
||||||
} else if (stringValue !== String(existing.value)) {
|
} else if (compareValue !== String(existing.value)) {
|
||||||
// If the user changed the setting's value from what's in the DB:
|
// If the user changed the setting's value from what's in the DB - preserve original type
|
||||||
changedSettings[settingKey] = stringValue;
|
changedSettings[settingKey] = actualValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changedSettings;
|
return changedSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseSettings = (settings) => {
|
export const parseSettings = (settings) => {
|
||||||
return Object.entries(settings).reduce((acc, [key, value]) => {
|
const parsed = {};
|
||||||
// Modify each value based on its own properties
|
|
||||||
switch (value.value) {
|
|
||||||
case 'true':
|
|
||||||
value.value = true;
|
|
||||||
break;
|
|
||||||
case 'false':
|
|
||||||
value.value = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let val = null;
|
// Stream settings - direct mapping with underscore keys
|
||||||
switch (key) {
|
const streamSettings = settings['stream_settings']?.value;
|
||||||
case 'm3u-hash-key':
|
if (streamSettings && typeof streamSettings === 'object') {
|
||||||
// Split comma-separated string, filter out empty strings
|
// IDs must be strings for Select components
|
||||||
val = value.value ? value.value.split(',').filter((v) => v) : [];
|
parsed.default_user_agent = streamSettings.default_user_agent != null ? String(streamSettings.default_user_agent) : null;
|
||||||
break;
|
parsed.default_stream_profile = streamSettings.default_stream_profile != null ? String(streamSettings.default_stream_profile) : null;
|
||||||
case 'dvr-pre-offset-minutes':
|
parsed.preferred_region = streamSettings.preferred_region;
|
||||||
case 'dvr-post-offset-minutes':
|
parsed.auto_import_mapped_files = streamSettings.auto_import_mapped_files;
|
||||||
val = Number.parseInt(value.value || '0', 10);
|
|
||||||
if (Number.isNaN(val)) val = 0;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
val = value.value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[key] = val;
|
// m3u_hash_key should be array
|
||||||
return acc;
|
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