From 4796184a67e0eb32e5448c221346956c8b5ba884 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 1 Feb 2026 13:42:48 -0700 Subject: [PATCH 01/15] Fix uWSGI segfaults by disabling threads when using gevent Mixing threading and gevent concurrency models causes uWSGI workers to crash with segmentation faults, particularly on ARM64/Python 3.13. The production uwsgi.ini was already correct (gevent without threads). This fixes the dev and debug configs to match. References: - https://github.com/gevent/gevent/issues/1784 - https://github.com/unbit/uwsgi/issues/2457 Co-Authored-By: Claude Opus 4.5 --- docker/uwsgi.debug.ini | 2 -- docker/uwsgi.dev.ini | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/docker/uwsgi.debug.ini b/docker/uwsgi.debug.ini index 69c040f2..d2847335 100644 --- a/docker/uwsgi.debug.ini +++ b/docker/uwsgi.debug.ini @@ -28,8 +28,6 @@ static-map = /static=/app/static # Worker configuration workers = 1 -threads = 8 -enable-threads = true lazy-apps = true # HTTP server diff --git a/docker/uwsgi.dev.ini b/docker/uwsgi.dev.ini index e476e216..c4c5d0aa 100644 --- a/docker/uwsgi.dev.ini +++ b/docker/uwsgi.dev.ini @@ -28,10 +28,8 @@ vacuum = true die-on-term = true static-map = /static=/app/static -# Worker management (Optimize for I/O bound tasks) +# Worker management workers = 4 -threads = 2 -enable-threads = true # Optimize for streaming http = 0.0.0.0:5656 From b22f9e8e07a68047821214fd2b666770e29dab65 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 14 Mar 2026 18:38:59 -0500 Subject: [PATCH 02/15] Enhancement: Account expiration tracking and notifications for M3U profiles --- CHANGELOG.md | 6 + .../0019_m3uaccountprofile_exp_date.py | 57 +++++++ apps/m3u/models.py | 79 +++++---- apps/m3u/serializers.py | 66 +++++++- apps/m3u/signals.py | 86 +++++++++- apps/m3u/tasks.py | 159 +++++++++++++++++- dispatcharr/settings.py | 5 + frontend/src/api.js | 1 + frontend/src/components/forms/M3U.jsx | 39 ++++- frontend/src/components/forms/M3UProfile.jsx | 32 ++++ frontend/src/components/tables/M3UsTable.jsx | 88 +++++++++- frontend/src/store/notifications.jsx | 13 +- 12 files changed, 578 insertions(+), 53 deletions(-) create mode 100644 apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py 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/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..c629cac9 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -52,12 +52,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 +92,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 +119,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 +180,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 +211,19 @@ 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 + default_profile = instance.profiles.filter(is_default=True).first() + data["exp_date"] = ( + default_profile.exp_date.isoformat() if default_profile and default_profile.exp_date else 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 +285,19 @@ class M3UAccountSerializer(serializers.ModelSerializer): memberships_to_update, ["enabled"] ) + # Write exp_date through to the default profile + 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() + 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 +321,41 @@ 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.""" + profiles_with_exp = obj.profiles.filter( + is_active=True, exp_date__isnull=False + ).order_by("exp_date") + first = profiles_with_exp.first() + return first.exp_date.isoformat() if first else None + + def get_all_expirations(self, obj): + """Return exp_date info for every profile that has one (for tooltip).""" + profiles = obj.profiles.filter(exp_date__isnull=False).order_by("exp_date") + return [ + { + "profile_id": p.id, + "profile_name": p.name, + "exp_date": p.exp_date.isoformat(), + "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..0af5d382 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,160 @@ 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 + + now = timezone.now() + warning_threshold = now + timezone.timedelta(days=7) + exp = profile.exp_date + 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": False, + }, + ) + 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": False, + }, + ) + 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_xc_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/dispatcharr/settings.py b/dispatcharr/settings.py index 50bac5d5..46e03d1f 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 XC account expirations daily + "check-xc-account-expirations": { + "task": "apps.m3u.tasks.check_xc_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..423b2a05 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, @@ -69,6 +70,7 @@ const M3U = ({ stale_stream_days: 7, priority: 0, enable_vod: false, + exp_date: null, }, validate: { @@ -101,6 +103,7 @@ const M3U = ({ ? m3uAccount.priority : 0, enable_vod: m3uAccount.enable_vod || false, + exp_date: m3uAccount.exp_date ? new Date(m3uAccount.exp_date) : null, }); // Determine schedule type from existing data @@ -131,6 +134,16 @@ const M3U = ({ const onSubmit = async () => { const { create_epg, ...values } = form.getValues(); + // Convert exp_date Date object 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 (values.exp_date instanceof Date) { + values.exp_date = values.exp_date.toISOString(); + } else if (!values.exp_date) { + 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 +372,25 @@ const M3U = ({ )} {form.getValues().account_type != 'XC' && ( - + <> + + + + )} 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)} + /> + )} +