diff --git a/CHANGELOG.md b/CHANGELOG.md
index c636e281..53da6118 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 8ea5db8a..8cbd70d1 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -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
diff --git a/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py b/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py
new file mode 100644
index 00000000..cbf6ba4f
--- /dev/null
+++ b/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py
@@ -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'),
+ ),
+ ]
diff --git a/apps/channels/models.py b/apps/channels/models.py
index 6d199520..703498a8 100644
--- a/apps/channels/models.py
+++ b/apps/channels/models.py
@@ -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"
diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py
index c1919e24..42853ca1 100644
--- a/apps/channels/serializers.py
+++ b/apps/channels/serializers.py
@@ -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",
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index b3e11251..f37838fd 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -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
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index ed9eb465..82e4f213 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -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:
diff --git a/apps/output/views.py b/apps/output/views.py
index 2cdd4dac..a34ff45f 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -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:
diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx
index 4f12dd2c..379825a0 100644
--- a/frontend/src/components/forms/Channel.jsx
+++ b/frontend/src/components/forms/Channel.jsx
@@ -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
+
+
+
+ setValue('is_adult', event.currentTarget.checked)
+ }
+ size="md"
+ />
+
+
diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx
index 14dd22f1..bdbb0b2d 100644
--- a/frontend/src/components/forms/ChannelBatch.jsx
+++ b/frontend/src/components/forms/ChannelBatch.jsx
@@ -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 }) => {
})
)}
/>
+
+
diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx
index 619b156f..29c93f30 100644
--- a/frontend/src/components/forms/User.jsx
+++ b/frontend/src/components/forms/User.jsx
@@ -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 && (
+
+
+
+
+
+ )}