Enhancement: - Mature content filtering support:

- Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting
  - Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data
  - Type-safe conversion supporting both integer (0/1) and string ("0"/"1") formats from different providers
  - UI controls in channel edit form (Switch with tooltip) and bulk edit form (Select dropdown) for easy management
  - XtreamCodes API support with proper integer formatting (0/1) in live stream responses
  - Automatic propagation from streams to channels during both single and bulk channel creation operations
  - Included in serializers for full API support
  - User-level content filtering: Non-admin users can opt to hide mature content channels across all interfaces (web UI, M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in user settings (stored in custom_properties, admin users always see all content)
This commit is contained in:
SergeantPanda 2026-01-17 15:00:28 -06:00
parent 19d25f37c6
commit d33d047a94
11 changed files with 163 additions and 14 deletions

View file

@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Mature content filtering support:
- Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting
- Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data
- Type-safe conversion supporting both integer (0/1) and string ("0"/"1") formats from different providers
- UI controls in channel edit form (Switch with tooltip) and bulk edit form (Select dropdown) for easy management
- XtreamCodes API support with proper integer formatting (0/1) in live stream responses
- Automatic propagation from streams to channels during both single and bulk channel creation operations
- Included in serializers for full API support
- User-level content filtering: Non-admin users can opt to hide mature content channels across all interfaces (web UI, M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in user settings (stored in custom_properties, admin users always see all content)
- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663)
- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647)

View file

@ -585,6 +585,10 @@ class ChannelViewSet(viewsets.ModelViewSet):
if self.request.user.user_level < 10:
filters["user_level__lte"] = self.request.user.user_level
# Hide adult content if user preference is set
custom_props = self.request.user.custom_properties or {}
if custom_props.get('hide_adult_content', False):
filters["is_adult"] = False
if filters:
qs = qs.filter(**filters)
@ -947,6 +951,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
"tvg_id": stream.tvg_id,
"tvc_guide_stationid": tvc_guide_stationid,
"streams": [stream_id],
"is_adult": stream.is_adult,
}
# Only add channel_group_id if the stream has a channel group

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.9 on 2026-01-17 16:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0031_channelgroupm3uaccount_is_stale_and_more'),
]
operations = [
migrations.AddField(
model_name='channel',
name='is_adult',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this channel contains adult content'),
),
migrations.AddField(
model_name='stream',
name='is_adult',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream contains adult content'),
),
]

View file

@ -99,6 +99,11 @@ class Stream(models.Model):
db_index=True,
help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)"
)
is_adult = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this stream contains adult content"
)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
# Stream statistics fields
@ -301,6 +306,12 @@ class Channel(models.Model):
user_level = models.IntegerField(default=0)
is_adult = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this channel contains adult content"
)
auto_created = models.BooleanField(
default=False,
help_text="Whether this channel was automatically created via M3U auto channel sync"

View file

@ -120,6 +120,7 @@ class StreamSerializer(serializers.ModelSerializer):
"updated_at",
"last_seen",
"is_stale",
"is_adult",
"stream_profile_id",
"is_custom",
"channel_group",
@ -293,6 +294,7 @@ class ChannelSerializer(serializers.ModelSerializer):
"uuid",
"logo_id",
"user_level",
"is_adult",
"auto_created",
"auto_created_by",
"auto_created_by_name",

View file

@ -2585,6 +2585,7 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None
"name": name,
"tvc_guide_stationid": tvc_guide_stationid,
"tvg_id": stream.tvg_id,
"is_adult": stream.is_adult,
}
# Only add channel_group_id if the stream has a channel group

View file

@ -834,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_adult": int(stream.get("is_adult", 0)) == 1,
"is_stale": False,
}
@ -862,7 +863,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
obj.url != stream_props["url"] or
obj.logo_url != stream_props["logo_url"] or
obj.tvg_id != stream_props["tvg_id"] or
obj.custom_properties != stream_props["custom_properties"]
obj.custom_properties != stream_props["custom_properties"] or
obj.is_adult != stream_props["is_adult"]
)
if changed:
@ -898,7 +900,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', 'is_stale'],
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
batch_size=150 # Smaller batch size for XC processing
)
@ -1011,6 +1013,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_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1,
"is_stale": False,
}
@ -1036,7 +1039,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.url != stream_props["url"] or
obj.logo_url != stream_props["logo_url"] or
obj.tvg_id != stream_props["tvg_id"] or
obj.custom_properties != stream_props["custom_properties"]
obj.custom_properties != stream_props["custom_properties"] or
obj.is_adult != stream_props["is_adult"]
)
# Always update last_seen
@ -1049,6 +1053,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.logo_url = stream_props["logo_url"]
obj.tvg_id = stream_props["tvg_id"]
obj.custom_properties = stream_props["custom_properties"]
obj.is_adult = stream_props["is_adult"]
obj.updated_at = timezone.now()
# Always mark as not stale since we saw it in this refresh
@ -1071,7 +1076,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', 'is_stale'],
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
batch_size=200
)
except Exception as e:

View file

@ -134,7 +134,11 @@ def generate_m3u(request, profile_name=None, user=None):
# If user has ALL profiles or NO profiles, give unrestricted access
if user_profile_count == 0:
# No profile filtering - user sees all channels based on user_level
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number")
filters = {"user_level__lte": user.user_level}
# Hide adult content if user preference is set
if (user.custom_properties or {}).get('hide_adult_content', False):
filters["is_adult"] = False
channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
# User has specific limited profiles assigned
filters = {
@ -142,6 +146,9 @@ def generate_m3u(request, profile_name=None, user=None):
"user_level__lte": user.user_level,
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
}
# Hide adult content if user preference is set
if (user.custom_properties or {}).get('hide_adult_content', False):
filters["is_adult"] = False
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
else:
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
@ -1264,7 +1271,11 @@ def generate_epg(request, profile_name=None, user=None):
# If user has ALL profiles or NO profiles, give unrestricted access
if user_profile_count == 0:
# No profile filtering - user sees all channels based on user_level
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number")
filters = {"user_level__lte": user.user_level}
# Hide adult content if user preference is set
if (user.custom_properties or {}).get('hide_adult_content', False):
filters["is_adult"] = False
channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
# User has specific limited profiles assigned
filters = {
@ -1272,6 +1283,9 @@ def generate_epg(request, profile_name=None, user=None):
"user_level__lte": user.user_level,
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
}
# Hide adult content if user preference is set
if (user.custom_properties or {}).get('hide_adult_content', False):
filters["is_adult"] = False
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
else:
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
@ -2125,6 +2139,9 @@ def xc_get_live_streams(request, user, category_id=None):
filters = {"user_level__lte": user.user_level}
if category_id is not None:
filters["channel_group__id"] = category_id
# Hide adult content if user preference is set
if (user.custom_properties or {}).get('hide_adult_content', False):
filters["is_adult"] = False
channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
# User has specific limited profiles assigned
@ -2135,6 +2152,9 @@ def xc_get_live_streams(request, user, category_id=None):
}
if category_id is not None:
filters["channel_group__id"] = category_id
# Hide adult content if user preference is set
if (user.custom_properties or {}).get('hide_adult_content', False):
filters["is_adult"] = False
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
else:
if not category_id:
@ -2189,7 +2209,7 @@ def xc_get_live_streams(request, user, category_id=None):
),
"epg_channel_id": str(channel_num_int),
"added": int(channel.created_at.timestamp()),
"is_adult": 0,
"is_adult": int(channel.is_adult),
"category_id": str(channel.channel_group.id),
"category_ids": [channel.channel_group.id],
"custom_sid": None,
@ -2214,10 +2234,14 @@ def xc_get_epg(request, user, short=False):
# If user has ALL profiles or NO profiles, give unrestricted access
if user_profile_count == 0:
# No profile filtering - user sees all channels based on user_level
channel = Channel.objects.filter(
id=channel_id,
user_level__lte=user.user_level
).first()
filters = {
"id": channel_id,
"user_level__lte": user.user_level
}
# Hide adult content if user preference is set
if (user.custom_properties or {}).get('hide_adult_content', False):
filters["is_adult"] = False
channel = Channel.objects.filter(**filters).first()
else:
# User has specific limited profiles assigned
filters = {
@ -2226,6 +2250,9 @@ def xc_get_epg(request, user, short=False):
"user_level__lte": user.user_level,
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
}
# Hide adult content if user preference is set
if (user.custom_properties or {}).get('hide_adult_content', False):
filters["is_adult"] = False
channel = Channel.objects.filter(**filters).distinct().first()
if not channel:

View file

@ -18,12 +18,10 @@ import {
Button,
Modal,
TextInput,
NativeSelect,
Text,
Group,
ActionIcon,
Center,
Grid,
Flex,
Select,
Divider,
@ -33,8 +31,8 @@ import {
ScrollArea,
Tooltip,
NumberInput,
Image,
UnstyledButton,
Switch,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { ListOrdered, SquarePlus, SquareX, X, Zap } from 'lucide-react';
@ -318,6 +316,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
epg_data_id: channel?.epg_data_id ?? '',
logo_id: channel?.logo_id ? `${channel.logo_id}` : '',
user_level: `${channel?.user_level ?? '0'}`,
is_adult: channel?.is_adult ?? false,
}),
[channel, channelGroups]
);
@ -811,6 +810,18 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
>
Upload or Create Logo
</Button>
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
<Box>
<Switch
label="Mature Content"
checked={watch('is_adult')}
onChange={(event) =>
setValue('is_adult', event.currentTarget.checked)
}
size="md"
/>
</Box>
</Tooltip>
</Stack>
<Divider size="sm" orientation="vertical" />

View file

@ -102,6 +102,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
logo: '(no change)',
stream_profile_id: '-1',
user_level: '-1',
is_adult: '-1',
},
});
@ -153,6 +154,13 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
changes.push(`• User Level: ${userLevelLabel}`);
}
// Check mature content flag
if (values.is_adult && values.is_adult !== '-1') {
changes.push(
`• Mature Content: ${values.is_adult === 'true' ? 'Yes' : 'No'}`
);
}
// Check dummy EPG
if (selectedDummyEpgId) {
if (selectedDummyEpgId === 'clear') {
@ -223,6 +231,14 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
delete values.user_level;
}
if (values.is_adult === '-1') {
delete values.is_adult;
} else if (values.is_adult === 'true') {
values.is_adult = true;
} else if (values.is_adult === 'false') {
values.is_adult = false;
}
// Remove the channel_group field from form values as we use channel_group_id
delete values.channel_group;
@ -931,6 +947,18 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
})
)}
/>
<Select
size="xs"
label="Mature Content"
{...form.getInputProps('is_adult')}
key={form.key('is_adult')}
data={[
{ value: '-1', label: '(no change)' },
{ value: 'true', label: 'Yes' },
{ value: 'false', label: 'No' },
]}
/>
</Stack>
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">

View file

@ -12,6 +12,9 @@ import {
Stack,
MultiSelect,
ActionIcon,
Switch,
Box,
Tooltip,
} from '@mantine/core';
import { RotateCcwKey, X } from 'lucide-react';
import { useForm } from '@mantine/form';
@ -38,6 +41,7 @@ const User = ({ user = null, isOpen, onClose }) => {
password: '',
xc_password: '',
channel_profiles: [],
hide_adult_content: false,
},
validate: (values) => ({
@ -80,6 +84,10 @@ const User = ({ user = null, isOpen, onClose }) => {
customProps.xc_password = values.xc_password || '';
delete values.xc_password;
// Save hide_adult_content in custom_properties
customProps.hide_adult_content = values.hide_adult_content || false;
delete values.hide_adult_content;
values.custom_properties = customProps;
// If 'All' is included, clear this and we assume access to all channels
@ -125,6 +133,7 @@ const User = ({ user = null, isOpen, onClose }) => {
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
hide_adult_content: customProps.hide_adult_content || false,
});
if (customProps.xc_password) {
@ -242,6 +251,24 @@ const User = ({ user = null, isOpen, onClose }) => {
}))}
/>
)}
{showPermissions && (
<Box>
<Tooltip
label="Hide channels marked as mature content (admin users not affected)"
position="top"
withArrow
>
<Switch
label="Hide Mature Content"
{...form.getInputProps('hide_adult_content', {
type: 'checkbox',
})}
key={form.key('hide_adult_content')}
/>
</Tooltip>
</Box>
)}
</Stack>
</Group>