diff --git a/apps/m3u/admin.py b/apps/m3u/admin.py
index d4d6885b..29022933 100644
--- a/apps/m3u/admin.py
+++ b/apps/m3u/admin.py
@@ -2,56 +2,73 @@ from django.contrib import admin
from django.utils.html import format_html
from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent
+
class M3UFilterInline(admin.TabularInline):
model = M3UFilter
extra = 1
verbose_name = "M3U Filter"
verbose_name_plural = "M3U Filters"
+
@admin.register(M3UAccount)
class M3UAccountAdmin(admin.ModelAdmin):
- list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'user_agent_display', 'uploaded_file_link', 'created_at', 'updated_at')
- list_filter = ('is_active', 'server_group')
- search_fields = ('name', 'server_url', 'server_group__name')
+ list_display = (
+ "name",
+ "server_url",
+ "server_group",
+ "max_streams",
+ "is_active",
+ "user_agent_display",
+ "uploaded_file_link",
+ "created_at",
+ "updated_at",
+ )
+ list_filter = ("is_active", "server_group")
+ search_fields = ("name", "server_url", "server_group__name")
inlines = [M3UFilterInline]
- actions = ['activate_accounts', 'deactivate_accounts']
+ actions = ["activate_accounts", "deactivate_accounts"]
# Handle both ForeignKey and ManyToManyField cases for UserAgent
def user_agent_display(self, obj):
- if hasattr(obj, 'user_agent'): # ForeignKey case
+ if hasattr(obj, "user_agent"): # ForeignKey case
return obj.user_agent.user_agent if obj.user_agent else "None"
- elif hasattr(obj, 'user_agents'): # ManyToManyField case
+ elif hasattr(obj, "user_agents"): # ManyToManyField case
return ", ".join([ua.user_agent for ua in obj.user_agents.all()]) or "None"
return "None"
+
user_agent_display.short_description = "User Agent(s)"
def uploaded_file_link(self, obj):
if obj.uploaded_file:
- return format_html("Download M3U", obj.uploaded_file.url)
+ return format_html(
+ "Download M3U", obj.uploaded_file.url
+ )
return "No file uploaded"
+
uploaded_file_link.short_description = "Uploaded File"
- @admin.action(description='Activate selected accounts')
+ @admin.action(description="Activate selected accounts")
def activate_accounts(self, request, queryset):
queryset.update(is_active=True)
- @admin.action(description='Deactivate selected accounts')
+ @admin.action(description="Deactivate selected accounts")
def deactivate_accounts(self, request, queryset):
queryset.update(is_active=False)
# Add ManyToManyField for Django Admin (if applicable)
- if hasattr(M3UAccount, 'user_agents'):
- filter_horizontal = ('user_agents',) # Only for ManyToManyField
+ if hasattr(M3UAccount, "user_agents"):
+ filter_horizontal = ("user_agents",) # Only for ManyToManyField
+
@admin.register(M3UFilter)
class M3UFilterAdmin(admin.ModelAdmin):
- list_display = ('m3u_account', 'filter_type', 'regex_pattern', 'exclude')
- list_filter = ('filter_type', 'exclude')
- search_fields = ('regex_pattern',)
- ordering = ('m3u_account',)
+ list_display = ("m3u_account", "filter_type", "regex_pattern", "exclude")
+ list_filter = ("filter_type", "exclude")
+ search_fields = ("regex_pattern",)
+ ordering = ("m3u_account",)
+
@admin.register(ServerGroup)
class ServerGroupAdmin(admin.ModelAdmin):
- list_display = ('name',)
- search_fields = ('name',)
-
+ list_display = ("name",)
+ search_fields = ("name",)
diff --git a/apps/m3u/migrations/0015_alter_m3ufilter_options_m3ufilter_custom_properties.py b/apps/m3u/migrations/0015_alter_m3ufilter_options_m3ufilter_custom_properties.py
new file mode 100644
index 00000000..6b62c9a1
--- /dev/null
+++ b/apps/m3u/migrations/0015_alter_m3ufilter_options_m3ufilter_custom_properties.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2.4 on 2025-08-02 16:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('m3u', '0014_alter_m3ufilter_options_m3ufilter_order'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='m3ufilter',
+ options={},
+ ),
+ migrations.AddField(
+ model_name='m3ufilter',
+ name='custom_properties',
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
diff --git a/apps/m3u/models.py b/apps/m3u/models.py
index b7993ef6..2a7846c6 100644
--- a/apps/m3u/models.py
+++ b/apps/m3u/models.py
@@ -180,6 +180,7 @@ class M3UFilter(models.Model):
help_text="If True, matching items are excluded; if False, only matches are included.",
)
order = models.PositiveIntegerField(default=0)
+ custom_properties = models.TextField(null=True, blank=True)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == "group" else stream_name
diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py
index 3bf0e335..c0824bb3 100644
--- a/apps/m3u/serializers.py
+++ b/apps/m3u/serializers.py
@@ -6,7 +6,6 @@ from core.models import UserAgent
from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
from apps.channels.serializers import (
ChannelGroupM3UAccountSerializer,
- ChannelGroupSerializer,
)
import logging
@@ -18,7 +17,14 @@ class M3UFilterSerializer(serializers.ModelSerializer):
class Meta:
model = M3UFilter
- fields = ["id", "filter_type", "regex_pattern", "exclude", "order"]
+ fields = [
+ "id",
+ "filter_type",
+ "regex_pattern",
+ "exclude",
+ "order",
+ "custom_properties",
+ ]
class M3UAccountProfileSerializer(serializers.ModelSerializer):
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index 588705a4..50b7309c 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -279,7 +279,17 @@ def process_groups(account, groups):
logger.info(f"Currently {len(existing_groups)} existing groups")
compiled_filters = [
- (re.compile(f.regex_pattern), f)
+ (
+ re.compile(
+ f.regex_pattern,
+ (
+ re.IGNORECASE
+ if json.loads(f.custom_properties or "{}").get("case_sensitive", True) == False
+ else 0
+ ),
+ ),
+ f,
+ )
for f in account.filters.order_by("order")
if f.filter_type == "group"
]
@@ -510,7 +520,17 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
account = M3UAccount.objects.get(id=account_id)
compiled_filters = [
- (re.compile(f.regex_pattern), f)
+ (
+ re.compile(
+ f.regex_pattern,
+ (
+ re.IGNORECASE
+ if json.loads(f.custom_properties or "{}").get("case_sensitive", True) == False
+ else 0
+ ),
+ ),
+ f,
+ )
for f in account.filters.order_by("order")
if f.filter_type != "group"
]
@@ -519,7 +539,6 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
streams_to_update = []
stream_hashes = {}
- # compiled_filters = [(f.filter_type, re.compile(f.regex_pattern, re.IGNORECASE)) for f in filters]
logger.debug(f"Processing batch of {len(batch)} for M3U account {account_id}")
for stream_info in batch:
try:
diff --git a/frontend/src/api.js b/frontend/src/api.js
index a6998bc2..d448f38e 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -857,7 +857,6 @@ export default class API {
body = { ...payload };
delete body.file;
}
- console.log(body);
const response = await request(`${host}/api/m3u/accounts/${id}/`, {
method: 'PATCH',
diff --git a/frontend/src/components/forms/M3UFilter.jsx b/frontend/src/components/forms/M3UFilter.jsx
index 9e25f13b..fdd7db8e 100644
--- a/frontend/src/components/forms/M3UFilter.jsx
+++ b/frontend/src/components/forms/M3UFilter.jsx
@@ -1,5 +1,5 @@
// Modal.js
-import React, { useState, useEffect } from 'react';
+import React, { useEffect } from 'react';
import API from '../../api';
import {
TextInput,
@@ -7,24 +7,15 @@ import {
Modal,
Flex,
Select,
- PasswordInput,
Group,
Stack,
- MultiSelect,
- ActionIcon,
Switch,
Box,
} from '@mantine/core';
-import { RotateCcwKey, X } from 'lucide-react';
import { useForm } from '@mantine/form';
-import useChannelsStore from '../../store/channels';
-import {
- M3U_FILTER_TYPES,
- USER_LEVELS,
- USER_LEVEL_LABELS,
-} from '../../constants';
-import useAuthStore from '../../store/auth';
+import { M3U_FILTER_TYPES } from '../../constants';
import usePlaylistsStore from '../../store/playlists';
+import { setCustomProperty } from '../../utils';
const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
@@ -35,6 +26,7 @@ const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
filter_type: 'group',
regex_pattern: '',
exclude: true,
+ case_sensitive: true,
},
validate: (values) => ({}),
@@ -46,6 +38,8 @@ const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
filter_type: filter.filter_type,
regex_pattern: filter.regex_pattern,
exclude: filter.exclude,
+ case_sensitive:
+ JSON.parse(filter.custom_properties || '{}').case_sensitive ?? true,
});
} else {
form.reset();
@@ -55,6 +49,15 @@ const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
const onSubmit = async () => {
const values = form.getValues();
+ values.custom_properties = setCustomProperty(
+ filter ? filter.custom_properties : {},
+ 'case_sensitive',
+ values.case_sensitive,
+ true
+ );
+
+ delete values.case_sensitive;
+
if (!filter) {
// By default, new rule will go at the end
values.order = m3u.filters.length;
@@ -106,6 +109,19 @@ const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
})}
/>
+
+
+ Case Sensitive
+
+
diff --git a/frontend/src/utils.js b/frontend/src/utils.js
index c995fd89..81836f0a 100644
--- a/frontend/src/utils.js
+++ b/frontend/src/utils.js
@@ -89,3 +89,30 @@ export const copyToClipboard = async (value) => {
return false;
}
};
+
+export const setCustomProperty = (input, key, value, serialize = false) => {
+ let obj;
+
+ if (input == null) {
+ // matches null or undefined
+ obj = {};
+ } else if (typeof input === 'string') {
+ try {
+ obj = JSON.parse(input);
+ } catch (e) {
+ obj = {};
+ }
+ } else if (typeof input === 'object' && !Array.isArray(input)) {
+ obj = { ...input }; // shallow copy
+ } else {
+ obj = {};
+ }
+
+ obj[key] = value;
+
+ if (serialize === true) {
+ return JSON.stringify(obj);
+ }
+
+ return obj;
+};