Dispatcharr/core/models.py
SergeantPanda 2b62928b42 feat(settings): implement Redis caching for CoreSettings and enhance network access handling
This update introduces Redis caching for grouped settings in the CoreSettings model, improving performance by reducing database queries. A new method, `get_network_access_settings`, is added to streamline access to network settings. Additionally, the `setting_flag_enabled` function is introduced to handle boolean settings more robustly. The cache is invalidated on save and delete operations, ensuring data consistency. Tests have been added to validate the caching behavior and the new settings functionality.
2026-07-18 17:33:02 +00:00

719 lines
25 KiB
Python

# core/models.py
from shlex import split as shlex_split
from django.conf import settings
from django.db import models
from django.utils.text import slugify
from django.core.exceptions import ValidationError
class UserAgent(models.Model):
name = models.CharField(
max_length=512, unique=True, help_text="The User-Agent name."
)
user_agent = models.CharField(
max_length=512,
unique=True,
help_text="The complete User-Agent string sent by the client.",
)
description = models.CharField(
max_length=255,
blank=True,
help_text="An optional description of the client or device type.",
)
is_active = models.BooleanField(
default=True,
help_text="Whether this user agent is currently allowed/recognized.",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
PROXY_PROFILE_NAME = "Proxy"
REDIRECT_PROFILE_NAME = "Redirect"
class StreamProfile(models.Model):
name = models.CharField(max_length=255, help_text="Name of the stream profile")
command = models.CharField(
max_length=255,
help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')",
blank=True,
)
parameters = models.TextField(
help_text="Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.",
blank=True,
)
locked = models.BooleanField(
default=False, help_text="Protected - can't be deleted or modified"
)
is_active = models.BooleanField(
default=True, help_text="Whether this profile is active"
)
user_agent = models.ForeignKey(
"UserAgent",
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Optional user agent to use. If not set, you can fall back to a default.",
)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if self.pk: # Only check existing records
orig = StreamProfile.objects.get(pk=self.pk)
if orig.locked:
allowed_fields = {"user_agent_id"} # Only allow this field to change
for field in self._meta.fields:
field_name = field.name
# Convert user_agent to user_agent_id for comparison
orig_value = getattr(orig, field_name)
new_value = getattr(self, field_name)
# Ensure that ForeignKey fields compare their ID values
if isinstance(orig_value, models.Model):
orig_value = orig_value.pk
if isinstance(new_value, models.Model):
new_value = new_value.pk
if field_name not in allowed_fields and orig_value != new_value:
raise ValidationError(
f"Cannot modify {field_name} on a protected profile."
)
super().save(*args, **kwargs)
@classmethod
def update(cls, pk, **kwargs):
instance = cls.objects.get(pk=pk)
if instance.locked:
allowed_fields = {"user_agent_id"} # Only allow updating this field
for field_name, new_value in kwargs.items():
if field_name not in allowed_fields:
raise ValidationError(
f"Cannot modify {field_name} on a protected profile."
)
# Ensure user_agent ForeignKey updates correctly
if field_name == "user_agent" and isinstance(
new_value, cls._meta.get_field("user_agent").related_model
):
new_value = new_value.pk # Convert object to ID if needed
setattr(instance, field_name, new_value)
instance.save()
return instance
def is_proxy(self):
if self.locked and self.name == PROXY_PROFILE_NAME:
return True
return False
def is_redirect(self):
if self.locked and self.name == REDIRECT_PROFILE_NAME:
return True
return False
def build_command(self, stream_url, user_agent):
if self.is_proxy():
return []
replacements = {
"{streamUrl}": stream_url,
"{userAgent}": user_agent,
}
# Split the command and iterate through each part to apply replacements
cmd = [self.command] + [
self._replace_in_part(part, replacements)
for part in shlex_split(self.parameters) # use shlex to handle quoted strings
]
return cmd
def _replace_in_part(self, part, replacements):
# Iterate through the replacements and replace each part of the string
for key, value in replacements.items():
part = part.replace(key, value)
return part
class OutputProfile(models.Model):
"""
Defines a pre-delivery transcode step applied to a channel's TS stream.
The command and parameters must accept raw MPEG-TS via pipe:0 (stdin) and
write the transcoded output to pipe:1 (stdout). One transcode process runs
per active (channel, OutputProfile) pair regardless of how many clients
request it; all clients share the same Redis-backed output buffer.
Example parameters for a 720p transcode:
-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1
"""
name = models.CharField(max_length=255, unique=True, help_text="Display name for this output profile")
command = models.CharField(
max_length=255,
help_text="Executable to run (e.g. 'ffmpeg')",
)
parameters = models.TextField(
help_text="Command-line parameters. Must read from pipe:0 (stdin) and write to pipe:1 (stdout).",
)
locked = models.BooleanField(
default=False, help_text="Protected - can't be deleted or modified"
)
is_active = models.BooleanField(
default=True, help_text="Whether this profile is available for use"
)
def __str__(self):
return self.name
def build_command(self):
"""Return the full command as a list suitable for subprocess.Popen."""
from shlex import split as shlex_split
return [self.command] + shlex_split(self.parameters)
# Setting group keys
STREAM_SETTINGS_KEY = "stream_settings"
DVR_SETTINGS_KEY = "dvr_settings"
BACKUP_SETTINGS_KEY = "backup_settings"
PROXY_SETTINGS_KEY = "proxy_settings"
NETWORK_ACCESS_KEY = "network_access"
SYSTEM_SETTINGS_KEY = "system_settings"
EPG_SETTINGS_KEY = "epg_settings"
USER_LIMITS_SETTINGS_KEY = "user_limit_settings"
# Redis cache for CoreSettings JSON groups. Primary invalidation is post_save /
# post_delete; TTL is a safety net if a writer bypasses signals.
_GROUP_CACHE_PREFIX = "coresettings:group:"
_GROUP_CACHE_TTL_SECONDS = 300
def setting_flag_enabled(value, *, default=True):
"""Parse a settings/JSON flag that should default to *default* when unset.
Explicit ``False`` / ``0`` / common false strings disable. Explicit
``True`` / ``1`` / common true strings enable. Other types fall back to
*default* so garbage payloads do not accidentally enable a kill-switch.
"""
if value is None:
return default
if value is False or value == 0:
return False
if value is True or value == 1:
return True
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("false", "0", "no", "off", ""):
return False
if lowered in ("true", "1", "yes", "on"):
return True
return default
return default
class CoreSettings(models.Model):
key = models.CharField(
max_length=255,
unique=True,
)
name = models.CharField(
max_length=255,
)
value = models.JSONField(
default=dict,
blank=True,
)
def __str__(self):
return "Core Settings"
@classmethod
def group_cache_key(cls, key):
return f"{_GROUP_CACHE_PREFIX}{key}"
@classmethod
def invalidate_group_cache(cls, key):
"""Drop the cached JSON for a settings group (all workers share Redis)."""
from django.core.cache import cache
cache.delete(cls.group_cache_key(key))
if key == PROXY_SETTINGS_KEY:
# Proxy workers also keep a short process-local copy.
try:
from apps.proxy.config import BaseConfig
BaseConfig.clear_proxy_settings_cache()
except Exception:
pass
# Helper methods to get/set grouped settings
@classmethod
def _get_group(cls, key, defaults=None):
"""Get a settings group, returning defaults if not found.
Results are cached in Redis so hot paths (proxy, XC, catchup) do not
hit Postgres on every client request. Mutations go through ``save`` /
``_update_group``, which invalidate via CoreSettings post_save /
post_delete signals.
"""
import copy
from django.core.cache import cache
defaults = defaults or {}
cache_key = cls.group_cache_key(key)
cached = cache.get(cache_key)
if isinstance(cached, dict):
return copy.deepcopy(cached)
try:
value = cls.objects.get(key=key).value or defaults
if not isinstance(value, dict):
value = defaults
except cls.DoesNotExist:
value = defaults
value = copy.deepcopy(value)
cache.set(cache_key, value, timeout=_GROUP_CACHE_TTL_SECONDS)
return copy.deepcopy(value)
@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": "",
"default_output_format": "mpegts",
"hdhr_output_profile_id": None,
})
@classmethod
def get_default_user_agent_id(cls):
return cls.get_stream_settings().get("default_user_agent")
@classmethod
def get_default_stream_profile_id(cls):
return cls.get_stream_settings().get("default_stream_profile")
@classmethod
def get_default_output_format(cls):
return cls.get_stream_settings().get("default_output_format", "mpegts")
@classmethod
def get_m3u_hash_key(cls):
return cls.get_stream_settings().get("m3u_hash_key", "")
@classmethod
def get_preferred_region(cls):
return cls.get_system_settings().get("preferred_region")
@classmethod
def get_auto_import_mapped_files(cls):
return cls.get_system_settings().get("auto_import_mapped_files")
# EPG Settings
@classmethod
def get_epg_settings(cls):
"""Get all EPG-related settings."""
return cls._get_group(EPG_SETTINGS_KEY, {
"epg_match_mode": "default",
"epg_match_ignore_prefixes": [],
"epg_match_ignore_suffixes": [],
"epg_match_ignore_custom": [],
})
@classmethod
def _safe_string_list(cls, value):
"""Return a list of strings, filtering out non-list or non-string values."""
if not isinstance(value, list):
return []
return [v for v in value if isinstance(v, str)]
@classmethod
def get_epg_match_ignore_prefixes(cls):
return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_prefixes", []))
@classmethod
def get_epg_match_ignore_suffixes(cls):
return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_suffixes", []))
@classmethod
def get_epg_match_ignore_custom(cls):
return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_custom", []))
# DVR Settings
@classmethod
def get_dvr_settings(cls):
"""Get all DVR-related settings."""
return cls._get_group(DVR_SETTINGS_KEY, {
"tv_template": "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv",
"movie_template": "Movies/{title} ({year}).mkv",
"tv_fallback_dir": "TV_Shows",
"tv_fallback_template": "TV_Shows/{show}/{start}.mkv",
"movie_fallback_template": "Movies/{start}.mkv",
"comskip_enabled": False,
"comskip_custom_path": "",
"comskip_mode": "cut",
"comskip_hw_accel": "none",
"pre_offset_minutes": 0,
"post_offset_minutes": 0,
"series_rules": [],
})
@classmethod
def get_dvr_tv_template(cls):
return cls.get_dvr_settings().get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv")
@classmethod
def get_dvr_movie_template(cls):
return cls.get_dvr_settings().get("movie_template", "Movies/{title} ({year}).mkv")
@classmethod
def get_dvr_tv_fallback_dir(cls):
return cls.get_dvr_settings().get("tv_fallback_dir", "TV_Shows")
@classmethod
def get_dvr_tv_fallback_template(cls):
return cls.get_dvr_settings().get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv")
@classmethod
def get_dvr_movie_fallback_template(cls):
return cls.get_dvr_settings().get("movie_fallback_template", "Movies/{start}.mkv")
@classmethod
def get_dvr_comskip_enabled(cls):
return bool(cls.get_dvr_settings().get("comskip_enabled", False))
@classmethod
def get_dvr_comskip_mode(cls):
mode = cls.get_dvr_settings().get("comskip_mode", "cut")
return mode if mode in ("cut", "mark") else "cut"
@classmethod
def get_dvr_comskip_hw_accel(cls):
hw = cls.get_dvr_settings().get("comskip_hw_accel", "none")
return hw if hw in ("none", "cuvid", "qsv") else "none"
@classmethod
def get_dvr_comskip_custom_path(cls):
return cls.get_dvr_settings().get("comskip_custom_path", "")
@classmethod
def set_dvr_comskip_custom_path(cls, path: str | None):
value = (path or "").strip()
cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"comskip_custom_path": value})
return value
@classmethod
def get_dvr_pre_offset_minutes(cls):
return int(cls.get_dvr_settings().get("pre_offset_minutes", 0) or 0)
@classmethod
def get_dvr_post_offset_minutes(cls):
return int(cls.get_dvr_settings().get("post_offset_minutes", 0) or 0)
@classmethod
def get_dvr_series_rules(cls):
rules = cls.get_dvr_settings().get("series_rules", [])
if not isinstance(rules, list):
return []
return [r for r in rules if isinstance(r, dict)]
@classmethod
def set_dvr_series_rules(cls, rules):
clean = [r for r in rules if isinstance(r, dict)] if isinstance(rules, list) else []
cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"series_rules": clean})
return clean
# Proxy Settings
@classmethod
def get_proxy_settings(cls):
"""Get proxy settings."""
return cls._get_group(PROXY_SETTINGS_KEY, {
"buffering_timeout": 15,
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 60,
"channel_client_wait_period": 5,
"new_client_behind_seconds": 5,
})
@classmethod
def get_network_access_settings(cls):
"""CIDR allowlists per endpoint type (UI, STREAMS, XC_API, M3U_EPG, ...)."""
return cls._get_group(NETWORK_ACCESS_KEY, {})
# 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,
"preferred_region": None,
"auto_import_mapped_files": True,
"enable_ip_lookup": True,
"catchup_enabled": True,
})
@classmethod
def get_catchup_enabled(cls):
"""Whether catch-up / timeshift is enabled system-wide (default True)."""
return setting_flag_enabled(
cls.get_system_settings().get("catchup_enabled"),
default=True,
)
@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
@classmethod
def get_hdhr_output_profile_id(cls):
raw = cls.get_stream_settings().get("hdhr_output_profile_id")
try:
return int(raw) if raw is not None else None
except (ValueError, TypeError):
return None
@classmethod
def get_user_limits_settings(cls):
return cls._get_group(USER_LIMITS_SETTINGS_KEY, {
"terminate_on_limit_exceeded": True,
"prioritize_single_client_channels": True,
"ignore_same_channel_connections": False,
"terminate_oldest": True,
})
class SystemEvent(models.Model):
"""
Tracks system events like channel start/stop, buffering, failover, client connections.
Maintains a rolling history based on max_system_events setting.
"""
EVENT_TYPES = [
('channel_start', 'Channel Started'),
('channel_stop', 'Channel Stopped'),
('channel_buffering', 'Channel Buffering'),
('channel_failover', 'Channel Failover'),
('channel_reconnect', 'Channel Reconnected'),
('channel_error', 'Channel Error'),
('client_connect', 'Client Connected'),
('client_disconnect', 'Client Disconnected'),
('recording_start', 'Recording Started'),
('recording_end', 'Recording Ended'),
('stream_switch', 'Stream Switched'),
('m3u_refresh', 'M3U Refreshed'),
('m3u_download', 'M3U Downloaded'),
('epg_refresh', 'EPG Refreshed'),
('epg_download', 'EPG Downloaded'),
('login_success', 'Login Successful'),
('login_failed', 'Login Failed'),
('logout', 'User Logged Out'),
('m3u_blocked', 'M3U Download Blocked'),
('epg_blocked', 'EPG Download Blocked'),
('vod_start', 'VOD Started'),
('vod_stop', 'VOD Stopped'),
]
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
channel_id = models.UUIDField(null=True, blank=True, db_index=True)
channel_name = models.CharField(max_length=255, null=True, blank=True)
details = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['-timestamp']),
models.Index(fields=['event_type', '-timestamp']),
]
def __str__(self):
return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}"
class SystemNotification(models.Model):
"""
Stores system notifications that users can view and dismiss.
Used for version updates, recommended settings, announcements, etc.
"""
class NotificationType(models.TextChoices):
VERSION_UPDATE = 'version_update', 'Version Update Available'
SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change'
ANNOUNCEMENT = 'announcement', 'System Announcement'
WARNING = 'warning', 'Warning'
INFO = 'info', 'Information'
class Priority(models.TextChoices):
LOW = 'low', 'Low'
NORMAL = 'normal', 'Normal'
HIGH = 'high', 'High'
CRITICAL = 'critical', 'Critical'
class Source(models.TextChoices):
SYSTEM = 'system', 'System Generated'
DEVELOPER = 'developer', 'Developer Notification'
# Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer')
# This allows deduplication and targeted dismissals
notification_key = models.CharField(max_length=255, unique=True, db_index=True)
notification_type = models.CharField(
max_length=50,
choices=NotificationType.choices,
default=NotificationType.INFO,
db_index=True
)
priority = models.CharField(
max_length=20,
choices=Priority.choices,
default=Priority.NORMAL
)
# Source of the notification (system-generated vs developer-defined)
source = models.CharField(
max_length=20,
choices=Source.choices,
default=Source.SYSTEM,
db_index=True
)
title = models.CharField(max_length=255)
message = models.TextField()
# Optional action data (e.g., setting key/value for recommendations, release URL for versions)
action_data = models.JSONField(default=dict, blank=True)
# Whether this notification is currently active
is_active = models.BooleanField(default=True, db_index=True)
# Admin-only notifications require admin privileges to view
admin_only = models.BooleanField(default=False)
# Auto-expire after this date (null = never expires)
expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-priority', '-created_at']
indexes = [
models.Index(fields=['is_active', '-created_at']),
models.Index(fields=['notification_type', 'is_active']),
models.Index(fields=['source', 'is_active']),
]
def __str__(self):
return f"[{self.notification_type}] {self.title}"
@classmethod
def create_version_notification(cls, version, release_url=None, release_notes=None):
"""Create or update a version update notification. Returns (notification, created) tuple."""
key = f"version-{version}"
notification, created = cls.objects.update_or_create(
notification_key=key,
defaults={
'notification_type': cls.NotificationType.VERSION_UPDATE,
'priority': cls.Priority.HIGH,
'title': f'Version {version} Available',
'message': f'A new version of Dispatcharr ({version}) is available.',
'action_data': {
'version': version,
'release_url': release_url,
'release_notes': release_notes,
},
'is_active': True,
'admin_only': True,
}
)
return notification, created
@classmethod
def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None):
"""Create a setting recommendation notification. Returns (notification, created) tuple."""
key = f"setting-{setting_key}"
notification, created = cls.objects.update_or_create(
notification_key=key,
defaults={
'notification_type': cls.NotificationType.SETTING_RECOMMENDATION,
'priority': cls.Priority.NORMAL,
'title': f'Recommended Setting: {setting_key}',
'message': reason,
'action_data': {
'setting_key': setting_key,
'recommended_value': recommended_value,
'current_value': current_value,
},
'is_active': True,
'admin_only': True,
}
)
return notification, created
class NotificationDismissal(models.Model):
"""
Tracks which users have dismissed which notifications.
Allows users to dismiss notifications once without seeing them again.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='dismissed_notifications'
)
notification = models.ForeignKey(
SystemNotification,
on_delete=models.CASCADE,
related_name='dismissals'
)
dismissed_at = models.DateTimeField(auto_now_add=True)
# Optional: track if user accepted/applied the recommendation
action_taken = models.CharField(max_length=50, blank=True, null=True)
class Meta:
unique_together = ['user', 'notification']
indexes = [
models.Index(fields=['user', 'notification']),
]
def __str__(self):
return f"{self.user.username} dismissed {self.notification.notification_key}"