From a84553d15c80405925b511940d2cc43febbb561a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 9 Jan 2026 13:53:01 -0600 Subject: [PATCH] Enhancement: Stale status indicators for streams and groups: Added `is_stale` field to both Stream and ChannelGroupM3UAccount models to track items in their grace period (seen in previous refresh but not current). --- CHANGELOG.md | 1 + ...hannelgroupm3uaccount_is_stale_and_more.py | 29 +++++++++++++++++ apps/channels/models.py | 10 ++++++ apps/channels/serializers.py | 3 +- apps/m3u/tasks.py | 32 +++++++++++++++++-- 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a45fdf..b797e0a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Group retention policy for M3U accounts: Groups now follow the same stale retention logic as streams, using the account's `stale_stream_days` setting. Groups that temporarily disappear from an M3U source are retained for the configured retention period instead of being immediately deleted, preserving user settings and preventing data loss when providers temporarily remove/re-add groups. (Closes #809) +- Stale status indicators for streams and groups: Added `is_stale` field to both Stream and ChannelGroupM3UAccount models to track items in their grace period (seen in previous refresh but not current). ### Changed diff --git a/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py b/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py new file mode 100644 index 00000000..2428a97b --- /dev/null +++ b/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.9 on 2026-01-09 18:19 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0030_alter_stream_url'), + ] + + operations = [ + migrations.AddField( + model_name='channelgroupm3uaccount', + name='is_stale', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)'), + ), + migrations.AddField( + model_name='channelgroupm3uaccount', + name='last_seen', + field=models.DateTimeField(db_index=True, default=datetime.datetime.now, help_text='Last time this group was seen in the M3U source during a refresh'), + ), + migrations.AddField( + model_name='stream', + name='is_stale', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream is stale (not seen in recent refresh, pending deletion)'), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index aff4a2fb..6d199520 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -94,6 +94,11 @@ class Stream(models.Model): db_index=True, ) last_seen = models.DateTimeField(db_index=True, default=datetime.now) + is_stale = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)" + ) custom_properties = models.JSONField(default=dict, blank=True, null=True) # Stream statistics fields @@ -594,6 +599,11 @@ class ChannelGroupM3UAccount(models.Model): db_index=True, help_text='Last time this group was seen in the M3U source during a refresh' ) + is_stale = models.BooleanField( + default=False, + db_index=True, + help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)' + ) class Meta: unique_together = ("channel_group", "m3u_account") diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 8847050d..c1919e24 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -119,6 +119,7 @@ class StreamSerializer(serializers.ModelSerializer): "current_viewers", "updated_at", "last_seen", + "is_stale", "stream_profile_id", "is_custom", "channel_group", @@ -155,7 +156,7 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): class Meta: model = ChannelGroupM3UAccount - fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"] + fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties", "is_stale", "last_seen"] def to_representation(self, instance): data = super().to_representation(instance) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 71a8ba60..ed9eb465 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -594,11 +594,13 @@ def process_groups(account, groups, scan_start_time=None): existing_rel.custom_properties = updated_custom_props existing_rel.last_seen = scan_start_time + existing_rel.is_stale = False relations_to_update.append(existing_rel) logger.debug(f"Updated xc_id for group '{group.name}' from '{existing_xc_id}' to '{new_xc_id}' - account {account.id}") else: # Update last_seen even if xc_id hasn't changed existing_rel.last_seen = scan_start_time + existing_rel.is_stale = False relations_to_update.append(existing_rel) logger.debug(f"xc_id unchanged for group '{group.name}' - account {account.id}") else: @@ -614,6 +616,7 @@ def process_groups(account, groups, scan_start_time=None): custom_properties=custom_props, enabled=auto_enable_new_groups_live, last_seen=scan_start_time, + is_stale=False, ) ) @@ -624,7 +627,7 @@ def process_groups(account, groups, scan_start_time=None): # Bulk update existing relationships if relations_to_update: - ChannelGroupM3UAccount.objects.bulk_update(relations_to_update, ['custom_properties', 'last_seen']) + ChannelGroupM3UAccount.objects.bulk_update(relations_to_update, ['custom_properties', 'last_seen', 'is_stale']) logger.info(f"Updated {len(relations_to_update)} existing group relationships for account {account.id}") @@ -831,6 +834,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(group_id), "stream_hash": stream_hash, "custom_properties": stream, + "is_stale": False, } if stream_hash not in stream_hashes: @@ -866,10 +870,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): setattr(obj, key, value) obj.last_seen = timezone.now() obj.updated_at = timezone.now() # Update timestamp only for changed streams + obj.is_stale = False streams_to_update.append(obj) else: # Always update last_seen, even if nothing else changed obj.last_seen = timezone.now() + obj.is_stale = False # Don't update updated_at for unchanged streams streams_to_update.append(obj) @@ -880,6 +886,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): stream_props["updated_at"] = ( timezone.now() ) # Set initial updated_at for new streams + stream_props["is_stale"] = False streams_to_create.append(Stream(**stream_props)) try: @@ -891,7 +898,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): # Simplified bulk update for better performance Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'], batch_size=150 # Smaller batch size for XC processing ) @@ -1004,6 +1011,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(groups.get(group_title)), "stream_hash": stream_hash, "custom_properties": stream_info["attributes"], + "is_stale": False, } if stream_hash not in stream_hashes: @@ -1043,11 +1051,15 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.custom_properties = stream_props["custom_properties"] obj.updated_at = timezone.now() + # Always mark as not stale since we saw it in this refresh + obj.is_stale = False + streams_to_update.append(obj) else: # New stream stream_props["last_seen"] = timezone.now() stream_props["updated_at"] = timezone.now() + stream_props["is_stale"] = False streams_to_create.append(Stream(**stream_props)) try: @@ -1059,7 +1071,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): # Update all streams in a single bulk operation Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'], batch_size=200 ) except Exception as e: @@ -2842,6 +2854,20 @@ def refresh_single_m3u_account(account_id): id=-1 ).exists() # This will never find anything but ensures DB sync + # Mark streams that weren't seen in this refresh as stale (pending deletion) + stale_stream_count = Stream.objects.filter( + m3u_account=account, + last_seen__lt=refresh_start_timestamp + ).update(is_stale=True) + logger.info(f"Marked {stale_stream_count} streams as stale for account {account_id}") + + # Mark group relationships that weren't seen in this refresh as stale (pending deletion) + stale_group_count = ChannelGroupM3UAccount.objects.filter( + m3u_account=account, + last_seen__lt=refresh_start_timestamp + ).update(is_stale=True) + logger.info(f"Marked {stale_group_count} group relationships as stale for account {account_id}") + # Now run cleanup streams_deleted = cleanup_streams(account_id, refresh_start_timestamp)