mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into vod-relationtest
This commit is contained in:
commit
2903773c86
21 changed files with 1972 additions and 785 deletions
|
|
@ -28,6 +28,8 @@ from core.utils import acquire_task_lock, release_task_lock, send_websocket_upda
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_EXTRACT_CHUNK_SIZE = 65536 # 64kb (base2)
|
||||
|
||||
|
||||
def send_epg_update(source_id, action, progress, **kwargs):
|
||||
"""Send WebSocket update about EPG download/parsing progress"""
|
||||
|
|
@ -641,7 +643,11 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False):
|
|||
# Reset file pointer and extract the content
|
||||
gz_file.seek(0)
|
||||
with open(extracted_path, 'wb') as out_file:
|
||||
out_file.write(gz_file.read())
|
||||
while True:
|
||||
chunk = gz_file.read(MAX_EXTRACT_CHUNK_SIZE)
|
||||
if not chunk or len(chunk) == 0:
|
||||
break
|
||||
out_file.write(chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting GZIP file: {e}", exc_info=True)
|
||||
return None
|
||||
|
|
@ -685,9 +691,13 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False):
|
|||
return None
|
||||
|
||||
# Extract the first XML file
|
||||
xml_content = zip_file.read(xml_files[0])
|
||||
with open(extracted_path, 'wb') as out_file:
|
||||
out_file.write(xml_content)
|
||||
with zip_file.open(xml_files[0], "r") as xml_file:
|
||||
while True:
|
||||
chunk = xml_file.read(MAX_EXTRACT_CHUNK_SIZE)
|
||||
if not chunk or len(chunk) == 0:
|
||||
break
|
||||
out_file.write(chunk)
|
||||
|
||||
logger.info(f"Successfully extracted zip file to: {extracted_path}")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,27 +3,40 @@ from django.utils.html import format_html
|
|||
from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent
|
||||
import json
|
||||
|
||||
|
||||
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', 'vod_enabled_display', 'uploaded_file_link', 'created_at', 'updated_at')
|
||||
list_filter = ('is_active', 'server_group', 'account_type')
|
||||
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 vod_enabled_display(self, obj):
|
||||
|
|
@ -40,31 +53,35 @@ class M3UAccountAdmin(admin.ModelAdmin):
|
|||
|
||||
def uploaded_file_link(self, obj):
|
||||
if obj.uploaded_file:
|
||||
return format_html("<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url)
|
||||
return format_html(
|
||||
"<a href='{}' target='_blank'>Download M3U</a>", 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",)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -252,8 +252,6 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
|
||||
|
||||
class M3UFilterViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U filters"""
|
||||
|
||||
queryset = M3UFilter.objects.all()
|
||||
serializer_class = M3UFilterSerializer
|
||||
|
||||
|
|
@ -263,6 +261,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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,8 @@ 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)
|
||||
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
|
||||
|
|
@ -226,9 +230,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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
import json
|
||||
|
|
@ -17,11 +16,16 @@ 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",
|
||||
"custom_properties",
|
||||
]
|
||||
|
||||
|
||||
class M3UAccountProfileSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -65,7 +69,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(),
|
||||
|
|
@ -200,6 +204,10 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
|
||||
return super().create(validated_data)
|
||||
|
||||
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"""
|
||||
|
|
|
|||
1032
apps/m3u/tasks.py
1032
apps/m3u/tasks.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,208 +1,217 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# Root check
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "[ERROR] This script must be run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR
|
||||
|
||||
##############################################################################
|
||||
# 0) Warning / Disclaimer
|
||||
##############################################################################
|
||||
|
||||
echo "**************************************************************"
|
||||
echo "WARNING: While we do not anticipate any problems, we disclaim all"
|
||||
echo "responsibility for anything that happens to your machine."
|
||||
echo ""
|
||||
echo "This script is intended for **Debian-based operating systems only**."
|
||||
echo "Running it on other distributions WILL cause unexpected issues."
|
||||
echo ""
|
||||
echo "This script is **NOT RECOMMENDED** for use on your primary machine."
|
||||
echo "For safety and best results, we strongly advise running this inside a"
|
||||
echo "clean virtual machine (VM) or LXC container environment."
|
||||
echo ""
|
||||
echo "Additionally, there is NO SUPPORT for this method; Docker is the only"
|
||||
echo "officially supported way to run Dispatcharr."
|
||||
echo "**************************************************************"
|
||||
echo ""
|
||||
echo "If you wish to proceed, type \"I understand\" and press Enter."
|
||||
read user_input
|
||||
|
||||
if [ "$user_input" != "I understand" ]; then
|
||||
echo "Exiting script..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
show_disclaimer() {
|
||||
echo "**************************************************************"
|
||||
echo "WARNING: While we do not anticipate any problems, we disclaim all"
|
||||
echo "responsibility for anything that happens to your machine."
|
||||
echo ""
|
||||
echo "This script is intended for **Debian-based operating systems only**."
|
||||
echo "Running it on other distributions WILL cause unexpected issues."
|
||||
echo ""
|
||||
echo "This script is **NOT RECOMMENDED** for use on your primary machine."
|
||||
echo "For safety and best results, we strongly advise running this inside a"
|
||||
echo "clean virtual machine (VM) or LXC container environment."
|
||||
echo ""
|
||||
echo "Additionally, there is NO SUPPORT for this method; Docker is the only"
|
||||
echo "officially supported way to run Dispatcharr."
|
||||
echo "**************************************************************"
|
||||
echo ""
|
||||
echo "If you wish to proceed, type \"I understand\" and press Enter."
|
||||
read user_input
|
||||
if [ "$user_input" != "I understand" ]; then
|
||||
echo "Exiting script..."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 1) Configuration
|
||||
##############################################################################
|
||||
|
||||
# Linux user/group under which Dispatcharr processes will run
|
||||
DISPATCH_USER="dispatcharr"
|
||||
DISPATCH_GROUP="dispatcharr"
|
||||
|
||||
# Where Dispatcharr source code should live
|
||||
APP_DIR="/opt/dispatcharr"
|
||||
|
||||
# Git branch to clone (e.g., "main" or "dev")
|
||||
DISPATCH_BRANCH="dev"
|
||||
|
||||
# PostgreSQL settings
|
||||
POSTGRES_DB="dispatcharr"
|
||||
POSTGRES_USER="dispatch"
|
||||
POSTGRES_PASSWORD="secret"
|
||||
|
||||
# The port on which Nginx will listen for HTTP
|
||||
NGINX_HTTP_PORT="9191"
|
||||
|
||||
# The TCP port for Daphné (Django Channels)
|
||||
WEBSOCKET_PORT="8001"
|
||||
|
||||
# Directory inside /run/ for our socket; full path becomes /run/dispatcharr/dispatcharr.sock
|
||||
GUNICORN_RUNTIME_DIR="dispatcharr"
|
||||
GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock"
|
||||
configure_variables() {
|
||||
DISPATCH_USER="dispatcharr"
|
||||
DISPATCH_GROUP="dispatcharr"
|
||||
APP_DIR="/opt/dispatcharr"
|
||||
DISPATCH_BRANCH="main"
|
||||
POSTGRES_DB="dispatcharr"
|
||||
POSTGRES_USER="dispatch"
|
||||
POSTGRES_PASSWORD="secret"
|
||||
NGINX_HTTP_PORT="9191"
|
||||
WEBSOCKET_PORT="8001"
|
||||
GUNICORN_RUNTIME_DIR="dispatcharr"
|
||||
GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock"
|
||||
PYTHON_BIN=$(command -v python3)
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
NGINX_SITE="/etc/nginx/sites-available/dispatcharr"
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 2) Install System Packages
|
||||
##############################################################################
|
||||
|
||||
echo ">>> Installing system packages..."
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
build-essential \
|
||||
gcc \
|
||||
libpcre3-dev \
|
||||
libpq-dev \
|
||||
python3-dev \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
nginx \
|
||||
redis-server \
|
||||
postgresql \
|
||||
postgresql-contrib \
|
||||
ffmpeg \
|
||||
procps \
|
||||
streamlink
|
||||
install_packages() {
|
||||
echo ">>> Installing system packages..."
|
||||
apt-get update
|
||||
declare -a packages=(
|
||||
git curl wget build-essential gcc libpcre3-dev libpq-dev
|
||||
python3-dev python3-venv python3-pip nginx redis-server
|
||||
postgresql postgresql-contrib ffmpeg procps streamlink
|
||||
)
|
||||
apt-get install -y --no-install-recommends "${packages[@]}"
|
||||
|
||||
# Node.js setup (v23.x from NodeSource) - adjust version if needed
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo ">>> Installing Node.js..."
|
||||
curl -sL https://deb.nodesource.com/setup_23.x | bash -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo ">>> Installing Node.js..."
|
||||
curl -sL https://deb.nodesource.com/setup_23.x | bash -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
|
||||
# Start & enable PostgreSQL and Redis
|
||||
systemctl enable postgresql redis-server
|
||||
systemctl start postgresql redis-server
|
||||
systemctl enable --now postgresql redis-server
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 3) Create Dispatcharr User/Group
|
||||
# 3) Create User/Group
|
||||
##############################################################################
|
||||
|
||||
if ! getent group "${DISPATCH_GROUP}" >/dev/null; then
|
||||
echo ">>> Creating group: ${DISPATCH_GROUP}"
|
||||
groupadd "${DISPATCH_GROUP}"
|
||||
fi
|
||||
|
||||
if ! id -u "${DISPATCH_USER}" >/dev/null; then
|
||||
echo ">>> Creating user: ${DISPATCH_USER}"
|
||||
useradd -m -g "${DISPATCH_GROUP}" -s /bin/bash "${DISPATCH_USER}"
|
||||
fi
|
||||
create_dispatcharr_user() {
|
||||
if ! getent group "$DISPATCH_GROUP" >/dev/null; then
|
||||
groupadd "$DISPATCH_GROUP"
|
||||
fi
|
||||
if ! id -u "$DISPATCH_USER" >/dev/null; then
|
||||
useradd -m -g "$DISPATCH_GROUP" -s /bin/bash "$DISPATCH_USER"
|
||||
fi
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 4) Configure PostgreSQL Database
|
||||
# 4) PostgreSQL Setup
|
||||
##############################################################################
|
||||
|
||||
echo ">>> Configuring PostgreSQL..."
|
||||
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'\"" | grep -q 1 || \
|
||||
su - postgres -c "psql -c \"CREATE DATABASE ${POSTGRES_DB};\""
|
||||
setup_postgresql() {
|
||||
echo ">>> Checking PostgreSQL database and user..."
|
||||
|
||||
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='${POSTGRES_USER}'\"" | grep -q 1 || \
|
||||
su - postgres -c "psql -c \"CREATE USER ${POSTGRES_USER} WITH PASSWORD '${POSTGRES_PASSWORD}';\""
|
||||
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'")
|
||||
if [[ "$db_exists" != "1" ]]; then
|
||||
echo ">>> Creating database '${POSTGRES_DB}'..."
|
||||
sudo -u postgres createdb "$POSTGRES_DB"
|
||||
else
|
||||
echo ">>> Database '${POSTGRES_DB}' already exists, skipping creation."
|
||||
fi
|
||||
|
||||
su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_USER};\""
|
||||
su - postgres -c "psql -c \"ALTER DATABASE ${POSTGRES_DB} OWNER TO ${POSTGRES_USER};\""
|
||||
su - postgres -c "psql -d ${POSTGRES_DB} -c \"ALTER SCHEMA public OWNER TO ${POSTGRES_USER};\""
|
||||
user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$POSTGRES_USER'")
|
||||
if [[ "$user_exists" != "1" ]]; then
|
||||
echo ">>> Creating user '${POSTGRES_USER}'..."
|
||||
sudo -u postgres psql -c "CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';"
|
||||
else
|
||||
echo ">>> User '${POSTGRES_USER}' already exists, skipping creation."
|
||||
fi
|
||||
|
||||
echo ">>> Granting privileges..."
|
||||
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;"
|
||||
sudo -u postgres psql -c "ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;"
|
||||
sudo -u postgres psql -d "$POSTGRES_DB" -c "ALTER SCHEMA public OWNER TO $POSTGRES_USER;"
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 5) Clone or Update Dispatcharr Code
|
||||
# 5) Clone Dispatcharr Repository
|
||||
##############################################################################
|
||||
|
||||
echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..."
|
||||
clone_dispatcharr_repo() {
|
||||
echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..."
|
||||
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
mkdir -p "$APP_DIR"
|
||||
chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR"
|
||||
fi
|
||||
|
||||
if [ ! -d "${APP_DIR}" ]; then
|
||||
echo ">>> Cloning repository for the first time..."
|
||||
mkdir -p "${APP_DIR}"
|
||||
chown "${DISPATCH_USER}:${DISPATCH_GROUP}" "${APP_DIR}"
|
||||
su - "${DISPATCH_USER}" -c "git clone -b ${DISPATCH_BRANCH} https://github.com/Dispatcharr/Dispatcharr.git ${APP_DIR}"
|
||||
else
|
||||
echo ">>> Updating existing repository..."
|
||||
su - "${DISPATCH_USER}" <<EOSU
|
||||
cd "${APP_DIR}"
|
||||
if [ -d .git ]; then
|
||||
if [ -d "$APP_DIR/.git" ]; then
|
||||
echo ">>> Updating existing Dispatcharr repo..."
|
||||
su - "$DISPATCH_USER" <<EOSU
|
||||
cd "$APP_DIR"
|
||||
git fetch origin
|
||||
git checkout ${DISPATCH_BRANCH}
|
||||
git pull origin ${DISPATCH_BRANCH}
|
||||
else
|
||||
echo "WARNING: .git directory missing, cannot perform update via git."
|
||||
fi
|
||||
EOSU
|
||||
fi
|
||||
git reset --hard HEAD
|
||||
git fetch origin
|
||||
git checkout $DISPATCH_BRANCH
|
||||
git pull origin $DISPATCH_BRANCH
|
||||
EOSU
|
||||
else
|
||||
echo ">>> Cloning Dispatcharr repo into ${APP_DIR}..."
|
||||
rm -rf "$APP_DIR"/*
|
||||
chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR"
|
||||
su - "$DISPATCH_USER" -c "git clone -b $DISPATCH_BRANCH https://github.com/Dispatcharr/Dispatcharr.git $APP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 6) Create Python Virtual Environment & Install Python Dependencies
|
||||
# 6) Setup Python Environment
|
||||
##############################################################################
|
||||
|
||||
echo ">>> Setting up Python virtual environment..."
|
||||
su - "${DISPATCH_USER}" <<EOSU
|
||||
cd "${APP_DIR}"
|
||||
python3 -m venv env
|
||||
setup_python_env() {
|
||||
echo ">>> Setting up Python virtual environment..."
|
||||
su - "$DISPATCH_USER" <<EOSU
|
||||
cd "$APP_DIR"
|
||||
$PYTHON_BIN -m venv env
|
||||
source env/bin/activate
|
||||
|
||||
# Upgrade pip and install dependencies from requirements
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Explicitly ensure Gunicorn is installed in the virtualenv
|
||||
pip install gunicorn
|
||||
EOSU
|
||||
|
||||
# 6a) Create a symlink for ffmpeg in the virtualenv's bin directory.
|
||||
echo ">>> Linking ffmpeg into the virtual environment..."
|
||||
ln -sf /usr/bin/ffmpeg ${APP_DIR}/env/bin/ffmpeg
|
||||
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 7) Build Frontend (React)
|
||||
# 7) Build Frontend
|
||||
##############################################################################
|
||||
|
||||
echo ">>> Building frontend..."
|
||||
su - "${DISPATCH_USER}" <<EOSU
|
||||
cd "${APP_DIR}/frontend"
|
||||
build_frontend() {
|
||||
echo ">>> Building frontend..."
|
||||
su - "$DISPATCH_USER" <<EOSU
|
||||
cd "$APP_DIR/frontend"
|
||||
npm install --legacy-peer-deps
|
||||
npm run build
|
||||
EOSU
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 8) Django Migrate & Collect Static
|
||||
# 8) Django Migrations & Static
|
||||
##############################################################################
|
||||
|
||||
echo ">>> Running Django migrations & collectstatic..."
|
||||
su - "${DISPATCH_USER}" <<EOSU
|
||||
cd "${APP_DIR}"
|
||||
django_migrate_collectstatic() {
|
||||
echo ">>> Running Django migrations & collectstatic..."
|
||||
su - "$DISPATCH_USER" <<EOSU
|
||||
cd "$APP_DIR"
|
||||
source env/bin/activate
|
||||
export POSTGRES_DB="${POSTGRES_DB}"
|
||||
export POSTGRES_USER="${POSTGRES_USER}"
|
||||
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD}"
|
||||
export POSTGRES_DB="$POSTGRES_DB"
|
||||
export POSTGRES_USER="$POSTGRES_USER"
|
||||
export POSTGRES_PASSWORD="$POSTGRES_PASSWORD"
|
||||
export POSTGRES_HOST="localhost"
|
||||
python manage.py migrate --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
EOSU
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 9) Create Systemd Service for Gunicorn
|
||||
# 9) Configure Services & Nginx
|
||||
##############################################################################
|
||||
|
||||
cat <<EOF >/etc/systemd/system/dispatcharr.service
|
||||
configure_services() {
|
||||
echo ">>> Creating systemd service files..."
|
||||
|
||||
# Gunicorn
|
||||
cat <<EOF >${SYSTEMD_DIR}/dispatcharr.service
|
||||
[Unit]
|
||||
Description=Gunicorn for Dispatcharr
|
||||
After=network.target postgresql.service redis-server.service
|
||||
|
|
@ -211,36 +220,31 @@ After=network.target postgresql.service redis-server.service
|
|||
User=${DISPATCH_USER}
|
||||
Group=${DISPATCH_GROUP}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
|
||||
RuntimeDirectory=${GUNICORN_RUNTIME_DIR}
|
||||
RuntimeDirectoryMode=0775
|
||||
|
||||
# Update PATH to include both the virtualenv and system binaries (for ffmpeg)
|
||||
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
|
||||
Environment="POSTGRES_DB=${POSTGRES_DB}"
|
||||
Environment="POSTGRES_USER=${POSTGRES_USER}"
|
||||
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
Environment="POSTGRES_HOST=localhost"
|
||||
|
||||
ExecStartPre=/usr/bin/bash -c 'until pg_isready -h localhost -U ${POSTGRES_USER}; do sleep 1; done'
|
||||
ExecStart=${APP_DIR}/env/bin/gunicorn \\
|
||||
--workers=4 \\
|
||||
--worker-class=gevent \\
|
||||
--timeout=300 \\
|
||||
--bind unix:${GUNICORN_SOCKET} \\
|
||||
dispatcharr.wsgi:application
|
||||
|
||||
Restart=always
|
||||
KillMode=mixed
|
||||
|
||||
SyslogIdentifier=dispatcharr
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
##############################################################################
|
||||
# 10) Create Systemd Service for Celery
|
||||
##############################################################################
|
||||
|
||||
cat <<EOF >/etc/systemd/system/dispatcharr-celery.service
|
||||
# Celery
|
||||
cat <<EOF >${SYSTEMD_DIR}/dispatcharr-celery.service
|
||||
[Unit]
|
||||
Description=Celery Worker for Dispatcharr
|
||||
After=network.target redis-server.service
|
||||
|
|
@ -256,21 +260,18 @@ Environment="POSTGRES_USER=${POSTGRES_USER}"
|
|||
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
Environment="POSTGRES_HOST=localhost"
|
||||
Environment="CELERY_BROKER_URL=redis://localhost:6379/0"
|
||||
|
||||
ExecStart=${APP_DIR}/env/bin/celery -A dispatcharr worker -l info
|
||||
|
||||
Restart=always
|
||||
KillMode=mixed
|
||||
|
||||
SyslogIdentifier=dispatcharr-celery
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
##############################################################################
|
||||
# 11) Create Systemd Service for Celery Beat (Optional)
|
||||
##############################################################################
|
||||
|
||||
cat <<EOF >/etc/systemd/system/dispatcharr-celerybeat.service
|
||||
# Celery Beat
|
||||
cat <<EOF >${SYSTEMD_DIR}/dispatcharr-celerybeat.service
|
||||
[Unit]
|
||||
Description=Celery Beat Scheduler for Dispatcharr
|
||||
After=network.target redis-server.service
|
||||
|
|
@ -286,23 +287,20 @@ Environment="POSTGRES_USER=${POSTGRES_USER}"
|
|||
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
Environment="POSTGRES_HOST=localhost"
|
||||
Environment="CELERY_BROKER_URL=redis://localhost:6379/0"
|
||||
|
||||
ExecStart=${APP_DIR}/env/bin/celery -A dispatcharr beat -l info
|
||||
|
||||
Restart=always
|
||||
KillMode=mixed
|
||||
|
||||
SyslogIdentifier=dispatcharr-celerybeat
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
##############################################################################
|
||||
# 12) Create Systemd Service for Daphné (WebSockets / Channels)
|
||||
##############################################################################
|
||||
|
||||
cat <<EOF >/etc/systemd/system/dispatcharr-daphne.service
|
||||
# Daphne
|
||||
cat <<EOF >${SYSTEMD_DIR}/dispatcharr-daphne.service
|
||||
[Unit]
|
||||
Description=Daphne for Dispatcharr (ASGI)
|
||||
Description=Daphne for Dispatcharr (ASGI/WebSockets)
|
||||
After=network.target
|
||||
Requires=dispatcharr.service
|
||||
|
||||
|
|
@ -315,47 +313,33 @@ Environment="POSTGRES_DB=${POSTGRES_DB}"
|
|||
Environment="POSTGRES_USER=${POSTGRES_USER}"
|
||||
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
Environment="POSTGRES_HOST=localhost"
|
||||
|
||||
ExecStart=${APP_DIR}/env/bin/daphne -b 0.0.0.0 -p ${WEBSOCKET_PORT} dispatcharr.asgi:application
|
||||
|
||||
Restart=always
|
||||
KillMode=mixed
|
||||
|
||||
SyslogIdentifier=dispatcharr-daphne
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
##############################################################################
|
||||
# 13) Configure Nginx
|
||||
##############################################################################
|
||||
|
||||
echo ">>> Configuring Nginx at /etc/nginx/sites-available/dispatcharr.conf ..."
|
||||
cat <<EOF >/etc/nginx/sites-available/dispatcharr.conf
|
||||
echo ">>> Creating Nginx config..."
|
||||
cat <<EOF >/etc/nginx/sites-available/dispatcharr.conf
|
||||
server {
|
||||
listen ${NGINX_HTTP_PORT};
|
||||
|
||||
# Proxy to Gunicorn socket for main HTTP traffic
|
||||
location / {
|
||||
include proxy_params;
|
||||
proxy_pass http://unix:${GUNICORN_SOCKET};
|
||||
}
|
||||
|
||||
# Serve Django static files
|
||||
location /static/ {
|
||||
alias ${APP_DIR}/static/;
|
||||
}
|
||||
|
||||
# Serve React build assets
|
||||
location /assets/ {
|
||||
alias ${APP_DIR}/frontend/dist/assets/;
|
||||
}
|
||||
|
||||
# Serve media files if any
|
||||
location /media/ {
|
||||
alias ${APP_DIR}/media/;
|
||||
}
|
||||
|
||||
# WebSockets for Daphné
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:${WEBSOCKET_PORT};
|
||||
proxy_http_version 1.1;
|
||||
|
|
@ -368,46 +352,66 @@ server {
|
|||
}
|
||||
EOF
|
||||
|
||||
ln -sf /etc/nginx/sites-available/dispatcharr.conf /etc/nginx/sites-enabled/dispatcharr.conf
|
||||
|
||||
# Remove default site if it exists
|
||||
if [ -f /etc/nginx/sites-enabled/default ]; then
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
fi
|
||||
|
||||
echo ">>> Testing Nginx config..."
|
||||
nginx -t
|
||||
|
||||
echo ">>> Restarting Nginx..."
|
||||
systemctl restart nginx
|
||||
systemctl enable nginx
|
||||
ln -sf /etc/nginx/sites-available/dispatcharr.conf /etc/nginx/sites-enabled/dispatcharr.conf
|
||||
[ -f /etc/nginx/sites-enabled/default ] && rm /etc/nginx/sites-enabled/default
|
||||
nginx -t
|
||||
systemctl restart nginx
|
||||
systemctl enable nginx
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# 14) Start & Enable Services
|
||||
# 10) Start Services
|
||||
##############################################################################
|
||||
|
||||
echo ">>> Enabling systemd services..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable dispatcharr
|
||||
systemctl enable dispatcharr-celery
|
||||
systemctl enable dispatcharr-celerybeat
|
||||
systemctl enable dispatcharr-daphne
|
||||
|
||||
echo ">>> Restarting / Starting services..."
|
||||
systemctl restart dispatcharr
|
||||
systemctl restart dispatcharr-celery
|
||||
systemctl restart dispatcharr-celerybeat
|
||||
systemctl restart dispatcharr-daphne
|
||||
start_services() {
|
||||
echo ">>> Enabling and starting services..."
|
||||
systemctl daemon-reexec
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now dispatcharr dispatcharr-celery dispatcharr-celerybeat dispatcharr-daphne
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# Done!
|
||||
# 11) Summary
|
||||
##############################################################################
|
||||
|
||||
echo "================================================="
|
||||
echo "Dispatcharr installation (or update) complete!"
|
||||
echo "Nginx is listening on port ${NGINX_HTTP_PORT}."
|
||||
echo "Gunicorn socket: ${GUNICORN_SOCKET}."
|
||||
echo "WebSockets on port ${WEBSOCKET_PORT} (path /ws/)."
|
||||
echo "You can check logs via 'sudo journalctl -u dispatcharr -f', etc."
|
||||
echo "Visit http://<server_ip>:${NGINX_HTTP_PORT} in your browser."
|
||||
echo "================================================="
|
||||
show_summary() {
|
||||
server_ip=$(ip route get 1 | awk '{print $7; exit}')
|
||||
cat <<EOF
|
||||
=================================================
|
||||
Dispatcharr installation (or update) complete!
|
||||
Nginx is listening on port ${NGINX_HTTP_PORT}.
|
||||
Gunicorn socket: ${GUNICORN_SOCKET}.
|
||||
WebSockets on port ${WEBSOCKET_PORT} (path /ws/).
|
||||
|
||||
You can check logs via:
|
||||
sudo journalctl -u dispatcharr -f
|
||||
sudo journalctl -u dispatcharr-celery -f
|
||||
sudo journalctl -u dispatcharr-celerybeat -f
|
||||
sudo journalctl -u dispatcharr-daphne -f
|
||||
|
||||
Visit the app at:
|
||||
http://${server_ip}:${NGINX_HTTP_PORT}
|
||||
=================================================
|
||||
EOF
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# Run Everything
|
||||
##############################################################################
|
||||
|
||||
main() {
|
||||
show_disclaimer
|
||||
configure_variables
|
||||
install_packages
|
||||
create_dispatcharr_user
|
||||
setup_postgresql
|
||||
clone_dispatcharr_repo
|
||||
setup_python_env
|
||||
build_frontend
|
||||
django_migrate_collectstatic
|
||||
configure_services
|
||||
start_services
|
||||
show_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -863,7 +866,6 @@ export default class API {
|
|||
body = { ...payload };
|
||||
delete body.file;
|
||||
}
|
||||
console.log(body);
|
||||
|
||||
const response = await request(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'PATCH',
|
||||
|
|
@ -1119,6 +1121,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/`);
|
||||
|
|
@ -1239,7 +1283,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) {
|
||||
|
|
@ -1378,7 +1424,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);
|
||||
|
||||
|
|
@ -86,7 +88,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,
|
||||
enable_vod: m3uAccount.enable_vod || false,
|
||||
});
|
||||
|
||||
|
|
@ -147,7 +153,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
|
||||
|
|
@ -179,7 +186,10 @@ const M3U = ({
|
|||
|
||||
const closeGroupFilter = () => {
|
||||
setGroupFilterModalOpen(false);
|
||||
close();
|
||||
};
|
||||
|
||||
const closeFilter = () => {
|
||||
setFilterModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -193,214 +203,238 @@ const M3U = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal size={700} opened={isOpen} onClose={close} title="M3U Account">
|
||||
<LoadingOverlay
|
||||
visible={form.submitting}
|
||||
overlayBlur={2}
|
||||
loaderProps={loadingText ? { children: loadingText } : {}}
|
||||
/>
|
||||
<>
|
||||
<Modal size={700} opened={isOpen} onClose={close} title="M3U Account">
|
||||
<LoadingOverlay
|
||||
visible={form.submitting}
|
||||
overlayBlur={2}
|
||||
loaderProps={loadingText ? { children: loadingText } : {}}
|
||||
/>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
style={{ width: '100%' }}
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this M3U account"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
<TextInput
|
||||
style={{ width: '100%' }}
|
||||
id="server_url"
|
||||
name="server_url"
|
||||
label="URL"
|
||||
description="Direct URL to the M3U playlist or server"
|
||||
{...form.getInputProps('server_url')}
|
||||
key={form.key('server_url')}
|
||||
/>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
style={{ width: '100%' }}
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this M3U account"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
<TextInput
|
||||
style={{ width: '100%' }}
|
||||
id="server_url"
|
||||
name="server_url"
|
||||
label="URL"
|
||||
description="Direct URL to the M3U playlist or server"
|
||||
{...form.getInputProps('server_url')}
|
||||
key={form.key('server_url')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="account_type"
|
||||
name="account_type"
|
||||
label="Account Type"
|
||||
description={<>Standard for direct M3U URLs, <br />Xtream Codes for panel-based services</>}
|
||||
data={[
|
||||
{
|
||||
value: 'STD',
|
||||
label: 'Standard',
|
||||
},
|
||||
{
|
||||
value: 'XC',
|
||||
label: 'Xtream Codes',
|
||||
},
|
||||
]}
|
||||
key={form.key('account_type')}
|
||||
{...form.getInputProps('account_type')}
|
||||
/>
|
||||
<Select
|
||||
id="account_type"
|
||||
name="account_type"
|
||||
label="Account Type"
|
||||
description={
|
||||
<>
|
||||
Standard for direct M3U URLs, <br />
|
||||
Xtream Codes for panel-based services
|
||||
</>
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: 'STD',
|
||||
label: 'Standard',
|
||||
},
|
||||
{
|
||||
value: 'XC',
|
||||
label: 'Xtream Codes',
|
||||
},
|
||||
]}
|
||||
key={form.key('account_type')}
|
||||
{...form.getInputProps('account_type')}
|
||||
/>
|
||||
|
||||
{form.getValues().account_type == 'XC' && (
|
||||
<Box>
|
||||
{!m3uAccount && (
|
||||
<Group justify="space-between">
|
||||
<Box>Create EPG</Box>
|
||||
<Switch
|
||||
id="create_epg"
|
||||
name="create_epg"
|
||||
description="Automatically create matching EPG source for this Xtream account"
|
||||
key={form.key('create_epg')}
|
||||
{...form.getInputProps('create_epg', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{form.getValues().account_type == 'XC' && (
|
||||
<Box>
|
||||
{!m3uAccount && (
|
||||
<Group justify="space-between">
|
||||
<Box>Create EPG</Box>
|
||||
<Box>Enable VOD Scanning</Box>
|
||||
<Switch
|
||||
id="create_epg"
|
||||
name="create_epg"
|
||||
description="Automatically create matching EPG source for this Xtream account"
|
||||
key={form.key('create_epg')}
|
||||
{...form.getInputProps('create_epg', {
|
||||
id="enable_vod"
|
||||
name="enable_vod"
|
||||
description="Scan and import VOD content (movies/series) from this Xtream account"
|
||||
key={form.key('enable_vod')}
|
||||
{...form.getInputProps('enable_vod', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Box>Enable VOD Scanning</Box>
|
||||
<Switch
|
||||
id="enable_vod"
|
||||
name="enable_vod"
|
||||
description="Scan and import VOD content (movies/series) from this Xtream account"
|
||||
key={form.key('enable_vod')}
|
||||
{...form.getInputProps('enable_vod', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
<TextInput
|
||||
id="username"
|
||||
name="username"
|
||||
label="Username"
|
||||
description="Username for Xtream Codes authentication"
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
id="username"
|
||||
name="username"
|
||||
label="Username"
|
||||
description="Username for Xtream Codes authentication"
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
description="Password for Xtream Codes authentication (leave empty to keep existing)"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{form.getValues().account_type != 'XC' && (
|
||||
<FileInput
|
||||
id="file"
|
||||
label="Upload files"
|
||||
placeholder="Upload files"
|
||||
description="Upload a local M3U file instead of using URL"
|
||||
onChange={setFile}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
style={{ width: '100%' }}
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams"
|
||||
placeholder="0 = Unlimited"
|
||||
description="Maximum number of concurrent streams (0 for unlimited)"
|
||||
{...form.getInputProps('max_streams')}
|
||||
key={form.key('max_streams')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
description="User-Agent header to use when accessing this M3U source"
|
||||
{...form.getInputProps('user_agent')}
|
||||
key={form.key('user_agent')}
|
||||
data={[{ value: '0', label: '(Use Default)' }].concat(
|
||||
userAgents.map((ua) => ({
|
||||
label: ua.name,
|
||||
value: `${ua.id}`,
|
||||
}))
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
description="Password for Xtream Codes authentication (leave empty to keep existing)"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description={<>How often to automatically refresh M3U data<br />
|
||||
(0 to disable automatic refreshes)</>}
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
/>
|
||||
{form.getValues().account_type != 'XC' && (
|
||||
<FileInput
|
||||
id="file"
|
||||
label="Upload files"
|
||||
placeholder="Upload files"
|
||||
description="Upload a local M3U file instead of using URL"
|
||||
onChange={setFile}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={365}
|
||||
label="Stale Stream Retention (days)"
|
||||
description="Streams not seen for this many days will be removed"
|
||||
{...form.getInputProps('stale_stream_days')}
|
||||
/>
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
<Checkbox
|
||||
label="Is Active"
|
||||
description="Enable or disable this M3U account"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
style={{ width: '100%' }}
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams"
|
||||
placeholder="0 = Unlimited"
|
||||
description="Maximum number of concurrent streams (0 for unlimited)"
|
||||
{...form.getInputProps('max_streams')}
|
||||
key={form.key('max_streams')}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
{playlist && (
|
||||
<>
|
||||
<Button
|
||||
variant="filled"
|
||||
// color={theme.custom.colors.buttonPrimary}
|
||||
size="sm"
|
||||
onClick={() => setGroupFilterModalOpen(true)}
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
// color={theme.custom.colors.buttonPrimary}
|
||||
size="sm"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
description="User-Agent header to use when accessing this M3U source"
|
||||
{...form.getInputProps('user_agent')}
|
||||
key={form.key('user_agent')}
|
||||
data={[{ value: '0', label: '(Use Default)' }].concat(
|
||||
userAgents.map((ua) => ({
|
||||
label: ua.name,
|
||||
value: `${ua.id}`,
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
disabled={form.submitting}
|
||||
size="sm"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
{playlist && (
|
||||
<>
|
||||
<M3UProfiles
|
||||
playlist={playlist}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
<M3UGroupFilter
|
||||
isOpen={groupFilterModalOpen}
|
||||
playlist={playlist}
|
||||
onClose={closeGroupFilter}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description={
|
||||
<>
|
||||
How often to automatically refresh M3U data
|
||||
<br />
|
||||
(0 to disable automatic refreshes)
|
||||
</>
|
||||
}
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={365}
|
||||
label="Stale Stream Retention (days)"
|
||||
description="Streams not seen for this many days will be removed"
|
||||
{...form.getInputProps('stale_stream_days')}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Is Active"
|
||||
description="Enable or disable this M3U account"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<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}
|
||||
size="sm"
|
||||
onClick={() => setGroupFilterModalOpen(true)}
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
// color={theme.custom.colors.buttonPrimary}
|
||||
size="sm"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
disabled={form.submitting}
|
||||
size="sm"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
{playlist && (
|
||||
<>
|
||||
<M3UProfiles
|
||||
playlist={playlist}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
<M3UGroupFilter
|
||||
isOpen={groupFilterModalOpen}
|
||||
playlist={playlist}
|
||||
onClose={closeGroupFilter}
|
||||
/>
|
||||
<M3UFilters
|
||||
isOpen={filterModalOpen}
|
||||
playlist={playlist}
|
||||
onClose={closeFilter}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
142
frontend/src/components/forms/M3UFilter.jsx
Normal file
142
frontend/src/components/forms/M3UFilter.jsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Modal,
|
||||
Flex,
|
||||
Select,
|
||||
Group,
|
||||
Stack,
|
||||
Switch,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
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);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: {
|
||||
filter_type: 'group',
|
||||
regex_pattern: '',
|
||||
exclude: true,
|
||||
case_sensitive: true,
|
||||
},
|
||||
|
||||
validate: (values) => ({}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (filter) {
|
||||
form.setValues({
|
||||
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();
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
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;
|
||||
await API.addM3UFilter(m3u.id, values);
|
||||
} else {
|
||||
await API.updateM3UFilter(m3u.id, filter.id, values);
|
||||
}
|
||||
|
||||
const updatedPlaylist = await fetchPlaylist(m3u.id);
|
||||
|
||||
form.reset();
|
||||
onClose(updatedPlaylist);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Box>Case Sensitive</Box>
|
||||
<Switch
|
||||
id="case_sensitive"
|
||||
name="case_sensitive"
|
||||
description="If the regex should be case sensitive or not"
|
||||
key={form.key('case_sensitive')}
|
||||
{...form.getInputProps('case_sensitive', {
|
||||
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;
|
||||
340
frontend/src/components/forms/M3UFilters.jsx
Normal file
340
frontend/src/components/forms/M3UFilters.jsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import {
|
||||
Flex,
|
||||
Modal,
|
||||
Button,
|
||||
Box,
|
||||
ActionIcon,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
Center,
|
||||
Group,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { GripHorizontal, Info, 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, editFilter, 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);
|
||||
}
|
||||
|
||||
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);
|
||||
setFilters(filters.filter((f) => f.id !== id));
|
||||
};
|
||||
|
||||
const closeEditor = (updatedPlaylist = null) => {
|
||||
setFilter(null);
|
||||
setEditorOpen(false);
|
||||
|
||||
if (updatedPlaylist) {
|
||||
setFilters(updatedPlaylist.filters);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<Alert
|
||||
icon={<Info size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
style={{ marginBottom: 5 }}
|
||||
>
|
||||
<Text size="sm">
|
||||
<strong>Order Matters!</strong> Rules are processed in the order
|
||||
below. Once a stream matches a given rule, no other rules are
|
||||
checked.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<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}
|
||||
editFilter={editFilter}
|
||||
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;
|
||||
|
|
@ -803,7 +803,12 @@ const M3UTable = () => {
|
|||
return (
|
||||
<Box>
|
||||
<Flex
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 10,
|
||||
}}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
|
|
@ -853,8 +858,7 @@ const M3UTable = () => {
|
|||
padding: 0,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
</Box>
|
||||
></Box>
|
||||
</Paper>
|
||||
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -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,26 @@ 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,
|
||||
},
|
||||
}));
|
||||
|
||||
return playlist;
|
||||
} 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 +111,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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
Django==5.2.4
|
||||
psycopg2-binary==2.9.10
|
||||
redis==6.2.0
|
||||
celery
|
||||
celery[redis]
|
||||
celery[redis]==5.5.3
|
||||
djangorestframework==3.16.0
|
||||
requests==2.32.4
|
||||
psutil==7.0.0
|
||||
|
|
@ -25,7 +23,7 @@ tzlocal
|
|||
torch==2.7.1+cpu
|
||||
|
||||
# ML/NLP dependencies
|
||||
sentence-transformers==5.0.0
|
||||
sentence-transformers==5.1.0
|
||||
channels
|
||||
channels-redis==4.3.0
|
||||
django-filter
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Dispatcharr version information.
|
||||
"""
|
||||
__version__ = '0.7.1' # Follow semantic versioning (MAJOR.MINOR.PATCH)
|
||||
__version__ = '0.8.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
|
||||
__timestamp__ = None # Set during CI/CD build process
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue