From edfa4972035e52048c6a291c08895284ccab3338 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 11 Jan 2026 17:31:15 -0600 Subject: [PATCH] Enhancement: Channel Profile membership control for manual channel creation and bulk operations: Extended the existing `channel_profile_ids` parameter from `POST /api/channels/from-stream/` to also support `POST /api/channels/` (manual creation) and bulk creation tasks with the same flexible semantics: - Omitted parameter (default): Channels are added to ALL profiles (preserves backward compatibility) - Empty array `[]`: Channels are added to NO profiles - Sentinel value `[0]`: Channels are added to ALL profiles (explicit) - Specific IDs `[1, 2, ...]`: Channels are added only to the specified profiles This allows API consumers to control profile membership across all channel creation methods without requiring all channels to be added to every profile by default. --- CHANGELOG.md | 6 ++++ apps/channels/api_views.py | 72 +++++++++++++++++++++++++++----------- apps/channels/tasks.py | 44 ++++++++++++++++------- 3 files changed, 90 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4758b146..cc21f194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Channel Profile membership control for manual channel creation and bulk operations: Extended the existing `channel_profile_ids` parameter from `POST /api/channels/from-stream/` to also support `POST /api/channels/` (manual creation) and bulk creation tasks with the same flexible semantics: + - Omitted parameter (default): Channels are added to ALL profiles (preserves backward compatibility) + - Empty array `[]`: Channels are added to NO profiles + - Sentinel value `[0]`: Channels are added to ALL profiles (explicit) + - Specific IDs `[1, 2, ...]`: Channels are added only to the specified profiles + This allows API consumers to control profile membership across all channel creation methods without requiring all channels to be added to every profile by default. - 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) - Visual stale indicators for streams and groups: Added `is_stale` field to Stream and both `is_stale` and `last_seen` fields to ChannelGroupM3UAccount models to track items in their retention grace period. Stale groups display with orange buttons and a warning tooltip, while stale streams show with a red background color matching the visual treatment of empty channels. diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 6d10b942..59f03baf 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -396,14 +396,37 @@ class ChannelViewSet(viewsets.ModelViewSet): channel = serializer.save() # Handle channel profile membership + # Semantics: + # - Omitted (None): add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only channel_profile_ids = request.data.get("channel_profile_ids") if channel_profile_ids is not None: # Normalize single ID to array if not isinstance(channel_profile_ids, list): channel_profile_ids = [channel_profile_ids] - if channel_profile_ids: - # Add channel only to the specified profiles + # Determine action based on semantics + if channel_profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + elif isinstance(channel_profile_ids, list) and len(channel_profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(channel_profile_ids, list) and 0 in channel_profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + else: + # Specific profile IDs try: channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids) if len(channel_profiles) != len(channel_profile_ids): @@ -426,13 +449,6 @@ class ChannelViewSet(viewsets.ModelViewSet): {"error": f"Error creating profile memberships: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, ) - else: - # Default behavior: add to all profiles - profiles = ChannelProfile.objects.all() - ChannelProfileMembership.objects.bulk_create([ - ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) - for profile in profiles - ]) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) @@ -791,7 +807,7 @@ class ChannelViewSet(viewsets.ModelViewSet): "channel_profile_ids": openapi.Schema( type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s) to add the channel to. Can be a single ID or array of IDs. If not provided, channel is added to all profiles." + description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles." ), }, ), @@ -884,14 +900,37 @@ class ChannelViewSet(viewsets.ModelViewSet): channel.streams.add(stream) # Handle channel profile membership + # Semantics: + # - Omitted (None): add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only channel_profile_ids = request.data.get("channel_profile_ids") if channel_profile_ids is not None: # Normalize single ID to array if not isinstance(channel_profile_ids, list): channel_profile_ids = [channel_profile_ids] - if channel_profile_ids: - # Add channel only to the specified profiles + # Determine action based on semantics + if channel_profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + elif isinstance(channel_profile_ids, list) and len(channel_profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(channel_profile_ids, list) and 0 in channel_profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + else: + # Specific profile IDs try: channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids) if len(channel_profiles) != len(channel_profile_ids): @@ -914,13 +953,6 @@ class ChannelViewSet(viewsets.ModelViewSet): {"error": f"Error creating profile memberships: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, ) - else: - # Default behavior: add to all profiles - profiles = ChannelProfile.objects.all() - ChannelProfileMembership.objects.bulk_create([ - ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) - for profile in profiles - ]) # Send WebSocket notification for single channel creation from core.utils import send_websocket_update @@ -953,7 +985,7 @@ class ChannelViewSet(viewsets.ModelViewSet): "channel_profile_ids": openapi.Schema( type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s) to add the channels to. If not provided, channels are added to all profiles." + description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles." ), "starting_channel_number": openapi.Schema( type=openapi.TYPE_INTEGER, diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 7ca73ac2..b3e11251 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -2679,7 +2679,38 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None ) # Handle channel profile membership - if profile_ids: + # Semantics: + # - None: add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0 in array: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only + if profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + all_profiles = ChannelProfile.objects.all() + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in all_profiles + ]) + elif isinstance(profile_ids, list) and len(profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(profile_ids, list) and 0 in profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + all_profiles = ChannelProfile.objects.all() + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in all_profiles + ]) + else: + # Specific profile IDs try: specific_profiles = ChannelProfile.objects.filter(id__in=profile_ids) channel_profile_memberships.extend([ @@ -2695,17 +2726,6 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None 'channel_id': channel.id, 'error': f'Failed to add to profiles: {str(e)}' }) - else: - # Add to all profiles by default - all_profiles = ChannelProfile.objects.all() - channel_profile_memberships.extend([ - ChannelProfileMembership( - channel_profile=profile, - channel=channel, - enabled=True - ) - for profile in all_profiles - ]) # Bulk update channels with logos if update: