diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6571bfcf..232521b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Search and filter controls**: Added search and filter controls to the recordings list.
- **Stream generator throttling**: Cached the `ProxyServer` singleton reference per client and throttled Redis resource checks (1 s) and non-owner health checks (2 s), eliminating 3+ Redis round-trips per stream loop iteration.
- **Automatic crash recovery on worker restart**: A `worker_ready` Celery signal now fires `recover_recordings_on_startup` automatically when the worker starts, so recordings stuck in "recording" status are recovered without manual intervention.
+- Account expiration tracking and notifications for M3U profiles
+ - A new `exp_date` field on `M3UAccountProfile` stores the account expiration date as a proper `DateTimeField`. For Xtream Codes accounts the field is auto-synced from `custom_properties.user_info.exp_date` on every save (supports both Unix timestamps and ISO date strings). For non-XC M3U accounts the date can be entered manually via the account or profile form.
+ - The M3U accounts table now shows an **Expiration** column displaying the earliest expiration date across all profiles for that account (color-coded: red = expired, orange = expiring soon, green = OK). Hovering the cell shows a tooltip with per-profile expiration details including inactive-profile labels.
+ - A daily Celery Beat task (`check_xc_account_expirations`) checks all active profiles with an expiration date and manages system notifications: a normal-priority warning is raised for profiles expiring within 7 days; a high-priority alert is raised once the profile has already expired. Warning and expired notifications use separate keys so dismissing the 7-day warning does not suppress the expiration alert.
+ - Notifications are also updated immediately when a profile is saved: if the expiration date is cleared or pushed beyond the 7-day window, any existing warning/expired notifications are deleted; if the date falls within the window or is already past, the matching notification is updated in place.
+ - Non-XC accounts expose a `DateTimePicker` on both the M3U account form and the profile form.
### Changed
diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py
index 34f3cd77..3b856dc8 100644
--- a/apps/m3u/api_views.py
+++ b/apps/m3u/api_views.py
@@ -40,7 +40,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
queryset = M3UAccount.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
- ).prefetch_related("channel_group")
+ ).prefetch_related("channel_group", "profiles")
serializer_class = M3UAccountSerializer
def get_permissions(self):
diff --git a/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py b/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py
new file mode 100644
index 00000000..bae4d9dd
--- /dev/null
+++ b/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py
@@ -0,0 +1,57 @@
+# Generated by Django 6.0.3 on 2026-03-14 19:41
+
+from datetime import datetime, timezone
+
+from django.db import migrations, models
+
+
+def populate_exp_date_from_custom_properties(apps, schema_editor):
+ """Backfill exp_date from custom_properties['user_info']['exp_date']."""
+ M3UAccountProfile = apps.get_model('m3u', 'M3UAccountProfile')
+ profiles_to_update = []
+
+ for profile in M3UAccountProfile.objects.filter(
+ custom_properties__isnull=False,
+ ).exclude(custom_properties={}):
+ user_info = profile.custom_properties.get('user_info', {})
+ raw_exp = user_info.get('exp_date')
+ if raw_exp is None:
+ continue
+
+ parsed = None
+ try:
+ if isinstance(raw_exp, (int, float)):
+ parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
+ elif isinstance(raw_exp, str):
+ try:
+ parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
+ except ValueError:
+ parsed = datetime.fromisoformat(raw_exp)
+ except (ValueError, TypeError, OSError):
+ pass
+
+ if parsed is not None:
+ profile.exp_date = parsed
+ profiles_to_update.append(profile)
+
+ if profiles_to_update:
+ M3UAccountProfile.objects.bulk_update(profiles_to_update, ['exp_date'], batch_size=500)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('m3u', '0018_add_profile_custom_properties'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='m3uaccountprofile',
+ name='exp_date',
+ field=models.DateTimeField(blank=True, help_text='Account expiration date, auto-synced from custom_properties on save', null=True),
+ ),
+ migrations.RunPython(
+ populate_exp_date_from_custom_properties,
+ reverse_code=migrations.RunPython.noop,
+ ),
+ ]
diff --git a/apps/m3u/models.py b/apps/m3u/models.py
index b812ad6c..fbe22d8c 100644
--- a/apps/m3u/models.py
+++ b/apps/m3u/models.py
@@ -1,3 +1,4 @@
+from datetime import datetime, timezone
from django.db import models
from django.core.exceptions import ValidationError
from core.models import UserAgent
@@ -264,11 +265,16 @@ class M3UAccountProfile(models.Model):
)
current_viewers = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(
- default=dict,
- blank=True,
- null=True,
+ 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 = [
@@ -280,36 +286,51 @@ class M3UAccountProfile(models.Model):
def __str__(self):
return f"{self.name} ({self.m3u_account.name})"
- def get_account_expiration(self):
- """Get account expiration date from custom properties if available"""
+ 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', {})
- exp_date = user_info.get('exp_date')
-
- if exp_date:
- try:
- from datetime import datetime
- # XC exp_date is typically a Unix timestamp
- if isinstance(exp_date, (int, float)):
- return datetime.fromtimestamp(exp_date)
- elif isinstance(exp_date, str):
- # Try to parse as timestamp first, then as ISO date
- try:
- return datetime.fromtimestamp(float(exp_date))
- except ValueError:
- return datetime.fromisoformat(exp_date)
- except (ValueError, TypeError):
- pass
-
- return None
+ 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')
@@ -317,7 +338,7 @@ class M3UAccountProfile(models.Model):
"""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')
@@ -325,7 +346,7 @@ class M3UAccountProfile(models.Model):
"""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')
@@ -333,7 +354,7 @@ class M3UAccountProfile(models.Model):
"""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:
@@ -341,7 +362,7 @@ class M3UAccountProfile(models.Model):
return datetime.fromisoformat(last_refresh)
except (ValueError, TypeError):
pass
-
+
return None
diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py
index 22c4057a..587d98cb 100644
--- a/apps/m3u/serializers.py
+++ b/apps/m3u/serializers.py
@@ -7,6 +7,7 @@ from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
from apps.channels.serializers import (
ChannelGroupM3UAccountSerializer,
)
+from datetime import timezone as dt_tz
import logging
import json
@@ -52,12 +53,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
"search_pattern",
"replace_pattern",
"custom_properties",
+ "exp_date",
"account",
]
read_only_fields = ["id", "account"]
extra_kwargs = {
'search_pattern': {'required': False, 'allow_blank': True},
'replace_pattern': {'required': False, 'allow_blank': True},
+ 'exp_date': {'required': False, 'allow_null': True},
}
def create(self, validated_data):
@@ -90,14 +93,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
if instance.is_default:
- # For default profiles, only allow updating name and custom_properties (for notes)
- allowed_fields = {'name', 'custom_properties'}
+ # For default profiles, only allow updating name, custom_properties, and exp_date
+ allowed_fields = {'name', 'custom_properties', 'exp_date'}
# Remove any fields that aren't allowed for default profiles
disallowed_fields = set(validated_data.keys()) - allowed_fields
if disallowed_fields:
raise serializers.ValidationError(
- f"Default profiles can only modify name and notes. "
+ f"Default profiles can only modify name, notes, and expiration. "
f"Cannot modify: {', '.join(disallowed_fields)}"
)
@@ -117,6 +120,12 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = serializers.SerializerMethodField()
+ earliest_expiration = serializers.SerializerMethodField()
+ all_expirations = serializers.SerializerMethodField()
+ exp_date = serializers.DateTimeField(
+ required=False, allow_null=True, write_only=True,
+ help_text="Expiration date for the default profile (write-through)",
+ )
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
@@ -172,6 +181,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"auto_enable_new_groups_live",
"auto_enable_new_groups_vod",
"auto_enable_new_groups_series",
+ "earliest_expiration",
+ "all_expirations",
+ "exp_date",
]
extra_kwargs = {
"password": {
@@ -200,9 +212,24 @@ class M3UAccountSerializer(serializers.ModelSerializer):
ct = instance.refresh_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
data["cron_expression"] = cron_expr
+
+ # Surface default profile's exp_date for the form.
+ # Use prefetch cache (obj.profiles.all()) to avoid an extra query per account.
+ # Always emit a Z-suffix UTC string so JS new Date() never misinterprets it as local.
+ default_profile = next((p for p in instance.profiles.all() if p.is_default), None)
+ exp = default_profile.exp_date if default_profile else None
+ if exp:
+ exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc)
+ data["exp_date"] = exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
+ else:
+ data["exp_date"] = None
+
return data
def update(self, instance, validated_data):
+ # Pop exp_date — it's written to the default profile, not the account
+ exp_date = validated_data.pop("exp_date", "__NOT_SET__")
+
# Pop cron_expression before it reaches model fields
# If not present (partial update), preserve the existing cron from the PeriodicTask
if "cron_expression" in validated_data:
@@ -264,9 +291,26 @@ class M3UAccountSerializer(serializers.ModelSerializer):
memberships_to_update, ["enabled"]
)
+ # Write exp_date through to the default profile.
+ # Use a fresh DB query (not the prefetch cache) so we get the profile
+ # object AFTER the post_save signal (create_profile_for_m3u_account)
+ # has already updated max_streams, avoiding a stale-value overwrite.
+ if exp_date != "__NOT_SET__":
+ default_profile = instance.profiles.filter(is_default=True).first()
+ if default_profile:
+ default_profile.exp_date = exp_date
+ default_profile.save(update_fields=['exp_date'])
+ # Invalidate the profiles prefetch cache so to_representation
+ # sees the updated exp_date rather than the pre-request snapshot.
+ if '_prefetched_objects_cache' in instance.__dict__:
+ instance._prefetched_objects_cache.pop('profiles', None)
+
return instance
def create(self, validated_data):
+ # Pop exp_date — it's written to the default profile after creation
+ exp_date = validated_data.pop("exp_date", None)
+
# Pop cron_expression — it's not a model field
cron_expr = validated_data.pop("cron_expression", "")
@@ -290,12 +334,47 @@ class M3UAccountSerializer(serializers.ModelSerializer):
instance = M3UAccount(**validated_data)
instance._cron_expression = cron_expr
instance.save()
+
+ # Write exp_date through to the default profile created by post_save signal
+ if exp_date is not None:
+ default_profile = instance.profiles.filter(is_default=True).first()
+ if default_profile:
+ default_profile.exp_date = exp_date
+ default_profile.save()
+
return instance
def get_filters(self, obj):
filters = obj.filters.order_by("order")
return M3UFilterSerializer(filters, many=True).data
+ def get_earliest_expiration(self, obj):
+ """Return the soonest exp_date across all active profiles for this account."""
+ # Filter in Python over the prefetch cache to avoid an extra query per account.
+ expiring = [p.exp_date for p in obj.profiles.all() if p.is_active and p.exp_date]
+ if not expiring:
+ return None
+ exp = min(expiring)
+ exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc)
+ return exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
+
+ def get_all_expirations(self, obj):
+ """Return exp_date info for every profile that has one (for tooltip)."""
+ # Filter in Python over the prefetch cache to avoid an extra query per account.
+ profiles = sorted(
+ (p for p in obj.profiles.all() if p.exp_date),
+ key=lambda p: p.exp_date,
+ )
+ return [
+ {
+ "profile_id": p.id,
+ "profile_name": p.name,
+ "exp_date": (p.exp_date.astimezone(dt_tz.utc) if p.exp_date.tzinfo else p.exp_date.replace(tzinfo=dt_tz.utc)).strftime('%Y-%m-%dT%H:%M:%SZ'),
+ "is_active": p.is_active,
+ }
+ for p in profiles
+ ]
+
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""
diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py
index 3de67c90..ce24d015 100644
--- a/apps/m3u/signals.py
+++ b/apps/m3u/signals.py
@@ -1,7 +1,7 @@
# apps/m3u/signals.py
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
-from .models import M3UAccount
+from .models import M3UAccount, M3UAccountProfile
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
import json
@@ -68,6 +68,62 @@ def create_or_update_refresh_task(sender, instance, created, update_fields=None,
if instance.refresh_task_id != task.id:
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
+@receiver(post_save, sender=M3UAccountProfile)
+def update_profile_expiration_notification(sender, instance, created, update_fields=None, **kwargs):
+ """
+ When a profile's exp_date is set or changed, immediately update its expiration notification
+ so the frontend reflects the new state without waiting for the daily celery task.
+ """
+ # Only act when exp_date was involved in the save
+ if not created and update_fields is not None and "exp_date" not in update_fields:
+ return
+
+ try:
+ if not instance.exp_date:
+ # exp_date was cleared — remove any existing notifications immediately
+ from core.models import SystemNotification
+ from core.utils import send_notification_dismissed
+
+ keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"]
+ deleted_keys = list(
+ SystemNotification.objects.filter(notification_key__in=keys)
+ .values_list("notification_key", flat=True)
+ )
+ SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
+ for key in deleted_keys:
+ send_notification_dismissed(key)
+ return
+
+ from apps.m3u.tasks import evaluate_profile_expiration_notification
+ evaluate_profile_expiration_notification(instance)
+ except Exception as e:
+ logger.error(f"Error updating expiration notification for profile {instance.id}: {str(e)}")
+
+
+@receiver(post_delete, sender=M3UAccountProfile)
+def cleanup_profile_notifications(sender, instance, **kwargs):
+ """
+ Delete expiration notifications for a profile when it is deleted.
+ Handles both direct deletion and cascade deletion from M3UAccount.
+ """
+ try:
+ from core.models import SystemNotification
+ from core.utils import send_notification_dismissed
+
+ keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"]
+ deleted_keys = list(
+ SystemNotification.objects.filter(notification_key__in=keys)
+ .values_list("notification_key", flat=True)
+ )
+ if deleted_keys:
+ SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
+ for key in deleted_keys:
+ send_notification_dismissed(key)
+ logger.debug(f"Cleaned up {len(deleted_keys)} notifications for deleted profile {instance.id}")
+ except Exception as e:
+ logger.error(f"Error cleaning up notifications for profile {instance.id}: {str(e)}")
+
+
@receiver(post_delete, sender=M3UAccount)
def delete_refresh_task(sender, instance, **kwargs):
"""
@@ -107,6 +163,34 @@ def update_status_on_active_change(sender, instance, **kwargs):
else:
# When deactivating, set status to disabled
instance.status = M3UAccount.Status.DISABLED
+ # Clean up any expiration notifications for all profiles of this account
+ try:
+ from core.models import SystemNotification
+ from core.utils import send_notification_dismissed
+
+ profile_ids = list(
+ M3UAccountProfile.objects.filter(m3u_account=instance)
+ .values_list("id", flat=True)
+ )
+ keys = [
+ key
+ for pid in profile_ids
+ for key in [f"xc-exp-warning-{pid}", f"xc-exp-expired-{pid}"]
+ ]
+ if keys:
+ deleted_keys = list(
+ SystemNotification.objects.filter(notification_key__in=keys)
+ .values_list("notification_key", flat=True)
+ )
+ if deleted_keys:
+ SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
+ for key in deleted_keys:
+ send_notification_dismissed(key)
+ logger.debug(
+ f"Cleaned up {len(deleted_keys)} notifications for deactivated M3U account {instance.id}"
+ )
+ except Exception as notify_err:
+ logger.error(f"Error cleaning up notifications on account deactivation: {notify_err}")
except M3UAccount.DoesNotExist:
# New record, will use default status
pass
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index 5259cefc..9c31fa75 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -11,7 +11,7 @@ from celery.result import AsyncResult
from celery import shared_task, current_app, group
from django.conf import settings
from django.core.cache import cache
-from django.db import transaction
+from django.db import models, transaction
from .models import M3UAccount
from apps.channels.models import Stream, ChannelGroup, ChannelGroupM3UAccount
from asgiref.sync import async_to_sync
@@ -3185,3 +3185,163 @@ def send_m3u_update(account_id, action, progress, **kwargs):
# Explicitly clear data reference to help garbage collection
data = None
+
+
+def evaluate_profile_expiration_notification(profile):
+ """
+ Evaluate a single M3UAccountProfile's expiration date and create, update,
+ or delete the corresponding SystemNotification accordingly.
+
+ Returns the notification key that should remain active (warning or expired),
+ or None if the profile is not expiring soon and any stale notifications were removed.
+ This return value is used by the bulk task to track active keys for stale cleanup.
+ """
+ from core.models import SystemNotification
+ from core.utils import send_websocket_notification, send_notification_dismissed
+
+ exp = profile.exp_date
+ if not exp:
+ return None
+
+ now = timezone.now()
+ warning_threshold = now + timezone.timedelta(days=7)
+ warning_key = f"xc-exp-warning-{profile.id}"
+ expired_key = f"xc-exp-expired-{profile.id}"
+
+ if exp <= now:
+ # Already expired — delete warning, create/update expired notification
+ deleted_warning = list(
+ SystemNotification.objects.filter(notification_key=warning_key)
+ .values_list("notification_key", flat=True)
+ )
+ SystemNotification.objects.filter(notification_key=warning_key).delete()
+ for key in deleted_warning:
+ send_notification_dismissed(key)
+
+ notification, created = SystemNotification.objects.update_or_create(
+ notification_key=expired_key,
+ defaults={
+ "notification_type": SystemNotification.NotificationType.WARNING,
+ "priority": SystemNotification.Priority.HIGH,
+ "title": f"Account Expired: {profile.name}",
+ "message": (
+ f'Profile "{profile.name}" on M3U account '
+ f'"{profile.m3u_account.name}" has expired '
+ f"(expired {exp.strftime('%Y-%m-%d %H:%M UTC')})."
+ ),
+ "action_data": {
+ "profile_id": profile.id,
+ "account_id": profile.m3u_account.id,
+ "account_name": profile.m3u_account.name,
+ "profile_name": profile.name,
+ "exp_date": exp.isoformat(),
+ },
+ "is_active": True,
+ "admin_only": True,
+ },
+ )
+ send_websocket_notification(notification)
+ return expired_key
+
+ elif exp <= warning_threshold:
+ # Expiring within 7 days — delete expired notification, create/update warning
+ deleted_expired = list(
+ SystemNotification.objects.filter(notification_key=expired_key)
+ .values_list("notification_key", flat=True)
+ )
+ SystemNotification.objects.filter(notification_key=expired_key).delete()
+ for key in deleted_expired:
+ send_notification_dismissed(key)
+
+ days_left = (exp - now).days
+ if days_left == 0:
+ expires_in_str = "today"
+ elif days_left == 1:
+ expires_in_str = "in 1 day"
+ else:
+ expires_in_str = f"in {days_left} days"
+
+ notification, created = SystemNotification.objects.update_or_create(
+ notification_key=warning_key,
+ defaults={
+ "notification_type": SystemNotification.NotificationType.WARNING,
+ "priority": SystemNotification.Priority.NORMAL,
+ "title": f"Account Expiring: {profile.name}",
+ "message": (
+ f'Profile "{profile.name}" on M3U account '
+ f'"{profile.m3u_account.name}" expires {expires_in_str} '
+ f"(expires {exp.strftime('%Y-%m-%d %H:%M UTC')})."
+ ),
+ "action_data": {
+ "profile_id": profile.id,
+ "account_id": profile.m3u_account.id,
+ "account_name": profile.m3u_account.name,
+ "profile_name": profile.name,
+ "exp_date": exp.isoformat(),
+ },
+ "is_active": True,
+ "admin_only": True,
+ },
+ )
+ send_websocket_notification(notification)
+ return warning_key
+
+ else:
+ # Not expiring soon — delete any stale notifications
+ deleted_keys = list(
+ SystemNotification.objects.filter(
+ notification_key__in=[warning_key, expired_key]
+ ).values_list("notification_key", flat=True)
+ )
+ SystemNotification.objects.filter(
+ notification_key__in=[warning_key, expired_key]
+ ).delete()
+ for key in deleted_keys:
+ send_notification_dismissed(key)
+ return None
+
+
+@shared_task
+def check_account_expirations():
+ """
+ Daily task: check all account profiles for upcoming expirations.
+ Creates/updates SystemNotifications for profiles expiring within 7 days.
+ Uses separate notification keys for warning vs expired so users can
+ dismiss the 7-day warning and still receive the expired notification.
+ """
+ from apps.m3u.models import M3UAccountProfile
+ from core.models import SystemNotification
+ from core.utils import send_notification_dismissed
+
+ # Find all active profiles with an exp_date that is set
+ expiring_profiles = (
+ M3UAccountProfile.objects.filter(
+ m3u_account__is_active=True,
+ is_active=True,
+ exp_date__isnull=False,
+ )
+ .select_related("m3u_account")
+ )
+
+ active_notification_keys = set()
+
+ for profile in expiring_profiles:
+ active_key = evaluate_profile_expiration_notification(profile)
+ if active_key:
+ active_notification_keys.add(active_key)
+
+ # Delete stale notifications for profiles whose expiration was extended
+ stale = SystemNotification.objects.filter(
+ is_active=True,
+ ).filter(
+ models.Q(notification_key__startswith="xc-exp-warning-") |
+ models.Q(notification_key__startswith="xc-exp-expired-")
+ ).exclude(notification_key__in=active_notification_keys)
+ stale_keys = list(stale.values_list("notification_key", flat=True))
+ stale.delete()
+ for key in stale_keys:
+ send_notification_dismissed(key)
+
+ logger.info(
+ f"Account expiration check complete: {len(active_notification_keys)} active notifications"
+ )
diff --git a/apps/m3u/tests/test_expiration_notifications.py b/apps/m3u/tests/test_expiration_notifications.py
new file mode 100644
index 00000000..c734d3d2
--- /dev/null
+++ b/apps/m3u/tests/test_expiration_notifications.py
@@ -0,0 +1,216 @@
+"""
+Tests for evaluate_profile_expiration_notification.
+
+Covers all four branches:
+ - no exp_date → returns None, touches nothing
+ - already expired → creates/updates expired notification, removes warning
+ - expiring within 7d → creates/updates warning notification, removes expired
+ - not expiring soon → removes any stale notifications, returns None
+"""
+from datetime import timedelta
+from unittest.mock import patch, MagicMock
+
+from django.test import SimpleTestCase
+from django.utils import timezone
+
+
+def _make_profile(exp_date, profile_id=1, profile_name="Test Profile",
+ account_id=10, account_name="Test Account"):
+ """Return a minimal mock M3UAccountProfile."""
+ profile = MagicMock()
+ profile.id = profile_id
+ profile.name = profile_name
+ profile.exp_date = exp_date
+ profile.m3u_account.id = account_id
+ profile.m3u_account.name = account_name
+ return profile
+
+
+class EvaluateProfileExpirationNotificationTests(SimpleTestCase):
+
+ def setUp(self):
+ # These three names are local imports inside evaluate_profile_expiration_notification,
+ # so we must patch them at their source modules rather than on apps.m3u.tasks.
+ self.mock_sn = MagicMock()
+ self.mock_send_ws = patch("core.utils.send_websocket_notification").start()
+ self.mock_dismissed = patch("core.utils.send_notification_dismissed").start()
+ patch("core.models.SystemNotification", self.mock_sn).start()
+
+ def tearDown(self):
+ patch.stopall()
+
+ def _run(self, profile):
+ from apps.m3u.tasks import evaluate_profile_expiration_notification
+ return evaluate_profile_expiration_notification(profile)
+
+ # ------------------------------------------------------------------ #
+ # No expiration date
+ # ------------------------------------------------------------------ #
+
+ def test_no_exp_date_returns_none(self):
+ profile = _make_profile(exp_date=None)
+ result = self._run(profile)
+ self.assertIsNone(result)
+ self.mock_sn.objects.update_or_create.assert_not_called()
+ self.mock_send_ws.assert_not_called()
+
+ # ------------------------------------------------------------------ #
+ # Already expired
+ # ------------------------------------------------------------------ #
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_expired_creates_expired_notification(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ profile = _make_profile(exp_date=now - timedelta(days=1))
+ # No existing warning notification to delete
+ self.mock_sn.objects.filter.return_value.values_list.return_value = []
+ notification = MagicMock()
+ self.mock_sn.objects.update_or_create.return_value = (notification, True)
+
+ result = self._run(profile)
+
+ self.assertEqual(result, f"xc-exp-expired-{profile.id}")
+ self.mock_sn.objects.update_or_create.assert_called_once()
+ call_kwargs = self.mock_sn.objects.update_or_create.call_args
+ self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-expired-{profile.id}")
+ self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
+ self.mock_send_ws.assert_called_once_with(notification)
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_expired_removes_stale_warning_notification(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ profile = _make_profile(exp_date=now - timedelta(hours=1))
+ warning_key = f"xc-exp-warning-{profile.id}"
+ # Simulate an existing warning notification
+ self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
+ self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
+
+ self._run(profile)
+
+ self.mock_dismissed.assert_any_call(warning_key)
+
+ # ------------------------------------------------------------------ #
+ # Expiring within 7 days
+ # ------------------------------------------------------------------ #
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_warning_window_creates_warning_notification(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ profile = _make_profile(exp_date=now + timedelta(days=3))
+ self.mock_sn.objects.filter.return_value.values_list.return_value = []
+ notification = MagicMock()
+ self.mock_sn.objects.update_or_create.return_value = (notification, True)
+
+ result = self._run(profile)
+
+ self.assertEqual(result, f"xc-exp-warning-{profile.id}")
+ call_kwargs = self.mock_sn.objects.update_or_create.call_args
+ self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-warning-{profile.id}")
+ self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
+ self.mock_send_ws.assert_called_once_with(notification)
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_warning_message_says_today_when_same_day(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ profile = _make_profile(exp_date=now + timedelta(hours=2))
+ self.mock_sn.objects.filter.return_value.values_list.return_value = []
+ self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
+
+ self._run(profile)
+
+ defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
+ self.assertIn("today", defaults["message"])
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_warning_message_says_1_day(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ profile = _make_profile(exp_date=now + timedelta(hours=30))
+ self.mock_sn.objects.filter.return_value.values_list.return_value = []
+ self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
+
+ self._run(profile)
+
+ defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
+ self.assertIn("in 1 day", defaults["message"])
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_warning_removes_stale_expired_notification(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ profile = _make_profile(exp_date=now + timedelta(days=5))
+ expired_key = f"xc-exp-expired-{profile.id}"
+ self.mock_sn.objects.filter.return_value.values_list.return_value = [expired_key]
+ self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
+
+ self._run(profile)
+
+ self.mock_dismissed.assert_any_call(expired_key)
+
+ # ------------------------------------------------------------------ #
+ # Not expiring soon (> 7 days away)
+ # ------------------------------------------------------------------ #
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_not_expiring_soon_returns_none(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ profile = _make_profile(exp_date=now + timedelta(days=30))
+ self.mock_sn.objects.filter.return_value.values_list.return_value = []
+
+ result = self._run(profile)
+
+ self.assertIsNone(result)
+ self.mock_sn.objects.update_or_create.assert_not_called()
+ self.mock_send_ws.assert_not_called()
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_not_expiring_soon_removes_stale_notifications(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ profile = _make_profile(exp_date=now + timedelta(days=30))
+ warning_key = f"xc-exp-warning-{profile.id}"
+ self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
+
+ self._run(profile)
+
+ self.mock_dismissed.assert_called_once_with(warning_key)
+
+ # ------------------------------------------------------------------ #
+ # Boundary: exactly at the 7-day warning threshold
+ # ------------------------------------------------------------------ #
+
+ @patch("apps.m3u.tasks.timezone")
+ def test_exactly_7_days_away_triggers_warning(self, mock_tz):
+ now = timezone.now()
+ mock_tz.now.return_value = now
+ mock_tz.timedelta = timedelta
+
+ # exp_date == now + 7 days → exp <= warning_threshold → warning
+ profile = _make_profile(exp_date=now + timedelta(days=7))
+ self.mock_sn.objects.filter.return_value.values_list.return_value = []
+ self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
+
+ result = self._run(profile)
+
+ self.assertEqual(result, f"xc-exp-warning-{profile.id}")
diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py
index 50bac5d5..b0d387b2 100644
--- a/dispatcharr/settings.py
+++ b/dispatcharr/settings.py
@@ -267,6 +267,11 @@ CELERY_BEAT_SCHEDULE = {
"task": "core.tasks.check_for_version_update",
"schedule": 86400.0, # Once every 24 hours
},
+ # Check for account expirations daily
+ "check-account-expirations": {
+ "task": "apps.m3u.tasks.check_account_expirations",
+ "schedule": 86400.0, # Once every 24 hours
+ },
}
MEDIA_ROOT = BASE_DIR / "media"
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 4fa3d46c..b1c103d8 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -1286,6 +1286,7 @@ export default class API {
body = new FormData();
for (const prop in values) {
+ if (values[prop] === null || values[prop] === undefined) continue;
body.append(prop, values[prop]);
}
} else {
diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx
index 4b3522af..c8caeaef 100644
--- a/frontend/src/components/forms/M3U.jsx
+++ b/frontend/src/components/forms/M3U.jsx
@@ -29,6 +29,7 @@ import useEPGsStore from '../../store/epgs';
import useVODStore from '../../store/useVODStore';
import M3UFilters from './M3UFilters';
import ScheduleInput from './ScheduleInput';
+import { DateTimePicker } from '@mantine/dates';
const M3U = ({
m3uAccount = null,
@@ -45,6 +46,7 @@ const M3U = ({
const [playlist, setPlaylist] = useState(null);
const [file, setFile] = useState(null);
+ const [expDate, setExpDate] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
const [filterModalOpen, setFilterModalOpen] = useState(false);
@@ -102,6 +104,7 @@ const M3U = ({
: 0,
enable_vod: m3uAccount.enable_vod || false,
});
+ setExpDate(m3uAccount.exp_date ? new Date(m3uAccount.exp_date) : null);
// Determine schedule type from existing data
setScheduleType(
@@ -119,6 +122,7 @@ const M3U = ({
setPlaylist(null);
form.reset();
setScheduleType('interval');
+ setExpDate(null);
}
}, [m3uAccount]);
@@ -131,6 +135,16 @@ const M3U = ({
const onSubmit = async () => {
const { create_epg, ...values } = form.getValues();
+ // Convert exp_date (from controlled state) to ISO string for the API
+ if (values.account_type === 'XC') {
+ // XC accounts have exp_date auto-managed server-side; don't send it
+ delete values.exp_date;
+ } else if (expDate instanceof Date) {
+ values.exp_date = expDate.toISOString();
+ } else {
+ values.exp_date = null;
+ }
+
// Determine which schedule type is active based on field values
const hasCronExpression =
values.cron_expression && values.cron_expression.trim() !== '';
@@ -359,13 +373,25 @@ const M3U = ({
)}
{form.getValues().account_type != 'XC' && (
-
+ <>
+
+
+ setExpDate(v ? new Date(v) : null)}
+ />
+ >
)}
diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx
index 025d3cae..7c24b212 100644
--- a/frontend/src/components/forms/M3UProfile.jsx
+++ b/frontend/src/components/forms/M3UProfile.jsx
@@ -16,6 +16,7 @@ import {
Textarea,
NumberInput,
} from '@mantine/core';
+import { DateTimePicker } from '@mantine/dates';
import { useWebSocket } from '../../WebSocket';
import usePlaylistsStore from '../../store/playlists';
@@ -32,6 +33,8 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [sampleInput, setSampleInput] = useState('');
const isDefaultProfile = profile?.is_default;
+ const isXC = m3u?.account_type === 'XC';
+
const defaultValues = useMemo(
() => ({
name: profile?.name || '',
@@ -39,6 +42,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
search_pattern: profile?.search_pattern || '',
replace_pattern: profile?.replace_pattern || '',
notes: profile?.custom_properties?.notes || '',
+ exp_date: profile?.exp_date ? new Date(profile.exp_date) : null,
}),
[profile]
);
@@ -73,6 +77,17 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const onSubmit = async (values) => {
console.log('submiting');
+ // Convert exp_date for submission
+ let expDateValue = values.exp_date;
+ if (isXC) {
+ // XC accounts have exp_date auto-managed; don't send it
+ expDateValue = undefined;
+ } else if (expDateValue instanceof Date) {
+ expDateValue = expDateValue.toISOString();
+ } else if (!expDateValue) {
+ expDateValue = null;
+ }
+
// For default profiles, only send name and custom_properties (notes)
let submitValues;
if (isDefaultProfile) {
@@ -99,6 +114,11 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
};
}
+ // Add exp_date for non-XC accounts
+ if (expDateValue !== undefined) {
+ submitValues.exp_date = expDateValue;
+ }
+
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
@@ -257,6 +277,18 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
>
)}
+ {!isXC && (
+ setValue('exp_date', value)}
+ />
+ )}
+