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; +};