Dispatcharr/apps/m3u/models.py
None 0c0e802cf2 refactor(orphan-cleanup): store as custom_properties mode
Drop 3UAccount.auto_cleanup_unused_channels field and its migration (apps/m3u/0020). Replace with a 3-state string under M3UAccount.custom_properties.orphan_channel_cleanup: "always" (default), "preserve_customized", "never".

Move the UI from a checkbox on the M3U account form to a SegmentedControl at the top of LiveGroupFilter. Local state mirrors the prop for immediate click feedback; reverts on API error.

M3UAccountSerializer.update() now merges incoming custom_properties before layering the typed preference fields on top.

Add LiveGroupFilter.test.jsx (5 cases). Replace AutoCleanupToggleTests with OrphanCleanupModeTests (5 cases) for the new 3-state contract.
2026-05-01 23:24:40 -05:00

357 lines
13 KiB
Python

from datetime import datetime, timezone
from django.db import models
from django.core.exceptions import ValidationError
from core.models import UserAgent
import re
from django.dispatch import receiver
from apps.channels.models import StreamProfile
from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings, UserAgent
CUSTOM_M3U_ACCOUNT_NAME = "custom"
class M3UAccount(models.Model):
class Types(models.TextChoices):
STADNARD = "STD", "Standard"
XC = "XC", "Xtream Codes"
class Status(models.TextChoices):
IDLE = "idle", "Idle"
FETCHING = "fetching", "Fetching"
PARSING = "parsing", "Parsing"
ERROR = "error", "Error"
SUCCESS = "success", "Success"
PENDING_SETUP = "pending_setup", "Pending Setup"
DISABLED = "disabled", "Disabled"
"""Represents an M3U Account for IPTV streams."""
name = models.CharField(
max_length=255, unique=True, help_text="Unique name for this M3U account"
)
server_url = models.URLField(
max_length=1000,
blank=True,
null=True,
help_text="The base URL of the M3U server (optional if a file is uploaded)",
)
file_path = models.CharField(max_length=255, blank=True, null=True)
server_group = models.ForeignKey(
"ServerGroup",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="m3u_accounts",
help_text="The server group this M3U account belongs to",
)
max_streams = models.PositiveIntegerField(
default=0, help_text="Maximum number of concurrent streams (0 for unlimited)"
)
is_active = models.BooleanField(
default=True, help_text="Set to false to deactivate this M3U account"
)
created_at = models.DateTimeField(
auto_now_add=True, help_text="Time when this account was created"
)
updated_at = models.DateTimeField(
null=True,
blank=True,
help_text="Time when this account was last successfully refreshed",
)
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.IDLE
)
last_message = models.TextField(
null=True,
blank=True,
help_text="Last status message, including success results or error information",
)
user_agent = models.ForeignKey(
"core.UserAgent",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="m3u_accounts",
help_text="The User-Agent associated with this M3U account.",
)
locked = models.BooleanField(
default=False, help_text="Protected - can't be deleted or modified"
)
stream_profile = models.ForeignKey(
StreamProfile,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="m3u_accounts",
)
account_type = models.CharField(choices=Types.choices, default=Types.STADNARD)
username = models.CharField(max_length=255, null=True, blank=True)
password = models.CharField(max_length=255, null=True, blank=True)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
refresh_interval = models.IntegerField(default=0)
refresh_task = models.ForeignKey(
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
)
stale_stream_days = models.PositiveIntegerField(
default=7,
help_text="Number of days after which a stream will be removed if not seen in the M3U source.",
)
priority = models.PositiveIntegerField(
default=0,
help_text="Priority for VOD provider selection (higher numbers = higher priority). Used when multiple providers offer the same content.",
)
def __str__(self):
return self.name
def clean(self):
if self.max_streams < 0:
raise ValidationError("Max streams cannot be negative.")
def display_action(self):
return "Exclude" if self.exclude else "Include"
@classmethod
def get_custom_account(cls):
return cls.objects.get(name=CUSTOM_M3U_ACCOUNT_NAME, locked=True)
def get_user_agent(self):
user_agent = self.user_agent
if not user_agent:
user_agent = UserAgent.objects.get(
id=CoreSettings.get_default_user_agent_id()
)
return user_agent
def save(self, *args, **kwargs):
# Prevent auto_now behavior by handling updated_at manually
if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]:
# Don't modify updated_at for regular updates
kwargs.setdefault("update_fields", [])
if "updated_at" in kwargs["update_fields"]:
kwargs["update_fields"].remove("updated_at")
super().save(*args, **kwargs)
# def get_channel_groups(self):
# return ChannelGroup.objects.filter(m3u_account__m3u_account=self)
# def is_channel_group_enabled(self, channel_group):
# """Check if the specified ChannelGroup is enabled for this M3UAccount."""
# return self.channel_group.filter(channel_group=channel_group, enabled=True).exists()
# def get_enabled_streams(self):
# """Return all streams linked to this account with enabled ChannelGroups."""
# return self.streams.filter(channel_group__in=ChannelGroup.objects.filter(m3u_account__enabled=True))
class M3UFilter(models.Model):
"""Defines filters for M3U accounts based on stream name or group title."""
FILTER_TYPE_CHOICES = (
("group", "Group"),
("name", "Stream Name"),
("url", "Stream URL"),
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name="filters",
help_text="The M3U account this filter is applied to.",
)
filter_type = models.CharField(
max_length=50,
choices=FILTER_TYPE_CHOICES,
default="group",
help_text="Filter based on either group title or stream name.",
)
regex_pattern = models.CharField(
max_length=200, help_text="A regex pattern to match streams or groups."
)
exclude = models.BooleanField(
default=True,
help_text="If True, matching items are excluded; if False, only matches are included.",
)
order = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == "group" else stream_name
return bool(re.search(self.regex_pattern, target, re.IGNORECASE))
def clean(self):
try:
re.compile(self.regex_pattern)
except re.error:
raise ValidationError(f"Invalid regex pattern: {self.regex_pattern}")
def __str__(self):
filter_type_display = dict(self.FILTER_TYPE_CHOICES).get(
self.filter_type, "Unknown"
)
exclude_status = "Exclude" if self.exclude else "Include"
return f"[{self.m3u_account.name}] {filter_type_display}: {self.regex_pattern} ({exclude_status})"
class ServerGroup(models.Model):
"""Represents a logical grouping of servers or channels."""
name = models.CharField(
max_length=100, unique=True, help_text="Unique name for this server group."
)
def __str__(self):
return self.name
class M3UAccountProfile(models.Model):
"""Represents a profile associated with an M3U Account."""
m3u_account = models.ForeignKey(
"M3UAccount",
on_delete=models.CASCADE,
related_name="profiles",
help_text="The M3U account this profile belongs to.",
)
name = models.CharField(
max_length=255, help_text="Name for the M3U account profile"
)
is_default = models.BooleanField(
default=False, help_text="Set to false to deactivate this profile"
)
max_streams = models.PositiveIntegerField(
default=0, help_text="Maximum number of concurrent streams (0 for unlimited)"
)
is_active = models.BooleanField(
default=True, help_text="Set to false to deactivate this profile"
)
search_pattern = models.CharField(
max_length=255,
)
replace_pattern = models.CharField(
max_length=255,
)
current_viewers = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(
default=dict,
blank=True,
null=True,
help_text="Custom properties for storing account information from provider (e.g., XC account details, expiration dates)"
)
exp_date = models.DateTimeField(
null=True,
blank=True,
help_text="Account expiration date, auto-synced from custom_properties on save",
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["m3u_account", "name"], name="unique_account_name"
)
]
def __str__(self):
return f"{self.name} ({self.m3u_account.name})"
def save(self, *args, **kwargs):
"""Auto-sync exp_date from custom_properties for XC accounts on every save.
For non-XC accounts, exp_date is set directly and left untouched here."""
parsed = self._parse_exp_date_from_custom_properties()
if parsed is not None:
# XC account with exp_date in custom_properties — always sync
self.exp_date = parsed
# else: keep whatever exp_date is already set (manual entry for non-XC)
super().save(*args, **kwargs)
@staticmethod
def _parse_exp_date(raw_value):
"""Parse a raw exp_date value (unix timestamp or ISO string) into a datetime."""
if raw_value is None:
return None
try:
if isinstance(raw_value, (int, float)):
return datetime.fromtimestamp(float(raw_value), tz=timezone.utc)
elif isinstance(raw_value, str):
try:
return datetime.fromtimestamp(float(raw_value), tz=timezone.utc)
except ValueError:
return datetime.fromisoformat(raw_value)
except (ValueError, TypeError, OSError):
pass
return None
def _parse_exp_date_from_custom_properties(self):
"""Extract exp_date from custom_properties JSON."""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return self._parse_exp_date(user_info.get('exp_date'))
def get_account_expiration(self):
"""Get account expiration date — uses the dedicated field if set, otherwise parses JSON."""
if self.exp_date:
return self.exp_date
return self._parse_exp_date_from_custom_properties()
def get_account_status(self):
"""Get account status from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('status')
def get_max_connections(self):
"""Get maximum connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('max_connections')
def get_active_connections(self):
"""Get active connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('active_cons')
def get_last_refresh(self):
"""Get last refresh timestamp from custom properties if available"""
if not self.custom_properties:
return None
last_refresh = self.custom_properties.get('last_refresh')
if last_refresh:
try:
from datetime import datetime
return datetime.fromisoformat(last_refresh)
except (ValueError, TypeError):
pass
return None
@receiver(models.signals.post_save, sender=M3UAccount)
def create_profile_for_m3u_account(sender, instance, created, **kwargs):
"""Automatically create an M3UAccountProfile when M3UAccount is created."""
if created:
M3UAccountProfile.objects.create(
m3u_account=instance,
name=f"{instance.name} Default",
max_streams=instance.max_streams,
is_default=True,
is_active=True,
search_pattern="^(.*)$",
replace_pattern="$1",
)
else:
profile = M3UAccountProfile.objects.get(
m3u_account=instance,
is_default=True,
)
profile.max_streams = instance.max_streams
profile.save()