mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-26 19:48:02 +00:00
first run at m3u filtering
This commit is contained in:
parent
7b5a617bf8
commit
ead76fe661
13 changed files with 1400 additions and 338 deletions
|
|
@ -1,18 +1,38 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet, M3UAccountProfileViewSet
|
||||
from .api_views import (
|
||||
M3UAccountViewSet,
|
||||
M3UFilterViewSet,
|
||||
ServerGroupViewSet,
|
||||
RefreshM3UAPIView,
|
||||
RefreshSingleM3UAPIView,
|
||||
UserAgentViewSet,
|
||||
M3UAccountProfileViewSet,
|
||||
)
|
||||
|
||||
app_name = 'm3u'
|
||||
app_name = "m3u"
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'accounts', M3UAccountViewSet, basename='m3u-account')
|
||||
router.register(r'accounts\/(?P<account_id>\d+)\/profiles', M3UAccountProfileViewSet, basename='m3u-account-profiles')
|
||||
router.register(r'filters', M3UFilterViewSet, basename='m3u-filter')
|
||||
router.register(r'server-groups', ServerGroupViewSet, basename='server-group')
|
||||
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
|
||||
router.register(
|
||||
r"accounts\/(?P<account_id>\d+)\/profiles",
|
||||
M3UAccountProfileViewSet,
|
||||
basename="m3u-account-profiles",
|
||||
)
|
||||
router.register(
|
||||
r"accounts\/(?P<account_id>\d+)\/filters",
|
||||
M3UFilterViewSet,
|
||||
basename="m3u-filters",
|
||||
)
|
||||
router.register(r"server-groups", ServerGroupViewSet, basename="server-group")
|
||||
|
||||
urlpatterns = [
|
||||
path('refresh/', RefreshM3UAPIView.as_view(), name='m3u_refresh'),
|
||||
path('refresh/<int:account_id>/', RefreshSingleM3UAPIView.as_view(), name='m3u_refresh_single'),
|
||||
path("refresh/", RefreshM3UAPIView.as_view(), name="m3u_refresh"),
|
||||
path(
|
||||
"refresh/<int:account_id>/",
|
||||
RefreshSingleM3UAPIView.as_view(),
|
||||
name="m3u_refresh_single",
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
|
|
|
|||
|
|
@ -183,8 +183,6 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
|
||||
|
||||
class M3UFilterViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U filters"""
|
||||
|
||||
queryset = M3UFilter.objects.all()
|
||||
serializer_class = M3UFilterSerializer
|
||||
|
||||
|
|
@ -194,6 +192,23 @@ class M3UFilterViewSet(viewsets.ModelViewSet):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
m3u_account_id = self.kwargs["account_id"]
|
||||
return M3UFilter.objects.filter(m3u_account_id=m3u_account_id)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# Get the account ID from the URL
|
||||
account_id = self.kwargs["account_id"]
|
||||
|
||||
# # Get the M3UAccount instance for the account_id
|
||||
# m3u_account = M3UAccount.objects.get(id=account_id)
|
||||
|
||||
# Save the 'm3u_account' in the serializer context
|
||||
serializer.context["m3u_account"] = account_id
|
||||
|
||||
# Perform the actual save
|
||||
serializer.save(m3u_account_id=account_id)
|
||||
|
||||
|
||||
class ServerGroupViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for Server Groups"""
|
||||
|
|
|
|||
18
apps/m3u/migrations/0013_alter_m3ufilter_filter_type.py
Normal file
18
apps/m3u/migrations/0013_alter_m3ufilter_filter_type.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.6 on 2025-07-22 21:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('m3u', '0012_alter_m3uaccount_refresh_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='m3ufilter',
|
||||
name='filter_type',
|
||||
field=models.CharField(choices=[('group', 'Group'), ('name', 'Stream Name'), ('url', 'Stream URL')], default='group', help_text='Filter based on either group title or stream name.', max_length=50),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 5.1.6 on 2025-07-31 17:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('m3u', '0013_alter_m3ufilter_filter_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='m3ufilter',
|
||||
options={'ordering': ['order']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='m3ufilter',
|
||||
name='order',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
]
|
||||
|
|
@ -155,9 +155,11 @@ class M3UFilter(models.Model):
|
|||
"""Defines filters for M3U accounts based on stream name or group title."""
|
||||
|
||||
FILTER_TYPE_CHOICES = (
|
||||
("group", "Group Title"),
|
||||
("group", "Group"),
|
||||
("name", "Stream Name"),
|
||||
("url", "Stream URL"),
|
||||
)
|
||||
|
||||
m3u_account = models.ForeignKey(
|
||||
M3UAccount,
|
||||
on_delete=models.CASCADE,
|
||||
|
|
@ -177,6 +179,7 @@ class M3UFilter(models.Model):
|
|||
default=True,
|
||||
help_text="If True, matching items are excluded; if False, only matches are included.",
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
def applies_to(self, stream_name, group_name):
|
||||
target = group_name if self.filter_type == "group" else stream_name
|
||||
|
|
@ -226,9 +229,6 @@ class ServerGroup(models.Model):
|
|||
return self.name
|
||||
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class M3UAccountProfile(models.Model):
|
||||
"""Represents a profile associated with an M3U Account."""
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,9 @@ logger = logging.getLogger(__name__)
|
|||
class M3UFilterSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for M3U Filters"""
|
||||
|
||||
channel_groups = ChannelGroupM3UAccountSerializer(source="m3u_account", many=True)
|
||||
|
||||
class Meta:
|
||||
model = M3UFilter
|
||||
fields = ["id", "filter_type", "regex_pattern", "exclude", "channel_groups"]
|
||||
fields = ["id", "filter_type", "regex_pattern", "exclude", "order"]
|
||||
|
||||
|
||||
class M3UAccountProfileSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -64,7 +62,7 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
|
|||
class M3UAccountSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for M3U Account"""
|
||||
|
||||
filters = M3UFilterSerializer(many=True, read_only=True)
|
||||
filters = serializers.SerializerMethodField()
|
||||
# Include user_agent as a mandatory field using its primary key.
|
||||
user_agent = serializers.PrimaryKeyRelatedField(
|
||||
queryset=UserAgent.objects.all(),
|
||||
|
|
@ -149,6 +147,10 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
|
||||
return instance
|
||||
|
||||
def get_filters(self, obj):
|
||||
filters = obj.filters.order_by("order")
|
||||
return M3UFilterSerializer(filters, many=True).data
|
||||
|
||||
|
||||
class ServerGroupSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Server Group"""
|
||||
|
|
|
|||
1016
apps/m3u/tasks.py
1016
apps/m3u/tasks.py
File diff suppressed because it is too large
Load diff
|
|
@ -256,7 +256,7 @@ export default class API {
|
|||
hasChannels: false,
|
||||
hasM3UAccounts: false,
|
||||
canEdit: true,
|
||||
canDelete: true
|
||||
canDelete: true,
|
||||
};
|
||||
useChannelsStore.getState().addChannelGroup(processedGroup);
|
||||
// Refresh channel groups to update the UI
|
||||
|
|
@ -736,10 +736,13 @@ export default class API {
|
|||
|
||||
static async updateM3UGroupSettings(playlistId, groupSettings) {
|
||||
try {
|
||||
const response = await request(`${host}/api/m3u/accounts/${playlistId}/group-settings/`, {
|
||||
method: 'PATCH',
|
||||
body: { group_settings: groupSettings },
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/api/m3u/accounts/${playlistId}/group-settings/`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: { group_settings: groupSettings },
|
||||
}
|
||||
);
|
||||
// Fetch the updated playlist and update the store
|
||||
const updatedPlaylist = await API.getPlaylist(playlistId);
|
||||
usePlaylistsStore.getState().updatePlaylist(updatedPlaylist);
|
||||
|
|
@ -1110,6 +1113,48 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async addM3UFilter(accountId, values) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/m3u/accounts/${accountId}/filters/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: values,
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to add profile to account ${accountId}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteM3UFilter(accountId, id) {
|
||||
try {
|
||||
await request(`${host}/api/m3u/accounts/${accountId}/filters/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to delete profile for account ${accountId}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateM3UFilter(accountId, filterId, values) {
|
||||
const { id, ...payload } = values;
|
||||
|
||||
try {
|
||||
await request(
|
||||
`${host}/api/m3u/accounts/${accountId}/filters/${filterId}/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to update profile for account ${accountId}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getSettings() {
|
||||
try {
|
||||
const response = await request(`${host}/api/core/settings/`);
|
||||
|
|
@ -1230,7 +1275,9 @@ export default class API {
|
|||
static async getLogos(params = {}) {
|
||||
try {
|
||||
const queryParams = new URLSearchParams(params);
|
||||
const response = await request(`${host}/api/channels/logos/?${queryParams.toString()}`);
|
||||
const response = await request(
|
||||
`${host}/api/channels/logos/?${queryParams.toString()}`
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -1369,7 +1416,7 @@ export default class API {
|
|||
});
|
||||
|
||||
// Remove multiple logos from store
|
||||
ids.forEach(id => {
|
||||
ids.forEach((id) => {
|
||||
useChannelsStore.getState().removeLogo(id);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import usePlaylistsStore from '../../store/playlists';
|
|||
import { notifications } from '@mantine/notifications';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import M3UFilters from './M3UFilters';
|
||||
|
||||
const M3U = ({
|
||||
m3uAccount = null,
|
||||
|
|
@ -45,6 +46,7 @@ const M3U = ({
|
|||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState('');
|
||||
const [showCredentialFields, setShowCredentialFields] = useState(false);
|
||||
|
||||
|
|
@ -85,7 +87,11 @@ const M3U = ({
|
|||
account_type: m3uAccount.account_type,
|
||||
username: m3uAccount.username ?? '',
|
||||
password: '',
|
||||
stale_stream_days: m3uAccount.stale_stream_days !== undefined && m3uAccount.stale_stream_days !== null ? m3uAccount.stale_stream_days : 7,
|
||||
stale_stream_days:
|
||||
m3uAccount.stale_stream_days !== undefined &&
|
||||
m3uAccount.stale_stream_days !== null
|
||||
? m3uAccount.stale_stream_days
|
||||
: 7,
|
||||
});
|
||||
|
||||
if (m3uAccount.account_type == 'XC') {
|
||||
|
|
@ -145,7 +151,8 @@ const M3U = ({
|
|||
if (values.account_type != 'XC') {
|
||||
notifications.show({
|
||||
title: 'Fetching M3U Groups',
|
||||
message: 'Configure group filters and auto sync settings once complete.',
|
||||
message:
|
||||
'Configure group filters and auto sync settings once complete.',
|
||||
});
|
||||
|
||||
// Don't prompt for group filters, but keeping this here
|
||||
|
|
@ -177,7 +184,10 @@ const M3U = ({
|
|||
|
||||
const closeGroupFilter = () => {
|
||||
setGroupFilterModalOpen(false);
|
||||
close();
|
||||
};
|
||||
|
||||
const closeFilter = () => {
|
||||
setFilterModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -224,7 +234,12 @@ const M3U = ({
|
|||
id="account_type"
|
||||
name="account_type"
|
||||
label="Account Type"
|
||||
description={<>Standard for direct M3U URLs, <br />Xtream Codes for panel-based services</>}
|
||||
description={
|
||||
<>
|
||||
Standard for direct M3U URLs, <br />
|
||||
Xtream Codes for panel-based services
|
||||
</>
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: 'STD',
|
||||
|
|
@ -316,8 +331,13 @@ const M3U = ({
|
|||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description={<>How often to automatically refresh M3U data<br />
|
||||
(0 to disable automatic refreshes)</>}
|
||||
description={
|
||||
<>
|
||||
How often to automatically refresh M3U data
|
||||
<br />
|
||||
(0 to disable automatic refreshes)
|
||||
</>
|
||||
}
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
/>
|
||||
|
|
@ -342,6 +362,13 @@ const M3U = ({
|
|||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
{playlist && (
|
||||
<>
|
||||
<Button
|
||||
variant="filled"
|
||||
size="sm"
|
||||
onClick={() => setFilterModalOpen(true)}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
// color={theme.custom.colors.buttonPrimary}
|
||||
|
|
@ -382,6 +409,11 @@ const M3U = ({
|
|||
playlist={playlist}
|
||||
onClose={closeGroupFilter}
|
||||
/>
|
||||
<M3UFilters
|
||||
isOpen={filterModalOpen}
|
||||
playlist={playlist}
|
||||
onClose={closeFilter}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
|
|
|||
126
frontend/src/components/forms/M3UFilter.jsx
Normal file
126
frontend/src/components/forms/M3UFilter.jsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
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 usePlaylistsStore from '../../store/playlists';
|
||||
|
||||
const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
|
||||
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: {
|
||||
filter_type: 'group',
|
||||
regex_pattern: '',
|
||||
exclude: true,
|
||||
},
|
||||
|
||||
validate: (values) => ({}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (filter) {
|
||||
form.setValues({
|
||||
filter_type: filter.filter_type,
|
||||
regex_pattern: filter.regex_pattern,
|
||||
exclude: filter.exclude,
|
||||
});
|
||||
} else {
|
||||
form.reset();
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
|
||||
if (!filter) {
|
||||
// By default, new rule will go at the end
|
||||
values.order = m3u.filters.length;
|
||||
await API.addM3UFilter(m3u.id, values);
|
||||
} else {
|
||||
await API.updateM3UFilter(m3u.id, filter.id, values);
|
||||
}
|
||||
|
||||
fetchPlaylist(m3u.id);
|
||||
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Filter">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Select
|
||||
label="Field"
|
||||
description="Specify which property of the stream object this rule will apply to"
|
||||
data={M3U_FILTER_TYPES}
|
||||
{...form.getInputProps('filter_type')}
|
||||
key={form.key('filter_type')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="regex_pattern"
|
||||
name="regex_pattern"
|
||||
label="Regex Pattern"
|
||||
description="Regular expression to execute on the value to determine if the filter applies to the item"
|
||||
{...form.getInputProps('regex_pattern')}
|
||||
key={form.key('regex_pattern')}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Box>Exclude</Box>
|
||||
<Switch
|
||||
id="exclude"
|
||||
name="exclude"
|
||||
description="Specify if this is an exclusion or inclusion rule"
|
||||
key={form.key('exclude')}
|
||||
{...form.getInputProps('exclude', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={form.submitting}
|
||||
size="small"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UFilter;
|
||||
327
frontend/src/components/forms/M3UFilters.jsx
Normal file
327
frontend/src/components/forms/M3UFilters.jsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import M3UProfile from './M3UProfile';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import {
|
||||
Card,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Modal,
|
||||
Button,
|
||||
Box,
|
||||
ActionIcon,
|
||||
Text,
|
||||
NumberInput,
|
||||
useMantineTheme,
|
||||
Center,
|
||||
Group,
|
||||
Switch,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { GripHorizontal, SquareMinus, SquarePen } from 'lucide-react';
|
||||
import M3UFilter from './M3UFilter';
|
||||
import { M3U_FILTER_TYPES } from '../../constants';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useDraggable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
|
||||
const RowDragHandleCell = ({ rowId }) => {
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: rowId,
|
||||
});
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<ActionIcon
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
variant="transparent"
|
||||
size="xs"
|
||||
style={{
|
||||
cursor: 'grab', // this is enough
|
||||
}}
|
||||
>
|
||||
<GripHorizontal color="white" />
|
||||
</ActionIcon>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
// Row Component
|
||||
const DraggableRow = ({ filter, onDelete }) => {
|
||||
const theme = useMantineTheme();
|
||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: filter.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform), //let dnd-kit do its thing
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
key={filter.id}
|
||||
spacing="xs"
|
||||
style={{
|
||||
...style,
|
||||
padding: '8px',
|
||||
paddingBottom: '8px',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#2A2A2E',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
<Flex gap="sm" justify="space-between" alignItems="middle">
|
||||
<Group justify="left">
|
||||
<RowDragHandleCell rowId={filter.id} />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={700}
|
||||
style={{
|
||||
color: filter.exclude
|
||||
? theme.tailwind.red[6]
|
||||
: theme.tailwind.green[5],
|
||||
}}
|
||||
>
|
||||
{filter.exclude ? 'Exclude' : 'Include'}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{
|
||||
M3U_FILTER_TYPES.find((type) => type.value == filter.filter_type)
|
||||
.label
|
||||
}
|
||||
</Text>
|
||||
<Text size="sm">matching</Text>
|
||||
<Text size="sm">
|
||||
"<code>{filter.regex_pattern}</code>"
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group align="flex-end" gap="xs">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={() => editFilter(filter)}
|
||||
>
|
||||
<SquarePen size="20" />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={() => onDelete(filter.id)}
|
||||
size="small"
|
||||
variant="transparent"
|
||||
>
|
||||
<SquareMinus size="20" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const M3UFilters = ({ playlist, isOpen, onClose }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [filter, setFilter] = useState(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [filterToDelete, setFilterToDelete] = useState(null);
|
||||
const [filters, setFilters] = useState([]);
|
||||
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(playlist.filters || []);
|
||||
}, [playlist]);
|
||||
|
||||
const editFilter = (filter = null) => {
|
||||
if (filter) {
|
||||
setFilter(filter);
|
||||
}
|
||||
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const onDelete = async (id) => {
|
||||
if (!playlist || !playlist.id) return;
|
||||
|
||||
// Get profile details for the confirmation dialog
|
||||
const filterObj = playlist.filters.find((p) => p.id === id);
|
||||
setFilterToDelete(filterObj);
|
||||
setDeleteTarget(id);
|
||||
|
||||
// Skip warning if it's been suppressed
|
||||
if (isWarningSuppressed('delete-filter')) {
|
||||
return deleteFilter(id);
|
||||
}
|
||||
|
||||
fetchPlaylist(playlist.id);
|
||||
setConfirmDeleteOpen(true);
|
||||
};
|
||||
|
||||
const deleteFilter = async (id) => {
|
||||
if (!playlist || !playlist.id) return;
|
||||
try {
|
||||
await API.deleteM3UFilter(playlist.id, id);
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Error deleting profile:', error);
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
|
||||
fetchPlaylist(playlist.id);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setFilter(null);
|
||||
setEditorOpen(false);
|
||||
};
|
||||
|
||||
const handleDragEnd = async ({ active, over }) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const originalFilters = [...filters];
|
||||
|
||||
const oldIndex = filters.findIndex((f) => f.id === active.id);
|
||||
const newIndex = filters.findIndex((f) => f.id === over.id);
|
||||
const newFilters = arrayMove(filters, oldIndex, newIndex);
|
||||
|
||||
setFilters(newFilters);
|
||||
|
||||
// Recalculate and compare order
|
||||
const updatedFilters = newFilters.map((filter, index) => ({
|
||||
...filter,
|
||||
newOrder: index,
|
||||
}));
|
||||
|
||||
// Filter only those whose order actually changed
|
||||
const changedFilters = updatedFilters.filter((f) => f.order !== f.newOrder);
|
||||
|
||||
// Send updates
|
||||
try {
|
||||
await Promise.all(
|
||||
changedFilters.map((f) =>
|
||||
API.updateM3UFilter(playlist.id, f.id, { ...f, order: f.newOrder })
|
||||
)
|
||||
);
|
||||
await fetchPlaylist(playlist.id);
|
||||
} catch (e) {
|
||||
setFilters(originalFilters);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if modal is not open, or if playlist data is invalid
|
||||
if (!isOpen || !playlist || !playlist.id) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={onClose} title="Filters" size="lg">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext
|
||||
items={filters.map(({ id }) => id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filters.map((filter) => (
|
||||
<DraggableRow
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => editFilter()}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
|
||||
<M3UFilter
|
||||
m3u={playlist}
|
||||
filter={filter}
|
||||
isOpen={editorOpen}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() => deleteFilter(deleteTarget)}
|
||||
title="Confirm Filter Deletion"
|
||||
message={
|
||||
filterToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to delete the following filter?
|
||||
|
||||
Type: ${filterToDelete.type}
|
||||
Patter: ${filterToDelete.regex_pattern}
|
||||
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
) : (
|
||||
'Are you sure you want to delete this filter? This action cannot be undone.'
|
||||
)
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
actionKey="delete-filter"
|
||||
onSuppressChange={suppressWarning}
|
||||
size="md"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UFilters;
|
||||
|
|
@ -33,19 +33,23 @@ export const NETWORK_ACCESS_OPTIONS = {
|
|||
export const PROXY_SETTINGS_OPTIONS = {
|
||||
buffering_timeout: {
|
||||
label: 'Buffering Timeout',
|
||||
description: 'Maximum time (in seconds) to wait for buffering before switching streams',
|
||||
description:
|
||||
'Maximum time (in seconds) to wait for buffering before switching streams',
|
||||
},
|
||||
buffering_speed: {
|
||||
label: 'Buffering Speed',
|
||||
description: 'Speed threshold below which buffering is detected (1.0 = normal speed)',
|
||||
description:
|
||||
'Speed threshold below which buffering is detected (1.0 = normal speed)',
|
||||
},
|
||||
redis_chunk_ttl: {
|
||||
label: 'Buffer Chunk TTL',
|
||||
description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
|
||||
description:
|
||||
'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
|
||||
},
|
||||
channel_shutdown_delay: {
|
||||
label: 'Channel Shutdown Delay',
|
||||
description: 'Delay in seconds before shutting down a channel after last client disconnects',
|
||||
description:
|
||||
'Delay in seconds before shutting down a channel after last client disconnects',
|
||||
},
|
||||
channel_init_grace_period: {
|
||||
label: 'Channel Initialization Grace Period',
|
||||
|
|
@ -53,6 +57,21 @@ export const PROXY_SETTINGS_OPTIONS = {
|
|||
},
|
||||
};
|
||||
|
||||
export const M3U_FILTER_TYPES = [
|
||||
{
|
||||
label: 'Group',
|
||||
value: 'group',
|
||||
},
|
||||
{
|
||||
label: 'Stream Name',
|
||||
value: 'name',
|
||||
},
|
||||
{
|
||||
label: 'Stream URL',
|
||||
value: 'url',
|
||||
},
|
||||
];
|
||||
|
||||
export const REGION_CHOICES = [
|
||||
{ value: 'ad', label: 'AD' },
|
||||
{ value: 'ae', label: 'AE' },
|
||||
|
|
|
|||
|
|
@ -19,6 +19,24 @@ const usePlaylistsStore = create((set) => ({
|
|||
editPlaylistId: id,
|
||||
})),
|
||||
|
||||
fetchPlaylist: async (id) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const playlist = await api.getPlaylist(id);
|
||||
set((state) => ({
|
||||
playlists: state.playlists.map((p) => (p.id == id ? playlist : p)),
|
||||
isLoading: false,
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[id]: playlist.profiles,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
set({ error: 'Failed to load playlists.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchPlaylists: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
|
|
@ -91,9 +109,11 @@ const usePlaylistsStore = create((set) => ({
|
|||
const existingProgress = state.refreshProgress[accountId];
|
||||
|
||||
// Don't replace 'initializing' status with empty/early server messages
|
||||
if (existingProgress &&
|
||||
if (
|
||||
existingProgress &&
|
||||
existingProgress.action === 'initializing' &&
|
||||
accountIdOrData.progress === 0) {
|
||||
accountIdOrData.progress === 0
|
||||
) {
|
||||
return state; // Keep showing 'initializing' until real progress comes
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue